diff --git a/Dockerfile b/Dockerfile index d106067..6b94a63 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,19 @@ -# Use an official Python runtime as a parent image -FROM python:3.11-slim +FROM ubuntu:24.04 -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"] diff --git a/Dockerfile.php b/Dockerfile.php new file mode 100644 index 0000000..5dab325 --- /dev/null +++ b/Dockerfile.php @@ -0,0 +1,4 @@ +FROM php:8.2-fpm + +RUN docker-php-ext-install pdo pdo_mysql sockets + diff --git a/backend/main.bin b/backend/main.bin new file mode 100755 index 0000000..66042fd Binary files /dev/null and b/backend/main.bin differ diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..04a6c39 --- /dev/null +++ b/backend/main.py @@ -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() + diff --git a/backend/tcp_sia_server.bin b/backend/tcp_sia_server.bin new file mode 100755 index 0000000..5c7c183 Binary files /dev/null and b/backend/tcp_sia_server.bin differ diff --git a/backend/tcp_sia_server.py b/backend/tcp_sia_server.py index 8655bfe..11212d8 100644 --- a/backend/tcp_sia_server.py +++ b/backend/tcp_sia_server.py @@ -1,9 +1,19 @@ import asyncio +import sys from crccheck.crc import CrcArc import re import json -import mysql.connector +import pymysql 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]: """ @@ -12,11 +22,11 @@ def verify_message(message: bytes) -> tuple[bool, str]: """ if len(message) < 10: - print("Message too short") + logging.info("Message too short") return False, None 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 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') payload_str = data_bytes.decode('ascii') except UnicodeDecodeError: - print("Non-ASCII characters in message") + logging.info("Non-ASCII characters in message") return False, None try: crc_recv = int(crc_str, 16) length_recv = int(length_str, 16) except ValueError: - print("CRC or length not hex") + logging.info("CRC or length not hex") return False, None 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 crc_calc = CrcArc.calc(data_bytes) 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 - print(f"Valid message received: {payload_str}") + logging.info(f"Valid message received: {payload_str}") return True, payload_str def parse_sia_payload(payload: str) -> dict: """ - Extracts fields from SIA-DC09 message according to the full protocol spec. - Example: + Extracts fields from SIA-DCS or ADM-CID message according to the protocol spec. + Examples: "SIA-DCS"0005L0#1234[1234|Nri1/RP00][Vimage.jpg][X010E57.5698][Y59N12.8358] + "ADM-CID"0000RF3L56789#1234[#1234|1131 01 015] """ - result = { "format": None, "seq": None, "line": None, "account": None, "receiver": None, - "event": None, "zone": None, "partition": None, "code": None, @@ -72,6 +81,7 @@ def parse_sia_payload(payload: str) -> dict: "y": None, "v": None, "raw": payload, + "signal_text": None, } # Extract format and header @@ -82,132 +92,228 @@ def parse_sia_payload(payload: str) -> dict: result["line"] = header_match.group(3) result["account"] = header_match.group(4) - # 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) + # Parse based on message format + format_type = result["format"] + + if format_type == "SIA-DCS": + # 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["event"] = event - # Optional: split event into type (2 chars), zone (2–3 digits) + # Extract code and zone from event (e.g., OP01, BA05) event_code_match = re.match(r'([A-Z]{2})(\d+)', event) if event_code_match: result["code"] = event_code_match.group(1) 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) if partition_match: result["partition"] = partition_match.group(1) - # Optional V block (usually image URL) - v_match = re.search(r'\[V([^\]]*)\]', payload) - if v_match: - result["v"] = v_match.group(1) + # Optional signal text (e.g., ^Fire alarm triggered^) + text_match = re.search(r'\^([^^]+)\^', event) + if text_match: + result["signal_text"] = text_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) + # Optional V block (usually image or file name) + v_match = re.search(r'\[V([^\]]*)\]', payload) + if v_match: + result["v"] = v_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) 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() 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 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') - print(f"Connection from {addr}") + lf = chr(0x0A) # LF + cr = chr(0x0D) # CR + logging.info(f"Connection from {addr}") try: - data = await reader.readuntil(b'\r') - print(f"Raw received: {data}") + while True: + try: + data = await reader.readuntil(b'\r') + except asyncio.IncompleteReadError: + logging.info("Client closed connection") + break - is_valid, payload = verify_message(data) - response = b"\n0004NACK\r" # fallback + logging.info(f"Raw received: {data}") - if is_valid and payload: - parsed = parse_sia_payload(payload) - 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") - ) + is_valid, payload = verify_message(data) + response = b'\n"NACK"0000L0#0000[]\r' # Default response - cursor.execute(query, values) - conn.commit() - print("Inserted signal into database.") - cursor.close() - conn.close() + if is_valid and payload: + parsed = parse_sia_payload(payload) + parsed["source_ip"] = str(addr[0]) + logging.info("Parsed payload:") + 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") - account = parsed.get("account", "0000") + # Validate required fields + required_format = parsed.get("format") + required_fields = [parsed.get("account"), parsed.get("code"), parsed.get("zone")] - response_payload = f'"ACK"{seq}L0#{account}[]' - crc = CrcArc.calc(response_payload.encode()) - length = str(hex(len(response_payload))).split('x') - response = f"\n{crc:04X}00{length[1].upper()}{response_payload}\r".encode() + if required_format in ["SIA-DCS","ADM-CID"]: + if any(f is None for f in required_fields): + logging.info("Missing required SIA fields — sending NACK.") + # 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}") - else: - print("Invalid message. Sending fallback NACK.") + # 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}") + 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) - await writer.drain() + writer.write(response) + await writer.drain() - except asyncio.IncompleteReadError: - print("Connection closed before end of message") except Exception as e: - print(f"Error: {e}") + logging.info(f"Unexpected error: {e}") finally: writer.close() await writer.wait_closed() - print(f"Connection with {addr} closed") + logging.info(f"Connection with {addr} closed") -async def main(): - server = await asyncio.start_server(handle_client, host='0.0.0.0', port=9000) - print("Listening on TCP port 9000 for SIA-DCS messages") +async def main(port): + server = await asyncio.start_server(handle_client, host='0.0.0.0', port=port) + logging.info(f"Listening on TCP port {port} for SIA-DCS messages") async with server: await server.serve_forever() if __name__ == '__main__': - asyncio.run(main()) + if len(sys.argv) < 2: + print("Usage: tcp_sia_server.bin ") + 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}") diff --git a/backend/watchdog.bin b/backend/watchdog.bin new file mode 100755 index 0000000..1bd8877 Binary files /dev/null and b/backend/watchdog.bin differ diff --git a/backend/watchdog.py b/backend/watchdog.py new file mode 100644 index 0000000..6f91696 --- /dev/null +++ b/backend/watchdog.py @@ -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) diff --git a/config.ini b/config.ini new file mode 100644 index 0000000..7e9b2c5 --- /dev/null +++ b/config.ini @@ -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 \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 935b5b1..cdd0413 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,4 @@ services: - mariadb: image: mariadb:10.9 environment: @@ -18,26 +17,27 @@ services: volumes: - ./frontend:/usr/share/nginx/html:ro - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro - ports: - - "80:80" + network_mode: "host" depends_on: - mariadb - php-fpm php-fpm: - image: php:8.2-fpm - volumes: - - ./frontend:/usr/share/nginx/html - - tcp_sia_server: build: context: . - ports: - - "9000:9000" + dockerfile: Dockerfile.php volumes: - - ./backend/:/app - working_dir: /app - command: python -u /app/tcp_sia_server.py + - ./frontend:/usr/share/nginx/html + network_mode: "host" + + main: + build: + context: . + dockerfile: Dockerfile + network_mode: "host" + volumes: + - ./config.ini:/app/config.ini:ro + - /etc/machine-id:/etc/machine-id:ro depends_on: - mariadb diff --git a/frontend/.user.ini b/frontend/.user.ini new file mode 100644 index 0000000..e52b63b --- /dev/null +++ b/frontend/.user.ini @@ -0,0 +1,2 @@ +error_reporting = E_ALL & ~E_DEPRECATED +display_errors = On \ No newline at end of file diff --git a/frontend/add_user.php b/frontend/add_user.php new file mode 100644 index 0000000..44e9d40 --- /dev/null +++ b/frontend/add_user.php @@ -0,0 +1,76 @@ +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."; +} +?> + + + +
+

