Major changes

This commit is contained in:
Anders Knutsen 2025-08-05 13:50:41 +02:00
parent f03151c8a4
commit 68d10967db
34 changed files with 1773 additions and 133 deletions

View File

@ -1,10 +1,19 @@
# Use an official Python runtime as a parent image FROM ubuntu:24.04
FROM python:3.11-slim
COPY requirements.txt . WORKDIR /app
RUN pip install --no-cache-dir -r requirements.txt COPY backend/tcp_sia_server.bin ./tcp_sia_server.bin
COPY backend/main.bin ./main.bin
COPY backend/watchdog.bin ./watchdog.bin
EXPOSE 9000 RUN chmod -R 777 /tmp
CMD ["python", "/backend/tcp_sia_server.py"] RUN apt-get update && apt-get install -y \
libstdc++6 libgcc-s1 libexpat1 zlib1g libffi8 \
ca-certificates \
default-mysql-client libmysqlclient21 \
&& chmod +x /app/tcp_sia_server.bin /app/main.bin \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
#CMD ["/app/tcp_sia_server.bin", "9000"]
CMD ["/app/main.bin"]

4
Dockerfile.php Normal file
View File

@ -0,0 +1,4 @@
FROM php:8.2-fpm
RUN docker-php-ext-install pdo pdo_mysql sockets

BIN
backend/main.bin Executable file

Binary file not shown.

211
backend/main.py Normal file
View File

@ -0,0 +1,211 @@
import subprocess
import threading
import time
import requests
import os
import pymysql
import logging
import bcrypt
import configparser
from functools import partial
CONFIG_PATH = "/app/config.ini"
#CONFIG_PATH = "../config.ini"
config = configparser.ConfigParser()
config.read(CONFIG_PATH)
loglevel_str = config["log"]["level"].upper()
loglevel = getattr(logging, loglevel_str, logging.INFO)
logging.basicConfig(level=loglevel)
DB_CONFIG = {
'host': config["database"]["host"],
'user': config["database"]["user"],
'password': config["database"]["password"],
'database': config["database"]["database"]
}
LICENSE_SERVER = "https://sikkerdata.com/validate_license.php"
LICENSE_KEY = config["license"]["license_key"]
with open("/etc/machine-id", "r") as f:
get_HWID = f.read().strip()
def validate_license():
try:
hwid = get_HWID
response = requests.post(LICENSE_SERVER, json={
"license_key": LICENSE_KEY,
"hwid": hwid
}, timeout=5)
logging.info(f"[MAIN] License server response: {response.text}")
if response.status_code == 200:
result = response.json()
if result.get("status") == "valid":
logging.info("[MAIN] License validated")
return True
else:
logging.info(f"[MAIN] License check failed: {result.get('message')}")
return False
else:
logging.info(f"[MAIN] License server error: {response.status_code}")
return False
except Exception as e:
logging.info(f"[MAIN] License check exception: {e}")
return False
def setup_database(db_config):
conn = pymysql.connect(**db_config)
cursor = conn.cursor()
def table_exists(name):
cursor.execute("SHOW TABLES LIKE %s", (name,))
return cursor.fetchone() is not None
# Create `users` table if missing
if not table_exists('users'):
print("[DB] Creating 'users' table...")
cursor.execute("""
CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
full_name VARCHAR(100) NOT NULL,
email VARCHAR(255) NOT NULL UNIQUE,
phone VARCHAR(20),
password_hash VARCHAR(255) NOT NULL,
totp_secret VARCHAR(64),
user_role ENUM('Admin','Operator','Installer','Client') DEFAULT 'Client',
access_group VARCHAR(100),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
""")
# Create `signals` table if missing
if not table_exists('signals'):
print("[DB] Creating 'signals' table...")
cursor.execute("""
CREATE TABLE signals (
id INT AUTO_INCREMENT PRIMARY KEY,
timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
protocol VARCHAR(10) NOT NULL,
raw_message TEXT NOT NULL,
account VARCHAR(20),
sequence VARCHAR(10),
line_number VARCHAR(10),
event_code VARCHAR(10),
partition VARCHAR(10),
zone VARCHAR(20),
signal_time DATETIME,
source_ip VARCHAR(45),
signal_text VARCHAR(255),
v TEXT,
x TEXT,
y TEXT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
""")
if not table_exists('receivers'):
print("[DB] Creating 'receivers' table...")
cursor.execute("""
CREATE TABLE receivers (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(50) NOT NULL,
type ENUM('SIA-DC09') NOT NULL DEFAULT 'SIA-DC09',
tcpport INT NOT NULL,
enabled BOOLEAN DEFAULT 1
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
""")
cursor.execute("""INSERT INTO receivers (name, type, tcpport, enabled) VALUES ('Main Receiver', 'SIA-DC09', 9000, 1);""")
# If no users exist, create default admin
cursor.execute("SELECT COUNT(*) FROM users")
(user_count,) = cursor.fetchone()
if user_count == 0:
print("[DB] Inserting default admin user...")
default_password = bcrypt.hashpw("admin123".encode(), bcrypt.gensalt()).decode('utf-8')
cursor.execute("""
INSERT INTO users (username, full_name, email, password_hash, user_role)
VALUES (%s, %s, %s, %s, %s)
""", ("admin", "Admin", "admin@example.com", default_password, "Admin"))
print("[DB] Admin created with username 'admin' and password 'admin123'")
conn.commit()
cursor.close()
conn.close()
running_services = {}
def watchdog():
bin_path = "/app/watchdog.bin"
def run_watchdog():
while True:
cmd = [bin_path]
logging.debug(f"[MAIN] Starting Watchdog...")
process = subprocess.Popen(cmd)
running_services["Watchdog"] = process
process.wait()
logging.debug(f"[MAIN] Watchdog finished. Restarting in 5s...")
time.sleep(5)
thread = threading.Thread(target=run_watchdog, name="Watchdog", daemon=True)
thread.start()
def is_watchdog_running():
return any(thread.name == "Watchdog" and thread.is_alive() for thread in threading.enumerate())
def main():
logging.info("[MAIN] Starting main function")
try:
if not validate_license():
logging.info("[MAIN] Shutting down due to invalid license.")
return
setup_thread = threading.Thread(target=partial(setup_database, DB_CONFIG), daemon=True)
setup_thread.start()
setup_thread.join() # Wait for DB setup before continuing
while True:
try:
logging.debug(f"[MAIN] Main is running")
if is_watchdog_running():
logging.debug("[MAIN] Watchdog thread is already running")
else:
watchdog()
time.sleep(10)
except Exception as e:
logging.exception(f"[MAIN] Exception while logging thread status: {e}")
except KeyboardInterrupt:
logging.info("Graceful shutdown requested")
def wait_for_mariadb(host, port, user, password, database, timeout=5):
logging.info(f"[WAIT] Waiting for MariaDB at {host}:{port} to become ready...")
while True:
try:
conn = pymysql.connect(
host=host,
port=port,
user=user,
password=password,
database=database,
connect_timeout=timeout
)
with conn.cursor() as cursor:
cursor.execute("SELECT 1;")
result = cursor.fetchone()
if result:
logging.info("[WAIT] MariaDB is ready.")
conn.close()
break
except pymysql.MySQLError as e:
logging.warning(f"[WAIT] MariaDB not ready yet: {e}")
time.sleep(timeout)
if __name__ == "__main__":
wait_for_mariadb(
host=config["database"]["host"],
port=3306,
user=config["database"]["user"],
password=config["database"]["password"],
database=config["database"]["database"]
)
main()

BIN
backend/tcp_sia_server.bin Executable file

Binary file not shown.

View File

