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 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"]

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 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 (23 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 <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:
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

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$ {
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;
}

View File

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

View File

@ -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()