Add New User

+ +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+
+ + + diff --git a/frontend/auth.php b/frontend/auth.php new file mode 100644 index 0000000..f07f896 --- /dev/null +++ b/frontend/auth.php @@ -0,0 +1,29 @@ + diff --git a/frontend/composer.json b/frontend/composer.json new file mode 100644 index 0000000..416d23d --- /dev/null +++ b/frontend/composer.json @@ -0,0 +1,7 @@ +{ + "require": { + "phpgangsta/googleauthenticator": "dev-master" + }, + "minimum-stability": "dev", + "prefer-stable": true +} diff --git a/frontend/create_user.php b/frontend/create_user.php new file mode 100644 index 0000000..ae49864 --- /dev/null +++ b/frontend/create_user.php @@ -0,0 +1,23 @@ +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:
"; +echo ""; +?> diff --git a/frontend/db.php b/frontend/db.php new file mode 100644 index 0000000..6cd51d3 --- /dev/null +++ b/frontend/db.php @@ -0,0 +1,4 @@ +setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); +?> diff --git a/frontend/edit_user.php b/frontend/edit_user.php new file mode 100644 index 0000000..7125351 --- /dev/null +++ b/frontend/edit_user.php @@ -0,0 +1,183 @@ +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); +} +?> + + + +
+