@ -1,9 +1,19 @@
import asyncio import asyncio
import sys
from crccheck.crc import CrcArc from crccheck.crc import CrcArc
import re import re
import json import json
import mysql.connector import pymysql
from datetime import datetime from datetime import datetime
import logging
import configparser
CONFIG_PATH = "/app/config.ini"
config = configparser.ConfigParser()
config.read(CONFIG_PATH)
loglevel_str = config["log"]["level"].upper()
loglevel = getattr(logging, loglevel_str, logging.INFO)
logging.basicConfig(level=loglevel)
def verify_message(message: bytes) -> tuple[bool, str]: def verify_message(message: bytes) -> tuple[bool, str]:
""" """
@ -12,11 +22,11 @@ def verify_message(message: bytes) -> tuple[bool, str]:
""" """
if len(message) < 10: if len(message) < 10:
print("Message too short") logging.info("Message too short")
return False, None return False, None
if message[0] != 10 or message[-1] != 13: if message[0] != 10 or message[-1] != 13:
print("Message must start with LF (\\n) and end with CR (\\r)") logging.info("Message must start with LF (\\n) and end with CR (\\r)")
return False, None return False, None
crc_bytes = message[1:5] # 4 hex chars crc_bytes = message[1:5] # 4 hex chars
@ -28,43 +38,42 @@ def verify_message(message: bytes) -> tuple[bool, str]:
length_str = length_bytes.decode('ascii') length_str = length_bytes.decode('ascii')
payload_str = data_bytes.decode('ascii') payload_str = data_bytes.decode('ascii')
except UnicodeDecodeError: except UnicodeDecodeError:
print("Non-ASCII characters in message") logging.info("Non-ASCII characters in message")
return False, None return False, None
try: try:
crc_recv = int(crc_str, 16) crc_recv = int(crc_str, 16)
length_recv = int(length_str, 16) length_recv = int(length_str, 16)
except ValueError: except ValueError:
print("CRC or length not hex") logging.info("CRC or length not hex")
return False, None return False, None
if length_recv != len(data_bytes): if length_recv != len(data_bytes):
print(f"Length mismatch: declared {length_recv}, actual {len(data_bytes)}") logging.info(f"Length mismatch: declared {length_recv}, actual {len(data_bytes)}")
return False, None return False, None
crc_calc = CrcArc.calc(data_bytes) crc_calc = CrcArc.calc(data_bytes)
if crc_calc != crc_recv: if crc_calc != crc_recv:
print(f"CRC mismatch: received {crc_str} calculated {crc_calc:04X}") logging.info(f"CRC mismatch: received {crc_str} calculated {crc_calc:04X}")
return False, None return False, None
print(f"Valid message received: {payload_str}") logging.info(f"Valid message received: {payload_str}")
return True, payload_str return True, payload_str
def parse_sia_payload(payload: str) -> dict: def parse_sia_payload(payload: str) -> dict:
""" """
Extracts fields from SIA-DC09 message according to the full protocol spec. Extracts fields from SIA-DCS or ADM-CID message according to the protocol spec.
Example: Examples:
"SIA-DCS"0005L0#1234[1234|Nri1/RP00][Vimage.jpg][X010E57.5698][Y59N12.8358] "SIA-DCS"0005L0#1234[1234|Nri1/RP00][Vimage.jpg][X010E57.5698][Y59N12.8358]
"ADM-CID"0000RF3L56789#1234[#1234|1131 01 015]
""" """
result = { result = {
"format": None, "format": None,
"seq": None, "seq": None,
"line": None, "line": None,
"account": None, "account": None,
"receiver": None, "receiver": None,
"event": None,
"zone": None, "zone": None,
"partition": None, "partition": None,
"code": None, "code": None,
@ -72,6 +81,7 @@ def parse_sia_payload(payload: str) -> dict:
"y": None, "y": None,
"v": None, "v": None,
"raw": payload, "raw": payload,
"signal_text": None,
} }
# Extract format and header # Extract format and header
@ -82,132 +92,228 @@ def parse_sia_payload(payload: str) -> dict:
result["line"] = header_match.group(3) result["line"] = header_match.group(3)
result["account"] = header_match.group(4) result["account"] = header_match.group(4)
# Main event block: [account|receiver/eventcode] # Parse based on message format
event_match = re.search(r'\[\#?(\d+)\|([^\]]+)\]', payload) format_type = result["format"]
if event_match:
result["account_verify"] = event_match.group(1) if format_type == "SIA-DCS":
receiver_event = event_match.group(2) # Main event block: [account|receiver/eventcode]
event_match = re.search(r'\[\#?(\d+)\|([^\]]+)\]', payload)
if event_match:
result["account_verify"] = event_match.group(1)
receiver_event = event_match.group(2)
# Handle both with and without slash
if "/" in receiver_event:
receiver, event = receiver_event.split("/", 1)
else:
match = re.match(r'(Nri\d+)([A-Z]{2}\d+.*)', receiver_event)
if match:
receiver = match.group(1)
event = match.group(2)
else:
receiver = ""
event = receiver_event # fallback
# Split by slash
if "/" in receiver_event:
receiver, event = receiver_event.split("/", 1)
result["receiver"] = receiver result["receiver"] = receiver
result["event"] = event
# Optional: split event into type (2 chars), zone (23 digits) # Extract code and zone from event (e.g., OP01, BA05)
event_code_match = re.match(r'([A-Z]{2})(\d+)', event) event_code_match = re.match(r'([A-Z]{2})(\d+)', event)
if event_code_match: if event_code_match:
result["code"] = event_code_match.group(1) result["code"] = event_code_match.group(1)
result["zone"] = event_code_match.group(2) result["zone"] = event_code_match.group(2)
# Extract partition from receiver (e.g., "Nri0" -> "0") # Extract partition from receiver (e.g., Nri1 → 1)
partition_match = re.match(r'Nri(\d+)', receiver) partition_match = re.match(r'Nri(\d+)', receiver)
if partition_match: if partition_match:
result["partition"] = partition_match.group(1) result["partition"] = partition_match.group(1)
# Optional V block (usually image URL) # Optional signal text (e.g., ^Fire alarm triggered^)
v_match = re.search(r'\[V([^\]]*)\]', payload) text_match = re.search(r'\^([^^]+)\^', event)
if v_match: if text_match:
result["v"] = v_match.group(1) result["signal_text"] = text_match.group(1)
# Optional GPS data # Optional V block (usually image or file name)
x_match = re.search(r'\[X([^\]]+)\]', payload) v_match = re.search(r'\[V([^\]]*)\]', payload)
y_match = re.search(r'\[Y([^\]]+)\]', payload) if v_match:
if x_match: result["v"] = v_match.group(1)
result["x"] = x_match.group(1)
if y_match:
result["y"] = y_match.group(1)
# Optional GPS data
x_match = re.search(r'\[X([^\]]+)\]', payload)
y_match = re.search(r'\[Y([^\]]+)\]', payload)
if x_match:
result["x"] = x_match.group(1)
if y_match:
result["y"] = y_match.group(1)
elif format_type == "ADM-CID":
# Extract format
format_match = re.search(r'"(ADM-CID)"(\d{4})[A-Z]*L(\d)#(\d+)', payload)
if format_match:
result["format"] = format_match.group(1)
result["seq"] = format_match.group(2)
result["line"] = format_match.group(3)
result["account"] = format_match.group(4)
# Parse event block: [account|event_code partition zone]
event_match = re.search(r'\[#?(\d+)\|([A-Z0-9]{3,4}) (\d{2}) (\d{3})\]', payload)
if event_match:
result["account_verify"] = event_match.group(1)
result["code"] = event_match.group(2)
result["partition"] = event_match.group(3)
result["zone"] = event_match.group(4)
# Optional fields
timestamp_match = re.search(r'_(\d{2}:\d{2}:\d{2},\d{2}-\d{2}-\d{4})', payload)
if timestamp_match:
result["timestamp"] = timestamp_match.group(1)
v_match = re.search(r'\[V([^\]]*)\]', payload)
if v_match:
result["v"] = v_match.group(1)
x_match = re.search(r'\[X([^\]]+)\]', payload)
y_match = re.search(r'\[Y([^\]]+)\]', payload)
if x_match:
result["x"] = x_match.group(1)
if y_match:
result["y"] = y_match.group(1)
# Optional timestamp: _hh:mm:ss,mm-dd-yyyy
time_match = re.search(r'_(\d{2}:\d{2}:\d{2}),(\d{2}-\d{2}-\d{4})', payload) time_match = re.search(r'_(\d{2}:\d{2}:\d{2}),(\d{2}-\d{2}-\d{4})', payload)
if time_match: if time_match:
time_str = f"{time_match.group(2)} {time_match.group(1)}" # e.g., 07-25-2025 07:33:01 time_str = f"{time_match.group(2)} {time_match.group(1)}"
result["signal_time"] = datetime.strptime(time_str, "%m-%d-%Y %H:%M:%S").isoformat() result["signal_time"] = datetime.strptime(time_str, "%m-%d-%Y %H:%M:%S").isoformat()
return result return result
def build_nack(seq: str, account: str, receiver: str = '0') -> bytes:
lf = chr(0x0A)
cr = chr(0x0D)
payload = f'"NACK"{seq}L{receiver}#{account}[]'
crc = CrcArc.calc(payload.encode())
length = str(len(payload)).zfill(4)
return f"{lf}{crc:04X}{length}{payload}{cr}".encode()
import asyncio import asyncio
async def handle_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter): async def handle_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
db_config = {
'host': config["database"]["host"],
'user': config["database"]["user"],
'password': config["database"]["password"],
'database': config["database"]["database"]
}
addr = writer.get_extra_info('peername') addr = writer.get_extra_info('peername')
print(f"Connection from {addr}") lf = chr(0x0A) # LF
cr = chr(0x0D) # CR
logging.info(f"Connection from {addr}")
try: try:
data = await reader.readuntil(b'\r') while True:
print(f"Raw received: {data}") try:
data = await reader.readuntil(b'\r')
except asyncio.IncompleteReadError:
logging.info("Client closed connection")
break
is_valid, payload = verify_message(data) logging.info(f"Raw received: {data}")
response = b"\n0004NACK\r" # fallback
if is_valid and payload: is_valid, payload = verify_message(data)
parsed = parse_sia_payload(payload) response = b'\n"NACK"0000L0#0000[]\r' # Default response
parsed["source_ip"] = str(addr[0])
print("Parsed payload:")
print(json.dumps(parsed, indent=2))
sia_data = parsed
# Connect to MariaDB
conn = mysql.connector.connect(
host="192.168.10.57",
user="admin",
password="yourpassword",
database="superarc"
)
cursor = conn.cursor()
# Insert data
query = """
INSERT INTO signals (
protocol, raw_message, account, sequence,
line_number, event_code, `partition`, zone, signal_time, source_ip
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
"""
values = (
sia_data["format"],
sia_data["raw"],
sia_data.get("account"),
sia_data.get("seq"),
sia_data.get("line"),
sia_data.get("code"),
sia_data.get("partition"),
sia_data.get("zone"),
sia_data.get("signal_time"),
sia_data.get("source_ip")
)
cursor.execute(query, values) if is_valid and payload:
conn.commit() parsed = parse_sia_payload(payload)
print("Inserted signal into database.") parsed["source_ip"] = str(addr[0])
cursor.close() logging.info("Parsed payload:")
conn.close() logging.info(json.dumps(parsed, indent=2))
seq = parsed.get("seq", "0000")
account = parsed.get("account", "0000")
response = build_nack(seq=seq, account=account)
seq = parsed.get("seq", "0000") # Validate required fields
account = parsed.get("account", "0000") required_format = parsed.get("format")
required_fields = [parsed.get("account"), parsed.get("code"), parsed.get("zone")]
response_payload = f'"ACK"{seq}L0#{account}[]' if required_format in ["SIA-DCS","ADM-CID"]:
crc = CrcArc.calc(response_payload.encode()) if any(f is None for f in required_fields):
length = str(hex(len(response_payload))).split('x') logging.info("Missing required SIA fields — sending NACK.")
response = f"\n{crc:04X}00{length[1].upper()}{response_payload}\r".encode() # build and send NACK
else:
logging.info("Valid SIA-DCS signal.")
# Connect to MariaDB
conn = pymysql.connect(**db_config)
cursor = conn.cursor()
query = """
INSERT INTO signals (
protocol, raw_message, account, sequence,
line_number, event_code, `partition`, zone, signal_time, source_ip, signal_text, x, y, v
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
"""
values = (
parsed["format"],
parsed["raw"],
parsed.get("account"),
parsed.get("seq"),
parsed.get("line"),
parsed.get("code"),
parsed.get("partition"),
parsed.get("zone"),
parsed.get("signal_time"),
parsed.get("source_ip"),
parsed.get("signal_text"),
parsed.get("x"),
parsed.get("y"),
parsed.get("v")
)
cursor.execute(query, values)
conn.commit()
logging.info("Inserted signal into database.")
cursor.close()
conn.close()
print(f"Sending response: {response}") # Build ACK response
else: response_payload = f'"ACK"{seq}L0#{account}[]'
print("Invalid message. Sending fallback NACK.") crc = CrcArc.calc(response_payload.encode())
length = str(len(response_payload)).zfill(4)
response = f"{lf}{crc:04X}{length}{response_payload}{cr}".encode()
logging.info(f"Sending response: {response}")
elif required_format == "NULL":
logging.info("Received NULL signal, skipping field validation.")
# Build ACK response
response_payload = f'"ACK"{seq}L0#{account}[]'
crc = CrcArc.calc(response_payload.encode())
length = str(len(response_payload)).zfill(4)
response = f"{lf}{crc:04X}{length}{response_payload}{cr}".encode()
logging.info(f"Sending response: {response}")
# allow
else:
logging.info("Invalid CRC or malformed message — sending fallback NACK.")
writer.write(response) writer.write(response)
await writer.drain() await writer.drain()
except asyncio.IncompleteReadError:
print("Connection closed before end of message")
except Exception as e: except Exception as e:
print(f"Error: {e}") logging.info(f"Unexpected error: {e}")
finally: finally:
writer.close() writer.close()
await writer.wait_closed() await writer.wait_closed()
print(f"Connection with {addr} closed") logging.info(f"Connection with {addr} closed")
async def main(): async def main(port):
server = await asyncio.start_server(handle_client, host='0.0.0.0', port=9000) server = await asyncio.start_server(handle_client, host='0.0.0.0', port=port)
print("Listening on TCP port 9000 for SIA-DCS messages") logging.info(f"Listening on TCP port {port} for SIA-DCS messages")
async with server: async with server:
await server.serve_forever() await server.serve_forever()
if __name__ == '__main__': if __name__ == '__main__':
asyncio.run(main()) if len(sys.argv) < 2:
print("Usage: tcp_sia_server.bin <port>")
sys.exit(1)
try:
port = int(sys.argv[1])
asyncio.run(main(port))
except Exception as e:
logging.error(f"[SIA] Failed to start server: {e}")

BIN
backend/watchdog.bin Executable file

Binary file not shown.

137
backend/watchdog.py Normal file
View File

@ -0,0 +1,137 @@
import subprocess
import threading
import time
import pymysql
import logging
import configparser
import psutil
CONFIG_PATH = "/app/config.ini"
config = configparser.ConfigParser()
config.read(CONFIG_PATH)
loglevel_str = config["log"]["level"].upper()
loglevel = getattr(logging, loglevel_str, logging.INFO)
logging.basicConfig(level=loglevel)
DB_CONFIG = {
'host': config["database"]["host"],
'user': config["database"]["user"],
'password': config["database"]["password"],
'database': config["database"]["database"]
}
running_services = {}
def get_process_on_port(port):
for conn in psutil.net_connections(kind='inet'):
if conn.laddr.port == port and conn.status == psutil.CONN_LISTEN:
pid = conn.pid
if pid is None:
continue
try:
proc = psutil.Process(pid)
return {
'pid': pid,
'name': proc.name(),
'cmdline': proc.cmdline()
}
except (psutil.NoSuchProcess, psutil.AccessDenied):
continue
return None
def initialize_running_services_from_db(db_config):
try:
conn = pymysql.connect(**db_config)
cursor = conn.cursor(pymysql.cursors.DictCursor)
cursor.execute("SELECT name, tcpport FROM receivers;")
rows = cursor.fetchall()
for row in rows:
name = row['name']
port = row['tcpport']
proc_info = get_process_on_port(port)
if proc_info:
logging.info(f"[INIT] Found existing service '{name}' on port {port}, PID={proc_info['pid']}, Cmd={proc_info['cmdline']}")
running_services[name] = {
"pid": proc_info["pid"],
"cmdline": proc_info["cmdline"]
}
cursor.close()
conn.close()
except Exception as e:
logging.exception("[INIT] Failed to detect existing services")
def watchdog(db_config):
global running_services
while True:
try:
thread_conn = pymysql.connect(**db_config)
cursor = thread_conn.cursor(pymysql.cursors.DictCursor)
cursor.execute("SELECT name, type, tcpport, enabled FROM receivers;")
rows = cursor.fetchall()
cursor.close()
thread_conn.close()
for row in rows:
name = row["name"]
receiver_type = row["type"]
port = row["tcpport"]
enabled = row["enabled"]
if receiver_type != "SIA-DC09":
continue
if enabled == 1 and name not in running_services:
binary_path = "/app/tcp_sia_server.bin"
logging.info(f"[WATCHDOG] Starting receiver '{name}' on port {port}")
start_service(name, binary_path, port)
elif enabled == 0:
proc_info = running_services.get(name)
if proc_info and isinstance(proc_info, subprocess.Popen):
logging.info(f"[WATCHDOG] Stopping disabled receiver '{name}' (started by script)")
proc_info.terminate()
proc_info.wait()
del running_services[name]
else:
# Kill external process using psutil
port = row["tcpport"]
try:
for conn in psutil.net_connections(kind='inet'):
if conn.laddr.port == port and conn.status == psutil.CONN_LISTEN:
pid = conn.pid
if pid:
logging.warning(
f"[WATCHDOG] Force killing external process on port {port} (PID {pid}) for receiver '{name}'")
psutil.Process(pid).kill()
if name in running_services:
del running_services[name]
except Exception as e:
logging.exception(f"[WATCHDOG] Failed to kill external process for receiver '{name}'")
log_thread_status()
time.sleep(3)
except Exception as e:
logging.exception(f"[WATCHDOG] Error during check: {e}")
def start_service(name, binary_path, port=None):
def run():
cmd = [binary_path]
if port:
cmd.append(str(port))
logging.info(f"[WATCHDOG] Starting {name} on port {port}...")
process = subprocess.Popen(cmd)
running_services[name] = {
"popen": process
}
process.wait()
logging.warning(f"[WATCHDOG] {name} exited.")
thread = threading.Thread(target=run, daemon=True)
thread.start()
def log_thread_status():
running = list(running_services.keys())
logging.debug(f"[STATUS] Running services: {running if running else 'None'}")
if __name__ == "__main__":
initialize_running_services_from_db(DB_CONFIG)
logging.debug(f"[INIT] Active running_services: {list(running_services.keys())}")
watchdog(DB_CONFIG)