+ + + +

+ + +
+ + +
+ + +
+ +
+ + +
+ + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + diff --git a/frontend/footer.php b/frontend/footer.php new file mode 100644 index 0000000..c483ed9 --- /dev/null +++ b/frontend/footer.php @@ -0,0 +1,13 @@ + + + + + + + + diff --git a/frontend/get_signals.php b/frontend/get_signals.php new file mode 100644 index 0000000..a1a6c1d --- /dev/null +++ b/frontend/get_signals.php @@ -0,0 +1,31 @@ +query("SELECT * FROM signals ORDER BY timestamp DESC LIMIT 100;"); +$signals = $stmt->fetchAll(PDO::FETCH_ASSOC); + +foreach ($signals as $signal): ?> + + + + + + + + + + + + + + + + diff --git a/frontend/header.php b/frontend/header.php new file mode 100644 index 0000000..1c0c175 --- /dev/null +++ b/frontend/header.php @@ -0,0 +1,83 @@ + + + + + + + AlarmDrift + + + + + + + + + + + + + + +
diff --git a/frontend/index.html b/frontend/index.html deleted file mode 100644 index 30d74d2..0000000 --- a/frontend/index.html +++ /dev/null @@ -1 +0,0 @@ -test \ No newline at end of file diff --git a/frontend/index.php b/frontend/index.php new file mode 100644 index 0000000..5c58f4e --- /dev/null +++ b/frontend/index.php @@ -0,0 +1,12 @@ + + + + +Dashboard + +

This will be a dashboard!