11
config.ini Normal file
View File

@ -0,0 +1,11 @@
[license]
license_key = ABCDEF-123456-XYZ789
[database]
host = 127.0.0.1
user = admin
password = yourpassword
database = superarc
[log]
level = INFO

View File

@ -1,5 +1,4 @@
services: services:
mariadb: mariadb:
image: mariadb:10.9 image: mariadb:10.9
environment: environment:
@ -18,26 +17,27 @@ services:
volumes: volumes:
- ./frontend:/usr/share/nginx/html:ro - ./frontend:/usr/share/nginx/html:ro
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
ports: network_mode: "host"
- "80:80"
depends_on: depends_on:
- mariadb - mariadb
- php-fpm - php-fpm
php-fpm: php-fpm:
image: php:8.2-fpm
volumes:
- ./frontend:/usr/share/nginx/html
tcp_sia_server:
build: build:
context: . context: .
ports: dockerfile: Dockerfile.php
- "9000:9000"
volumes: volumes:
- ./backend/:/app - ./frontend:/usr/share/nginx/html
working_dir: /app network_mode: "host"
command: python -u /app/tcp_sia_server.py
main:
build:
context: .
dockerfile: Dockerfile
network_mode: "host"
volumes:
- ./config.ini:/app/config.ini:ro
- /etc/machine-id:/etc/machine-id:ro
depends_on: depends_on:
- mariadb - mariadb

2
frontend/.user.ini Normal file
View File

@ -0,0 +1,2 @@
error_reporting = E_ALL & ~E_DEPRECATED
display_errors = On

76
frontend/add_user.php Normal file
View File

@ -0,0 +1,76 @@
<?php
require 'auth.php';
requireAdmin();
require 'db.php';
require 'vendor/autoload.php';
$message = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$username = $_POST['username'];
$full_name = $_POST['full_name'];
$email = $_POST['email'];
$phone = $_POST['phone'];
$password = password_hash($_POST['password'], PASSWORD_BCRYPT);
$user_role = $_POST['user_role'];
$access_group = $_POST['access_group'] ?? '';
// Generate 2FA secret
//$ga = new PHPGangsta_GoogleAuthenticator();
//$totp_secret = $ga->createSecret();
$stmt = $pdo->prepare("INSERT INTO users (username, full_name, email, phone, password_hash, user_role, access_group)
VALUES (?, ?, ?, ?, ?, ?, ?)");
$stmt->execute([$username, $full_name, $email, $phone, $password, $user_role, $access_group]);
$message = "User '$username' created successfully.";
}
?>
<?php include 'header.php'; ?>
<div class="container mt-5">
<h2>Add New User</h2>
<?php if ($message): ?>
<div class="alert alert-success"><?= htmlspecialchars($message) ?></div>
<?php endif; ?>
<form method="POST">
<div class="mb-3">
<label class="form-label">Username</label>
<input type="text" name="username" required class="form-control">
</div>
<div class="mb-3">
<label class="form-label">Full Name</label>
<input type="text" name="full_name" required class="form-control">
</div>
<div class="mb-3">
<label class="form-label">Email</label>
<input type="email" name="email" required class="form-control">
</div>
<div class="mb-3">
<label class="form-label">Phone</label>
<input type="text" name="phone" class="form-control">
</div>
<div class="mb-3">
<label class="form-label">Password</label>
<input type="password" name="password" required class="form-control">
</div>
<div class="mb-3">
<label class="form-label">Role</label>
<select name="user_role" class="form-select" required>
<option value="Admin">Admin</option>
<option value="Operator">Operator</option>
<option value="Installer">Installer</option>
<option value="Client">Client</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">Access Group</label>
<input type="text" name="access_group" class="form-control">
</div>
<button type="button" class="btn btn-danger ms-2" onclick="history.back()">Cancel</button> <button type="submit" class="btn btn-primary">Create User</button>
</form>
</div>
<?php include 'footer.php'; ?>

29
frontend/auth.php Normal file
View File

@ -0,0 +1,29 @@
<?php
session_start();
require 'db.php';
function isLoggedIn() {
return isset($_SESSION['user_id']) && isset($_SESSION['2fa_verified']) && $_SESSION['2fa_verified'] === true;
}
function requireLogin() {
if (!isLoggedIn()) {
header("Location: login.php");
exit;
}
}
function requireLoginBefore2FA() {
if (!isset($_SESSION['user_id'])) {
header('Location: login.php');
exit;
}
}
function requireAdmin() {
requireLogin();
if ($_SESSION['user_role'] !== 'Admin') {
die('Access denied: Admins only.');
}
}
?>

7
frontend/composer.json Normal file
View File

@ -0,0 +1,7 @@
{
"require": {
"phpgangsta/googleauthenticator": "dev-master"
},
"minimum-stability": "dev",
"prefer-stable": true
}

23
frontend/create_user.php Normal file
View File

@ -0,0 +1,23 @@
<?php
require 'db.php';
require 'vendor/autoload.php';
$ga = new PHPGangsta_GoogleAuthenticator();
$secret = $ga->createSecret();
$qrCodeUrl = $ga->getQRCodeGoogleUrl('SuperArc', $secret);
$username = 'anders';
$fullname = 'Anders Ostensvik';
$email = 'anders@ostsik.no';
$phone = '1234567890';
$password = password_hash('yourpassword', PASSWORD_BCRYPT);
$user_role = 'Admin'; // must be one of Admin, Operator, Installer, Client
$access_group = 'default';
$stmt = $pdo->prepare("INSERT INTO users (username, full_name, email, phone, password_hash, totp_secret, user_role, access_group) VALUES (?, ?, ?, ?, ?, ?, ?, ?)");
$stmt->execute([$username, $fullname, $email, $phone, $password, $secret, $user_role, $access_group]);
echo "User created. Scan this QR in Google Authenticator: <br>";
echo "<img src='$qrCodeUrl'>";
?>

4
frontend/db.php Normal file
View File

@ -0,0 +1,4 @@
<?php
$pdo = new PDO('mysql:host=127.0.0.1;dbname=superarc;charset=utf8mb4', 'admin', 'yourpassword');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
?>

183
frontend/edit_user.php Normal file
View File

@ -0,0 +1,183 @@
<?php
require 'auth.php';
requireAdmin();
require 'db.php';
$message = '';
$selectedUser = null;
// Fetch all users with full info for the list and modal
$users = $pdo->query("SELECT id, username, full_name, email, user_role, phone, access_group FROM users ORDER BY username")->fetchAll(PDO::FETCH_ASSOC);
// When a user is selected
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['select_user'])) {
$userId = $_POST['user_id'];
$stmt = $pdo->prepare("SELECT * FROM users WHERE id = ?");
$stmt->execute([$userId]);
$selectedUser = $stmt->fetch(PDO::FETCH_ASSOC);
}
// When user info is updated
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['update_user'])) {
$userId = $_POST['user_id'];
$full_name = $_POST['full_name'];
$email = $_POST['email'];
$phone = $_POST['phone'];
$user_role = $_POST['user_role'];
$access_group = $_POST['access_group'];
$stmt = $pdo->prepare("UPDATE users SET full_name = ?, email = ?, phone = ?, user_role = ?, access_group = ? WHERE id = ?");
$stmt->execute([$full_name, $email, $phone, $user_role, $access_group, $userId]);
$message = "User updated successfully.";
// Reload updated user
$stmt = $pdo->prepare("SELECT * FROM users WHERE id = ?");
$stmt->execute([$userId]);
$selectedUser = $stmt->fetch(PDO::FETCH_ASSOC);
}
?>
<?php include 'header.php'; ?>
<div class="container mt-4">
<h2><?= t('Users') ?>
<a href="add_user.php">
<button name="add_user" class="btn btn-success"><?= t('Add user') ?></button>
</a>
</h2>
<?php if ($message): ?>
<div class="alert alert-success"><?= htmlspecialchars(t($message)) ?></div>
<?php endif; ?>
<div class="mb-3">
<label for="filterRole" class="form-label"><?= t('Filter by Role') ?></label>
<select id="filterRole" class="form-select">
<option value=""><?= t('All Roles') ?></option>
<option value="Admin"><?= t('Admin') ?></option>
<option value="Operator"><?= t('Operator') ?></option>
<option value="Installer"><?= t('Installer') ?></option>
<option value="Client"><?= t('Client') ?></option>
</select>
</div>
<div class="mb-3">
<label for="searchUser" class="form-label"><?= t('Search User') ?></label>
<input type="text" id="searchUser" class="form-control" placeholder="<?= t('Search by name, username, or email...') ?>">
</div>
<table class="table table-hover" id="userTable" style="cursor: pointer;">
<thead>
<tr>
<th><?= t('Full Name') ?></th>
<th><?= t('Username') ?></th>
<th><?= t('Email') ?></th>
<th><?= t('Role') ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($users as $user): ?>
<tr
data-user='<?= json_encode($user, JSON_HEX_APOS | JSON_HEX_QUOT) ?>'
data-role="<?= htmlspecialchars($user['user_role']) ?>"
>
<td><?= htmlspecialchars($user['full_name']) ?></td>
<td><?= htmlspecialchars($user['username']) ?></td>
<td><?= htmlspecialchars($user['email']) ?></td>
<td><?= htmlspecialchars($user['user_role']) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<!-- Modal -->
<div class="modal fade" id="editUserModal" tabindex="-1" aria-labelledby="editUserModalLabel" aria-hidden="true">
<div class="modal-dialog">
<form id="editUserForm" method="POST" class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="editUserModalLabel"><?= t('Edit User') ?></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<input type="hidden" name="user_id" id="modal_user_id">
<div class="mb-3">
<label class="form-label"><?= t('Full Name') ?></label>
<input type="text" name="full_name" id="modal_full_name" class="form-control" required>
</div>
<div class="mb-3">
<label class="form-label"><?= t('Email') ?></label>
<input type="email" name="email" id="modal_email" class="form-control" required>
</div>
<div class="mb-3">
<label class="form-label"><?= t('Phone') ?></label>
<input type="text" name="phone" id="modal_phone" class="form-control">
</div>
<div class="mb-3">
<label class="form-label"><?= t('Role') ?></label>
<select name="user_role" id="modal_user_role" class="form-select" required>
<?php foreach (['Admin', 'Operator', 'Installer', 'Client'] as $role): ?>
<option value="<?= $role ?>"><?= $role ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="mb-3">
<label class="form-label"><?= t('Access Group') ?></label>
<input type="text" name="access_group" id="modal_access_group" class="form-control">
</div>
</div>
<div class="modal-footer">
<button type="submit" name="update_user" class="btn btn-success"><?= t('Update User') ?></button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal"><?= t('Cancel') ?></button>
</div>
</form>
</div>
</div>
<script>
const searchInput = document.getElementById('searchUser');
const roleFilter = document.getElementById('filterRole');
const userTable = document.getElementById('userTable');
const rows = userTable.querySelectorAll('tbody tr');
function filterTable() {
const searchTerm = searchInput.value.toLowerCase();
const selectedRole = roleFilter.value;
rows.forEach(row => {
const user = JSON.parse(row.getAttribute('data-user'));
const matchesSearch =
user.full_name.toLowerCase().includes(searchTerm) ||
user.username.toLowerCase().includes(searchTerm) ||
user.email.toLowerCase().includes(searchTerm);
const matchesRole = selectedRole === '' || user.user_role === selectedRole;
row.style.display = (matchesSearch && matchesRole) ? '' : 'none';
});
}
searchInput.addEventListener('input', filterTable);
roleFilter.addEventListener('change', filterTable);
// Show modal on row click with user data
rows.forEach(row => {
row.addEventListener('click', () => {
const user = JSON.parse(row.getAttribute('data-user'));
document.getElementById('modal_user_id').value = user.id;
document.getElementById('modal_full_name').value = user.full_name;
document.getElementById('modal_email').value = user.email;
document.getElementById('modal_phone').value = user.phone || '';
document.getElementById('modal_user_role').value = user.user_role;
document.getElementById('modal_access_group').value = user.access_group || '';
new bootstrap.Modal(document.getElementById('editUserModal')).show();
});
});
</script>
<?php include 'footer.php'; ?>

13
frontend/footer.php Normal file
View File

@ -0,0 +1,13 @@
<!-- Footer -->
<footer class="text-center text-muted py-3 mt-5 border-top bg-light">
<div class="container">
<small>&copy; 2025 AlarmDrift All rights reserved.</small><br>
<small>Version 0.0.1 (Dev) | Last updated: August 2025</small>
</div>
</footer>
<!-- Bootstrap JS bundle -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

31
frontend/get_signals.php Normal file
View File

@ -0,0 +1,31 @@
<?php
header("Cache-Control: no-cache, no-store, must-revalidate"); // HTTP 1.1
header("Pragma: no-cache"); // HTTP 1.0
header("Expires: 0"); // Proxies
require 'auth.php';
requireLogin();
require 'db.php';
require './lang/translate.php';
$stmt = $pdo->query("SELECT * FROM signals ORDER BY timestamp DESC LIMIT 100;");
$signals = $stmt->fetchAll(PDO::FETCH_ASSOC);
foreach ($signals as $signal): ?>
<tr>
<td><?= htmlspecialchars($signal['timestamp'] ?? '') ?></td>
<td><?= htmlspecialchars($signal['account'] ?? '') ?></td>
<td><?= htmlspecialchars($signal['event_code'] ?? '') ?></td>
<td><?= htmlspecialchars($signal['zone'] ?? '') ?></td>
<td><?= htmlspecialchars($signal['signal_text'] ?? '') ?></td>
<td>
<?php if (!empty($signal['v'])): ?>
<button class="btn btn-primary w-100 openImageModalBtn" data-image="<?= htmlspecialchars($signal['v']) ?>">
<?= htmlspecialchars($translations['image'] ?? 'Image') ?>
</button>
<?php endif; ?>
</td>
<td><?= htmlspecialchars($signal['source_ip'] ?? '') ?></td>
</tr>
<?php endforeach; ?>

83
frontend/header.php Normal file
View File

@ -0,0 +1,83 @@
<?php
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
require './lang/translate.php';
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>AlarmDrift</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- Bootstrap 5 CDN -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- jQuery (must come before DataTables) -->
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<!-- DataTables -->
<script src="https://cdn.datatables.net/1.13.6/js/jquery.dataTables.min.js"></script>
<script src="https://cdn.datatables.net/1.13.6/js/dataTables.bootstrap5.min.js"></script>
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mb-4">
<div class="container-fluid">
<a class="navbar-brand" href="index.php"><?= t('brand') ?></a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
<li class="nav-item"><a class="nav-link" href="index.php"><?= t('home') ?></a></li>
<li class="nav-item"><a class="nav-link" href="clients.php"><?= t('clients') ?></a></li>
<li class="nav-item"><a class="nav-link" href="signal_log.php"><?= t('signal_log') ?></a></li>
</ul>
<ul class="navbar-nav ms-auto">
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="maintenanceDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
<?= t('maintenance') ?>
</a>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="maintenanceDropdown">
<?php if (!empty($_SESSION['user_role']) && $_SESSION['user_role'] === 'Admin'): ?>
<li><h6 class="dropdown-header"><?= t('clients_header') ?></h6></li>
<li><a class="dropdown-item" href="edit_user.php"><?= t('dealers') ?></a></li>
<li><a class="dropdown-item" href="edit_user.php"><?= t('client_templates') ?></a></li>
<li><h6 class="dropdown-header"><?= t('event_handling_header') ?></h6></li>
<li><a class="dropdown-item" href="edit_user.php"><?= t('event_templates') ?></a></li>
<li><a class="dropdown-item" href="edit_user.php"><?= t('response_plans') ?></a></li>
<?php endif; ?>
</ul>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="systemDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
<?= t('system') ?>
</a>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="systemDropdown">
<?php if (!empty($_SESSION['user_role']) && $_SESSION['user_role'] === 'Admin'): ?>
<li><h6 class="dropdown-header"><?= t('users_header') ?></h6></li>
<li><a class="dropdown-item" href="edit_user.php"><?= t('users') ?></a></li>
<li><a class="dropdown-item" href="edit_user.php"><?= t('access_groups') ?></a></li>
<li><h6 class="dropdown-header"><?= t('system_header') ?></h6></li>
<li><a class="dropdown-item" href="edit_user.php"><?= t('sms_email') ?></a></li>
<li><a class="dropdown-item" href="receivers.php"><?= t('receivers') ?></a></li>
<li><a class="dropdown-item" href="edit_user.php"><?= t('system_settings') ?></a></li>
<li><a class="dropdown-item" href="edit_user.php"><?= t('backup') ?></a></li>
<?php endif; ?>
</ul>
</li>
<li class="nav-item dropdown ms-3">
<a class="nav-link dropdown-toggle" href="#" id="logoutDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
<?= t('logout') ?>
</a>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="logoutDropdown">
<li><a class="dropdown-item" href="logout.php"><?= t('logout') ?></a></li>
</ul>
</li>
</ul>
</div>
</div>
</nav>
<div class="container">