+ diff --git a/frontend/lang/en.php b/frontend/lang/en.php new file mode 100644 index 0000000..3226c0c --- /dev/null +++ b/frontend/lang/en.php @@ -0,0 +1,68 @@ + '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', +]; diff --git a/frontend/lang/no.php b/frontend/lang/no.php new file mode 100644 index 0000000..7d539d0 --- /dev/null +++ b/frontend/lang/no.php @@ -0,0 +1,68 @@ + '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', +]; diff --git a/frontend/lang/translate.php b/frontend/lang/translate.php new file mode 100644 index 0000000..f191591 --- /dev/null +++ b/frontend/lang/translate.php @@ -0,0 +1,44 @@ + diff --git a/frontend/login.php b/frontend/login.php new file mode 100644 index 0000000..63ed027 --- /dev/null +++ b/frontend/login.php @@ -0,0 +1,56 @@ +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."; + } +} +?> + + + + + + Login - SuperArc + + + + + + +
+

Login

+ + + +
+
+ + +
+
+ + +
+ +
+
+ + + diff --git a/frontend/logout.php b/frontend/logout.php new file mode 100644 index 0000000..37bc5ab --- /dev/null +++ b/frontend/logout.php @@ -0,0 +1,5 @@ +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); diff --git a/frontend/receivers.php b/frontend/receivers.php new file mode 100644 index 0000000..e0f4503 --- /dev/null +++ b/frontend/receivers.php @@ -0,0 +1,216 @@ +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; +} + +?> + + + +

+ +

+ + +
+ + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + +
+ + + + + + + + + diff --git a/frontend/signal_log.php b/frontend/signal_log.php new file mode 100644 index 0000000..bbd1db1 --- /dev/null +++ b/frontend/signal_log.php @@ -0,0 +1,115 @@ + + + + + + + +
+

+ + + + + + + + + + + + + + + + + + + +
+
+ + + + + + + + + + + diff --git a/frontend/verify_2fa.php b/frontend/verify_2fa.php new file mode 100644 index 0000000..135379a --- /dev/null +++ b/frontend/verify_2fa.php @@ -0,0 +1,86 @@ +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.'; + } +} +?> + + + + + 2FA Verification - SuperArc + + + + +
+

2FA Verification

+ + +
+ + + +
+

Scan this QR code in your authenticator app:

+ QR Code +
+ + +
+
+ + +
+ +
+
+ + diff --git a/nginx/nginx.conf b/nginx/nginx.conf index b454179..b31e4a1 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -12,7 +12,7 @@ http { location ~ \.php$ { include fastcgi_params; - fastcgi_pass php-fpm:9000; + fastcgi_pass 127.0.0.1:9000; fastcgi_index index.php; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; } diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 1b1c464..0000000 --- a/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -crccheck -mysql-connector-python \ No newline at end of file diff --git a/sia-tester.py b/sia-tester.py index 2ea5f87..1cf3511 100644 --- a/sia-tester.py +++ b/sia-tester.py @@ -25,25 +25,31 @@ def build_sia_dcs_message(): if image_url: body += f'[V{image_url}]' - data_bytes = body.encode("ascii") - crc = CrcArc.calc(data_bytes) - length = len(data_bytes) + body_bytes = body.encode("ascii") + crc = CrcArc.calc(body_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 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) - code = input("Enter event code (e.g. 401): ").zfill(3) - qualifier = input("Enter qualifier (1=New, 3=Restore): ").zfill(1) - area = input("Enter partition/area (e.g. 1): ").zfill(2) - zone = input("Enter zone/user (e.g. 005): ").zfill(3) + code = input("Enter event code (e.g. 1131): ").zfill(4) + area = input("Enter area/partition (e.g. 01): ").zfill(2) + zone = input("Enter zone/user (e.g. 015): ").zfill(3) + image_url = input("Optional Image URL (or leave blank): ") - # ADM-CID format: \r\nLLL:18QCCC[AAAA|EEMMZZ]\r - cid_data = f'18{qualifier}{code}[{account}|{area}{area}{zone}]' - msg = f"\r\n{line}{cid_data}\r".encode("ascii") - return msg + body = f'"ADM-CID"{seq}L{line}#{account}[#{account}|{code} {area} {zone}]' + if image_url: + body += f'[V{image_url}]' + 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(): clear_screen()