View File

@ -1 +0,0 @@
test

12
frontend/index.php Normal file
View File

@ -0,0 +1,12 @@
<?php
require 'auth.php';
requireLogin();
require 'header.php';
?>
<!DOCTYPE html>
<html>
<head><title>Dashboard</title></head>
<body>
<h1>This will be a dashboard!</h1>
<?php require 'footer.php'; ?>

68
frontend/lang/en.php Normal file
View File

@ -0,0 +1,68 @@
<?php
return [
'brand' => 'AlarmDrift',
'home' => 'Home',
'clients' => 'Clients',
'signal_log' => 'Signal Log',
'maintenance' => 'Maintenance',
'clients_header' => 'Clients',
'dealers' => 'Dealers',
'client_templates' => 'Client Templates',
'event_handling_header' => 'Event handling',
'event_templates' => 'Event Templates',
'response_plans' => 'Response Plans',
'system' => 'System',
'users_header' => 'Users',
'users' => 'Users',
'access_groups' => 'Access Groups',
'system_header' => 'System',
'sms_email' => 'SMS/Email',
'receivers' => 'Receivers',
'system_settings' => 'System Settings',
'backup' => 'Backup',
'logout' => 'Logout',
'signal_log_live' => 'Signal Log',
'timestamp' => 'Timestamp',
'account' => 'Account',
'event' => 'Event',
'zone' => 'Zone',
'text' => 'Text',
'image' => 'Image',
'source_ip' => 'Source IP',
'image_preview' => 'Image Preview',
'close' => 'Close',
'Users' => 'Users',
'Add user' => 'Add user',
'Filter by Role' => 'Filter by Role',
'All Roles' => 'All Roles',
'Admin' => 'Admin',
'Operator' => 'Operator',
'Installer' => 'Installer',
'Client' => 'Client',
'Search User' => 'Search User',
'Search by name, username, or email...' => 'Search by name, username, or email...',
'Full Name' => 'Full Name',
'Username' => 'Username',
'Email' => 'Email',
'Role' => 'Role',
'Edit User' => 'Edit User',
'Phone' => 'Phone',
'Access Group' => 'Access Group',
'Update User' => 'Update User',
'Cancel' => 'Cancel',
'User updated successfully.' => 'User updated successfully.',
'receivers' => 'Receivers',
'add_receiver' => 'Add Receiver',
'id' => 'ID',
'name' => 'Name',
'protocol' => 'Protocol',
'port' => 'Port',
'enabled' => 'Enabled',
'status' => 'Status',
'actions' => 'Actions',
'edit' => 'Edit',
'yes' => 'Yes',
'no' => 'No',
'running' => 'Running',
'offline' => 'Offline',
];

68
frontend/lang/no.php Normal file
View File

@ -0,0 +1,68 @@
<?php
return [
'brand' => 'AlarmDrift',
'home' => 'Hjem',
'clients' => 'Kunder',
'signal_log' => 'Signal Logg',
'maintenance' => 'Vedlikehold',
'clients_header' => 'Kunder',
'dealers' => 'Forhandlere',
'client_templates' => 'Kundemaler',
'event_handling_header' => 'Hendelseshåndtering',
'event_templates' => 'Hendelsesmaler',
'response_plans' => 'Responsplaner',
'system' => 'System',
'users_header' => 'Brukere',
'users' => 'Brukere',
'access_groups' => 'Tilgangsgrupper',
'system_header' => 'System',
'sms_email' => 'SMS/Epost',
'receivers' => 'Mottakere',
'system_settings' => 'Systeminnstillinger',
'backup' => 'Backup',
'logout' => 'Logg ut',
'signal_log_live' => 'Signal Logg',
'timestamp' => 'Tidsstempel',
'account' => 'Konto',
'event' => 'Hendelse',
'zone' => 'Sone',
'text' => 'Tekst',
'image' => 'Bilde',
'source_ip' => 'Kilde IP',
'image_preview' => 'Bilde forhåndsvisning',
'close' => 'Lukk',
'Users' => 'Brukere',
'Add user' => 'Legg til bruker',
'Filter by Role' => 'Filtrer etter rolle',
'All Roles' => 'Alle roller',
'Admin' => 'Administrator',
'Operator' => 'Operatør',
'Installer' => 'Installatør',
'Client' => 'Kunde',
'Search User' => 'Søk bruker',
'Search by name, username, or email...' => 'Søk etter navn, brukernavn eller e-post...',
'Full Name' => 'Fullt navn',
'Username' => 'Brukernavn',
'Email' => 'E-post',
'Role' => 'Rolle',
'Edit User' => 'Rediger bruker',
'Phone' => 'Telefon',
'Access Group' => 'Tilgangsgruppe',
'Update User' => 'Oppdater bruker',
'Cancel' => 'Avbryt',
'User updated successfully.' => 'Bruker oppdatert.'
'receivers' => 'Mottakere',
'add_receiver' => 'Legg til mottaker',
'id' => 'ID',
'name' => 'Navn',
'protocol' => 'Protokoll',
'port' => 'Port',
'enabled' => 'Aktiv',
'status' => 'Status',
'actions' => 'Handlinger',
'edit' => 'Rediger',
'yes' => 'Ja',
'no' => 'Nei',
'running' => 'Kjører',
'offline' => 'Av',
];

View File

@ -0,0 +1,44 @@
<?php
function getPreferredLanguage(array $availableLanguages, string $httpAcceptLanguage): string {
$langs = explode(',', $httpAcceptLanguage);
foreach ($langs as $lang) {
$langCode = strtolower(substr(trim($lang), 0, 2));
if (in_array($langCode, $availableLanguages)) {
return $langCode;
}
}
return 'en';
}
$availableLanguages = ['en', 'no'];
// URL override has priority
if (isset($_GET['lang']) && in_array($_GET['lang'], $availableLanguages)) {
$_SESSION['lang'] = $_GET['lang'];
}
// Set lang based on browser if not set already
if (!isset($_SESSION['lang'])) {
if (isset($_SERVER['HTTP_ACCEPT_LANGUAGE'])) {
$_SESSION['lang'] = getPreferredLanguage($availableLanguages, $_SERVER['HTTP_ACCEPT_LANGUAGE']);
} else {
$_SESSION['lang'] = 'en';
}
}
$lang = $_SESSION['lang'];
$langFile = __DIR__ . "/{$lang}.php";
$translations = [];
if (file_exists($langFile)) {
$translations = include $langFile;
} else {
$translations = include __DIR__ . '/en.php';
}
function t(string $key): string {
global $translations;
return htmlspecialchars($translations[$key] ?? $key);
}
?>

56
frontend/login.php Normal file
View File

@ -0,0 +1,56 @@
<?php
require 'db.php';
require 'vendor/autoload.php';
session_start();
$message = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$stmt = $pdo->prepare("SELECT * FROM users WHERE email = ?");
$stmt->execute([$_POST['email']]);
$user = $stmt->fetch();
if ($user && password_verify($_POST['password'], $user['password_hash'])) {
$_SESSION['user_id'] = $user['id'];
$_SESSION['totp_secret'] = $user['totp_secret'];
$_SESSION['2fa_verified'] = false;
$_SESSION['user_role'] = $user['user_role'] ?? 'Client';
header('Location: verify_2fa.php');
exit;
} else {
$message = "Invalid credentials.";
}
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Login - SuperArc</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- Bootstrap 5 CDN -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body class="bg-light d-flex align-items-center justify-content-center vh-100">
<div class="card shadow p-4" style="width: 100%; max-width: 400px;">
<h3 class="text-center mb-3">Login</h3>
<?php if ($message): ?>
<div class="alert alert-danger" role="alert"><?= htmlspecialchars($message) ?></div>
<?php endif; ?>
<form method="POST">
<div class="mb-3">
<label for="email" class="form-label">Email address</label>
<input name="email" type="email" class="form-control" id="email" required autofocus>
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input name="password" type="password" class="form-control" id="password" required>
</div>
<button type="submit" class="btn btn-primary w-100">Sign In</button>
</form>
</div>
</body>
</html>

5
frontend/logout.php Normal file
View File

@ -0,0 +1,5 @@
<?php
session_start();
session_destroy();
header('Location: login.php');
exit;

View File

@ -0,0 +1,38 @@
<?php
// receiver_status.php
require 'db.php'; // or wherever your DB connection is
$receivers = $pdo->query("SELECT id, tcpport, enabled FROM receivers")->fetchAll(PDO::FETCH_ASSOC);
$response = [];
function getServerIp(): string {
$socket = socket_create(AF_INET, SOCK_DGRAM, SOL_UDP);
if (!$socket) return '127.0.0.1';
// This IP doesn't need to be reachable — it just helps detect the outbound IP
socket_connect($socket, '8.8.8.8', 80);
socket_getsockname($socket, $localIp);
socket_close($socket);
return $localIp ?? '127.0.0.1';
}
foreach ($receivers as $r) {
$status = 'down';
// Check if the port is open (basic socket check)
$fp = @fsockopen(getServerIp(), $r['tcpport'], $errno, $errstr, 1);
if ($fp) {
fclose($fp);
$status = 'up';
}
$response[] = [
'id' => $r['id'],
'status' => $status
];
}
header('Content-Type: application/json');
echo json_encode($response);

216
frontend/receivers.php Normal file
View File

@ -0,0 +1,216 @@
<?php
require 'db.php';
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['delete_receiver']) && !empty($_POST['id'])) {
$deleteId = (int) $_POST['id'];
$stmt = $pdo->prepare("DELETE FROM receivers WHERE id = ?");
$stmt->execute([$deleteId]);
header("Location: receivers.php");
exit;
}
include 'header.php';
// Handle receiver update
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['update_receiver'])) {
$stmt = $pdo->prepare("UPDATE receivers SET name = ?, type = ?, tcpport = ?, enabled = ? WHERE id = ?");
$stmt->execute([
$_POST['name'],
$_POST['type'],
$_POST['tcpport'],
isset($_POST['enabled']) ? 1 : 0,
$_POST['id']
]);
$message = t('Receiver updated successfully.');
}
// Handle add receiver
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['add_receiver'])) {
$stmt = $pdo->prepare("INSERT INTO receivers (name, type, tcpport, enabled) VALUES (?, ?, ?, ?)");
$stmt->execute([
$_POST['name'],
$_POST['type'],
$_POST['tcpport'],
isset($_POST['enabled']) ? 1 : 0
]);
$message = t('Receiver added successfully.');
}
// Get all receivers
$receivers = $pdo->query("SELECT * FROM receivers")->fetchAll(PDO::FETCH_ASSOC);
function isPortOpen(string $host, int $port, int $timeout = 1): bool {
$connection = @fsockopen($host, $port, $errno, $errstr, $timeout);
if (is_resource($connection)) {
fclose($connection);
return true;
}
return false;
}
?>
<h2><?= t('Receivers') ?>
<button class="btn btn-success" data-bs-toggle="modal" data-bs-target="#addReceiverModal"><?= t('Add receiver') ?></button>
</h2>
<?php if (!empty($message)): ?>
<div class="alert alert-success"><?= htmlspecialchars($message) ?></div>
<?php endif; ?>
<table id="receiverTable" class="table table-hover" style="cursor:pointer;">
<thead>
<tr>
<th><?= t('Name') ?></th>
<th><?= t('Protocol') ?></th>
<th><?= t('Port') ?></th>
<th><?= t('Enabled') ?></th>
<th><?= t('status') ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($receivers as $receiver): ?>
<tr data-bs-toggle="modal" data-bs-target="#editReceiverModal"
data-id="<?= $receiver['id'] ?>"
data-name="<?= htmlspecialchars($receiver['name']) ?>"
data-type="<?= $receiver['type'] ?>"
data-port="<?= $receiver['tcpport'] ?>"
data-enabled="<?= $receiver['enabled'] ?>">
<td><?= htmlspecialchars($receiver['name']) ?></td>
<td><?= $receiver['type'] ?></td>
<td><?= $receiver['tcpport'] ?></td>
<td><?= $receiver['enabled'] ? t('Yes') : t('No') ?></td>
<td id="status-<?= $receiver['id'] ?>">
<?= $statusText ?> <!-- Initial status -->
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<!-- Edit Receiver Modal -->
<div class="modal fade" id="editReceiverModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<form method="post" class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><?= t('Edit Receiver') ?></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" name="id" id="edit-id">
<div class="mb-3">
<label class="form-label"><?= t('Name') ?></label>
<input type="text" name="name" id="edit-name" class="form-control" required>
</div>
<div class="mb-3">
<label class="form-label"><?= t('Protocol') ?></label>
<select name="type" id="edit-type" class="form-select">
<option value="SIA-DC09">SIA-DC09</option>
</select>
</div>
<div class="mb-3">
<label class="form-label"><?= t('Port') ?></label>
<input type="number" name="tcpport" id="edit-port" class="form-control" required>
</div>
<div class="form-check">
<input type="checkbox" name="enabled" id="edit-enabled" class="form-check-input">
<label class="form-check-label" for="edit-enabled"><?= t('Enabled') ?></label>
</div>
</div>
<div class="modal-footer">
<button type="submit" name="update_receiver" class="btn btn-success"><?= t('Update Receiver') ?></button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal"><?= t('Cancel') ?></button>
<button type="submit" name="delete_receiver" id="deleteReceiverBtn" class="btn btn-danger"><?= t('Delete Receiver') ?></button>
</div>
</div>
</form>
</div>
</div>
<!-- Add Receiver Modal -->
<div class="modal fade" id="addReceiverModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<form method="post" class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><?= t('Add Receiver') ?></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label"><?= t('Name') ?></label>
<input type="text" name="name" class="form-control" required>
</div>
<div class="mb-3">
<label class="form-label"><?= t('Protocol') ?></label>
<select name="type" class="form-select">
<option value="SIA-DC09">SIA-DC09</option>
</select>
</div>
<div class="mb-3">
<label class="form-label"><?= t('Port') ?></label>
<input type="number" name="tcpport" class="form-control" required>
</div>
<div class="form-check">
<input type="checkbox" name="enabled" class="form-check-input" checked>
<label class="form-check-label"><?= t('Enabled') ?></label>
</div>
</div>
<div class="modal-footer">
<button type="submit" name="add_receiver" class="btn btn-success"><?= t('Add Receiver') ?></button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal"><?= t('Cancel') ?></button>
</div>
</form>
</div>
</div>
<script>
document.querySelectorAll('tr[data-bs-toggle="modal"]').forEach(row => {
row.addEventListener('click', () => {
const id = row.dataset.id;
const name = row.dataset.name;
const type = row.dataset.type;
const port = row.dataset.port;
const enabled = row.dataset.enabled;
document.getElementById('edit-id').value = id;
document.getElementById('edit-name').value = name;
document.getElementById('edit-type').value = type;
document.getElementById('edit-port').value = port;
document.getElementById('edit-enabled').checked = (enabled === '1');
// Disable the delete button if receiver is enabled
document.getElementById('deleteReceiverBtn').disabled = (enabled === '1');
// Show the modal (optional if data-bs-toggle="modal" does this automatically)
// new bootstrap.Modal(document.getElementById('editReceiverModal')).show();
});
});
</script>
<script>
function updateStatuses() {
fetch('receiver_status.php')
.then(response => response.json())
.then(data => {
data.forEach(receiver => {
const el = document.getElementById('status-' + receiver.id);
if (el) {
el.textContent = receiver.status === 'up' ? 'Online' : 'Offline';
el.className = receiver.status === 'up' ? 'text-success' : 'text-danger';
}
});
})
.catch(error => console.error('Status update failed:', error));
}
// First run
updateStatuses();
// Repeat every 5 seconds
setInterval(updateStatuses, 5000);
</script>
<?php include 'footer.php'; ?>

115
frontend/signal_log.php Normal file
View File

@ -0,0 +1,115 @@
<?php
require 'auth.php';
requireLogin();
?>
<?php include 'header.php'; ?>
<!-- DataTables CSS -->
<link rel="stylesheet" href="https://cdn.datatables.net/1.13.6/css/dataTables.bootstrap5.min.css">
<div class="container mt-4">
<h2><?= htmlspecialchars($translations['signal_log_live'] ?? 'Signal Log') ?></h2>
<!-- Auto-refresh toggle button -->
<button id="toggleRefreshBtn" class="btn btn-sm btn-primary mb-3">⏸️ Auto-Refresh: OFF</button>
<table id="signal-log-table" class="table table-striped">
<thead>
<tr>
<th><?= htmlspecialchars($translations['timestamp'] ?? 'Timestamp') ?></th>
<th><?= htmlspecialchars($translations['account'] ?? 'Account') ?></th>
<th><?= htmlspecialchars($translations['event'] ?? 'Event') ?></th>
<th><?= htmlspecialchars($translations['zone'] ?? 'Zone') ?></th>
<th><?= htmlspecialchars($translations['text'] ?? 'Text') ?></th>
<th><?= htmlspecialchars($translations['image'] ?? 'Image') ?></th>
<th><?= htmlspecialchars($translations['source_ip'] ?? 'Source IP') ?></th>
</tr>
</thead>
<tbody id="signal-log-body">
<!-- Filled dynamically -->
</tbody>
</table>
</div>
<!-- Image Preview Modal -->
<div class="modal fade" id="imageModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><?= htmlspecialchars($translations['image_preview'] ?? 'Image Preview') ?></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="<?= htmlspecialchars($translations['close'] ?? 'Close') ?>"></button>
</div>
<div class="modal-body text-center">
<img src="" alt="<?= htmlspecialchars($translations['image'] ?? 'Image') ?>" class="img-fluid" id="modalImage" />
</div>
</div>
</div>
</div>
<!-- DataTables JS -->
<script src="https://cdn.datatables.net/1.13.6/js/jquery.dataTables.min.js"></script>
<script src="https://cdn.datatables.net/1.13.6/js/dataTables.bootstrap5.min.js"></script>
<script>
let dataTable = null;
let autoRefresh = false;
let refreshInterval = null;
function fetchSignals() {
fetch('get_signals.php?_ts=' + new Date().getTime())
.then(response => response.text())
.then(data => {
const tbody = document.getElementById('signal-log-body');
// Destroy existing DataTable instance before updating HTML
if (dataTable) {
dataTable.destroy();
}
tbody.innerHTML = data;
// Re-initialize DataTable
dataTable = new DataTable('#signal-log-table', {
paging: true,
searching: true,
ordering: true,
order: [[0, 'desc']],
lengthMenu: [ [50, 100, 200], [50, 100, 200] ]
});
})
.catch(err => console.error('Fetch error:', err));
}
document.addEventListener('DOMContentLoaded', () => {
fetchSignals();
const toggleBtn = document.getElementById('toggleRefreshBtn');
toggleBtn.addEventListener('click', () => {
autoRefresh = !autoRefresh;
toggleBtn.innerHTML = autoRefresh ? '🔄 Auto-Refresh: ON' : '⏸️ Auto-Refresh: OFF';
if (autoRefresh) {
fetchSignals();
refreshInterval = setInterval(fetchSignals, 2000);
} else {
clearInterval(refreshInterval);
refreshInterval = null;
}
});
// Delegate image modal opening for dynamically loaded buttons
document.body.addEventListener('click', e => {
if (e.target.classList.contains('openImageModalBtn')) {
const imgSrc = e.target.getAttribute('data-image');
document.getElementById('modalImage').src = imgSrc;
const myModal = new bootstrap.Modal(document.getElementById('imageModal'));
myModal.show();
}
});
});
</script>
<?php include 'footer.php'; ?>

86
frontend/verify_2fa.php Normal file
View File

@ -0,0 +1,86 @@
<?php
require 'auth.php';
requireLoginBefore2FA();
require 'vendor/autoload.php';
require 'db.php';
$ga = new PHPGangsta_GoogleAuthenticator();
$message = '';
$user_id = $_SESSION['user_id'] ?? null;
// Fetch user info
$stmt = $pdo->prepare("SELECT * FROM users WHERE id = ?");
$stmt->execute([$user_id]);
$user = $stmt->fetch();
if (!$user) {
die('User not found.');
}
// Load or generate secret
if (empty($user['totp_secret']) && empty($_SESSION['totp_secret_temp'])) {
// First-time login: generate temp secret and store in session
$_SESSION['totp_secret_temp'] = $ga->createSecret();
}
$secret = $_SESSION['totp_secret'] ?? $_SESSION['totp_secret_temp'] ?? null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$code = $_POST['code'] ?? '';
if (!$secret) {
die('No secret found.');
}
if ($ga->verifyCode($secret, $code, 2)) {
// First-time setup: store the verified secret
if (empty($user['totp_secret'])) {
$stmt = $pdo->prepare("UPDATE users SET totp_secret = ? WHERE id = ?");
$stmt->execute([$secret, $user_id]);
}
$_SESSION['2fa_verified'] = true;
$_SESSION['totp_secret'] = $secret;
unset($_SESSION['totp_secret_temp']);
header('Location: index.php');
exit;
} else {
$message = 'Invalid 2FA code.';
}
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>2FA Verification - SuperArc</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body class="bg-light d-flex align-items-center justify-content-center vh-100">
<div class="card shadow p-4" style="width: 100%; max-width: 400px;">
<h3 class="text-center mb-3">2FA Verification</h3>
<?php if ($message): ?>
<div class="alert alert-danger"><?= htmlspecialchars($message) ?></div>
<?php endif; ?>
<?php if (!empty($_SESSION['totp_secret_temp'])): ?>
<div class="mb-3 text-center">
<p>Scan this QR code in your authenticator app:</p>
<img src="<?= htmlspecialchars($ga->getQRCodeGoogleUrl('AlarmDrift', $_SESSION['totp_secret_temp'])) ?>" alt="QR Code">
</div>
<?php endif; ?>
<form method="POST">
<div class="mb-3">
<label for="code" class="form-label">Enter your 2FA code</label>
<input type="text" name="code" id="code" class="form-control" required autofocus>
</div>
<button type="submit" class="btn btn-primary w-100">Verify</button>
</form>
</div>
</body>
</html>

View File

@ -12,7 +12,7 @@ http {
location ~ \.php$ { location ~ \.php$ {
include fastcgi_params; include fastcgi_params;
fastcgi_pass php-fpm:9000; fastcgi_pass 127.0.0.1:9000;
fastcgi_index index.php; fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
} }

View File

@ -1,2 +0,0 @@
crccheck
mysql-connector-python

View File

@ -25,25 +25,31 @@ def build_sia_dcs_message():
if image_url: if image_url:
body += f'[V{image_url}]' body += f'[V{image_url}]'
data_bytes = body.encode("ascii") body_bytes = body.encode("ascii")
crc = CrcArc.calc(data_bytes) crc = CrcArc.calc(body_bytes)
length = len(data_bytes) length = f"{len(body_bytes):04X}" # Hex, 4-digit, upper-case
full_message = f"\n{crc:04X}{length:04X}{body}\r".encode("ascii") full_message = f"\n{crc:04X}{length}{body}\r".encode("ascii")
return full_message return full_message
def build_adm_cid_message(): def build_adm_cid_message():
line = input("Enter phone line number (e.g. 01): ").zfill(2) seq = input("Enter sequence number (e.g. 0000): ").zfill(4)
line = input("Enter line number (e.g. 3): ").zfill(1)
account = input("Enter account number (e.g. 1234): ").zfill(4) account = input("Enter account number (e.g. 1234): ").zfill(4)
code = input("Enter event code (e.g. 401): ").zfill(3) code = input("Enter event code (e.g. 1131): ").zfill(4)
qualifier = input("Enter qualifier (1=New, 3=Restore): ").zfill(1) area = input("Enter area/partition (e.g. 01): ").zfill(2)
area = input("Enter partition/area (e.g. 1): ").zfill(2) zone = input("Enter zone/user (e.g. 015): ").zfill(3)
zone = input("Enter zone/user (e.g. 005): ").zfill(3) image_url = input("Optional Image URL (or leave blank): ")
# ADM-CID format: \r\nLLL:18QCCC[AAAA|EEMMZZ]\r body = f'"ADM-CID"{seq}L{line}#{account}[#{account}|{code} {area} {zone}]'
cid_data = f'18{qualifier}{code}[{account}|{area}{area}{zone}]' if image_url:
msg = f"\r\n{line}{cid_data}\r".encode("ascii") body += f'[V{image_url}]'
return msg body_bytes = body.encode("ascii")
crc = CrcArc.calc(body_bytes)
length = f"{len(body_bytes):04X}"
full_message = f"\n{crc:04X}{length}{body}\r".encode("ascii")
return full_message
def main(): def main():
clear_screen() clear_screen()