You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

1345 lines
59 KiB

"""
server.py — Python HTTP Server for IoT Web Configurator
Replaces the Java Spring Boot backend with a Python stdlib-only HTTP server.
Serves the same 4 REST API endpoints as the original Java application:
- GET /setting/get-device
- POST /setting/device
- GET /setting/get-protocol
- POST /setting/protocol
Also serves static files (HTML/CSS/JS) for standalone operation.
Memory usage: ~10-30MB (vs Java's 436MB)
Dependencies: Python stdlib only (http.server, sqlite3, json, os, urllib)
"""
import gzip
import json
import os
import shutil
import socket
import sqlite3
import sys
import time
import mimetypes
import traceback
import logging
from http.server import HTTPServer, BaseHTTPRequestHandler
from socketserver import ThreadingMixIn
from urllib.parse import urlparse
# Add src directory to path for imports
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from db_manager import DBManager
from migrations import apply_all_migrations
from config_validator import (
strip_none_values,
normalize_device_port_types,
validate_device_port_types_hard,
process_protocol_config,
canonicalize_protocol_entries,
ensure_meid_string,
validate_device_config,
validate_protocol_config,
validate_device_enums,
validate_protocol_enums,
validate_wifi_country_code,
validate_odo_field,
validate_super_relay_keys, # NEW v1.4.4
validate_rs485_integers, # NEW v1.4.5
validate_register_entries, # NEW v1.11.8 A
validate_log_config_hard, # NEW v1.11.8 B
validate_wifi_ssid_profiles, # NEW v1.11.8 C
validate_device_ip_fields_hard, # NEW v1.11.8 D
)
from enum_normalizer import normalize_device_input, normalize_protocol_input # v1.4.0.3
import kernel_log
from log_manager import (
list_log_files,
validate_filenames,
create_download_archive,
sweep_stale_download_temp_dirs,
start_auto_compress_daemon,
get_log_stats,
run_manual_compression,
delete_log_files,
migrate_log_config,
LOG_CONFIG_KEYS,
)
# v1.6.0 M8: set_network_apply_provider 를 단일 import 로 통합 (중복 라인 제거)
from system_status import (
get_system_status,
test_connections,
restart_dpworldapp,
set_network_apply_provider,
)
from support_bundle import build_support_bundle
from firmware.fw_controller import FirmwareController
from firmware.fw_routes import FirmwareRoutes, RouteError
# Configuration
HOST = os.environ.get("HOST", "0.0.0.0")
# v1.12.0: 기본 포트 8080→9090 으로 통일. 설정기는 PC·디바이스 어디서든 :9090 이다
# (디바이스는 systemd 유닛이 PORT=9090 지정 — 동일값이라 dev/prod 포트가 갈리지 않음).
# 디바이스의 :8080 은 별개의 레거시 Java app-runner 이며 이 앱과 무관하다.
PORT = int(os.environ.get("PORT", "9090"))
STATIC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")
# v1.5.4: MIME prefixes eligible for gzip compression.
# Excludes already-compressed formats: .woff2, .gz, images (PNG/JPG/etc).
_COMPRESSIBLE_MIME = (
"text/",
"application/javascript",
"application/json",
"image/svg+xml",
)
# DB path (also used by FirmwareController)
DB_PATH = os.environ.get("DB_PATH", os.path.join(os.path.expanduser("~"), "db", "dynamic_data.db"))
# Initialize DB manager
db = DBManager()
# Firmware OTA subsystem (v1.5.0 Phase 4a)
# v1.5.2 B2 (H19): default_file is NOT overridden here — FirmwareController uses its own
# default (/var/www/html/config_device.json), the correct dpworldapp seed file.
_FW = FirmwareRoutes(
FirmwareController(
fw_host=os.environ.get("FW_HOST", "127.0.0.1"),
fw_port=int(os.environ.get("FW_PORT", "8990")),
staging_dir=os.environ.get("FW_STAGING_DIR", "/opt/fw_staging"),
backups_dir=os.environ.get("FW_BACKUPS_DIR", "/opt/config_backups"),
db_path=DB_PATH,
),
# v1.10.2: buffer the streamed firmware ZIP on the persistent /opt eMMC partition,
# NOT the default /tmp tmpfs — under MemoryMax=128M (v1.11.16, was 48M) with no swap, buffering a
# multi-hundred-MB package on RAM-backed tmpfs OOM-kills the service mid-upload.
# `or` (not the 2-arg default): an empty FW_UPLOAD_TMP must still resolve to /opt,
# never fall through FirmwareRoutes' `tmp_dir or gettempdir()` back to /tmp.
tmp_dir=os.environ.get("FW_UPLOAD_TMP") or "/opt/fw_upload",
)
# v1.6.0: Network apply subsystem — import-time 경로 생성 실패 시 503 fallback (Windows dev box 안전)
_NET_INIT_ERROR = None
_NET = None
_NET_ENGINE = None
_NET_WATCHDOG = None
def _net_run(argv, timeout=10):
import subprocess
try:
p = subprocess.run(argv, capture_output=True, text=True, timeout=timeout)
return p.returncode, (p.stdout or "") + (p.stderr or "")
except (subprocess.SubprocessError, OSError) as e:
return 1, str(e)
def _net_drift():
"""DB ↔ network_config.json 22항목 drift (§5.1 state)."""
try:
from network import netmodel as _netmodel, renderer as _renderer
import json as _json
dev = db.get_config("device_config") or {}
with open(os.path.join(os.environ.get("NET_DIR", "/home/root/network"),
"network_config.json"), encoding="utf-8") as f:
persist = _renderer.intent_from_persist(_json.load(f))
diff = _netmodel.diff_intents(persist, _netmodel.intent_from_device(dev))
return {"dirty": bool(diff), "fields": [d["field"] for d in diff]}
except (OSError, ValueError):
# #21 fix: an unreadable config is UNKNOWN drift, not 'no drift'. Returning
# dirty:False here was a false 'no drift'; dirty:None signals unknown so the
# UI does not zero the count or render a green 'no drift'.
return {"dirty": None, "error": "network_config.json unreadable", "fields": []}
def _net_live():
# I3: _ip_brief 는 (table, ok) 튜플 — interfaces 는 map 유지 (Phase 4 UI 가 object 소비)
from network.verifier import _ip_brief
table, ok = _ip_brief(lambda a, t=5: _net_run(a, t))
return {"interfaces": table, "interfaces_ok": ok}
try:
from network.apply_engine import ApplyEngine
from network.journal import Journal as _NetJournal
from network.net_routes import NetworkRoutes, RouteError as NetRouteError
from network.watchdog import NetworkWatchdog
_NET_JOURNAL = _NetJournal(os.path.join(os.environ.get("LOG_DIR", "/opt/log/dpworldapp"),
"network_journal.jsonl"))
_NET_ENGINE = ApplyEngine(
db=db, net_dir=os.environ.get("NET_DIR", "/home/root/network"),
backups_dir=os.environ.get("NET_BACKUPS_DIR", "/opt/config_backups/network"),
state_path=os.environ.get("NET_STATE_PATH",
"/opt/config_backups/network/apply_state.json"),
journal=_NET_JOURNAL,
runner=_net_run)
_NET_WATCHDOG = NetworkWatchdog(db=db, engine=_NET_ENGINE, journal=_NET_JOURNAL,
net_dir=os.environ.get("NET_DIR", "/home/root/network"))
_NET = NetworkRoutes(_NET_ENGINE, _NET_JOURNAL, _NET_WATCHDOG, _net_drift, _net_live)
except Exception as _e: # noqa: BLE001 — server must never fail to import due to network subsystem
_NET_INIT_ERROR = str(_e)
print(f"[startup] WARNING: network subsystem unavailable: {_NET_INIT_ERROR}", flush=True)
class _RouteErrorStub(Exception):
def __init__(self, status, message):
super().__init__(message); self.status = status; self.message = message
NetRouteError = _RouteErrorStub
# AP: subsystem init — begin
_AP = None
_AP_INIT_ERROR = None
try:
from network.ap_engine import ApEngine, parse_iw_link_channel, parse_iw_link_beacon_int
from network.ap_routes import ApRoutes, RouteError as ApRouteError
_AP_ENGINE = ApEngine(
ap_dir=os.environ.get("AP_DIR", "/home/root/network/ap"),
state_path=os.environ.get("AP_STATE_PATH", "/opt/dpworld-network/ap_apply_state.json"),
runner=_net_run,
live_country=(lambda: _NET_ENGINE.live_country()) if _NET_ENGINE else (lambda: ""),
sta_channel=lambda: parse_iw_link_channel(_net_run(["iw", "dev", "wlan0", "link"], 5)[1]),
sta_beacon_int=lambda: parse_iw_link_beacon_int(_net_run(["iw", "dev", "wlan0", "link"], 5)[1]),
country_pending=(lambda: _NET_ENGINE.country_pending()) if _NET_ENGINE else (lambda: False))
_AP = ApRoutes(engine=_AP_ENGINE, db=db)
except Exception as _ape:
_AP_INIT_ERROR = str(_ape)
print(f"[startup] WARNING: AP subsystem unavailable: {_AP_INIT_ERROR}", flush=True)
# AP: subsystem init — end
def _network_apply_provider():
"""v1.6.0 I4: system_status 용 network_apply provider — 절대 raise 금지 (fail-soft).
snapshot() 1회 호출을 watchdog/interfaces 두 키가 공유."""
try:
snap = _NET_WATCHDOG.snapshot()
return {
"watchdog": snap,
"drift": _net_drift(),
"country_pending": _NET_ENGINE.country_pending(),
"interfaces": {name: ("ok" if healthy else "fail")
for name, healthy in snap["last_results"].items()},
}
except Exception as e: # noqa: BLE001 — provider 실패가 system-status 전체를 죽이면 안 됨
return {"error": str(e)}
class ConfigHandler(BaseHTTPRequestHandler):
"""HTTP request handler for the Web Configurator API."""
# Suppress default logging per-request (we log manually)
def log_message(self, format, *args):
print(f"[{self.log_date_time_string()}] {format % args}")
# ─── CORS ────────────────────────────────────────────────────────────
def _send_cors_headers(self):
"""Add CORS headers matching Java CorsConfig (allow all)."""
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "*")
self.send_header("Access-Control-Max-Age", "3600")
# ─── Response Helpers ────────────────────────────────────────────────
def _send_json_response(self, data, status=200):
"""Send a JSON response."""
body = json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self._send_cors_headers()
self.end_headers()
self.wfile.write(body)
def _send_text_response(self, text, status=200):
"""Send a plain text response."""
body = text.encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self._send_cors_headers()
self.end_headers()
self.wfile.write(body)
def _send_no_content(self):
"""Send 204 No Content response."""
self.send_response(204)
self._send_cors_headers()
self.end_headers()
def _send_error_response(self, message, status=500):
"""Send an error response as JSON."""
self._send_json_response({"error": message}, status)
def _send_binary_response(self, data, content_type, filename, status=200):
"""Send a binary file download response."""
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(data)))
self.send_header("Content-Disposition", f'attachment; filename="{filename}"')
self._send_cors_headers()
self.end_headers()
self.wfile.write(data)
MAX_BODY_SIZE = 1 * 1024 * 1024 # 1MB
def _read_request_body(self):
"""Read and parse JSON request body.
Returns:
dict on success, or None if an error response was already sent
(caller should just `return` without sending another response).
"""
try:
content_length = int(self.headers.get("Content-Length", 0))
except (ValueError, TypeError):
self._send_error_response("Invalid Content-Length", 400)
return None
if content_length < 0:
self._send_error_response("Invalid Content-Length", 400)
return None
if content_length > self.MAX_BODY_SIZE:
self._send_error_response("Payload Too Large", 413)
return None
if content_length == 0:
self._send_error_response("Empty request body", 400)
return None
body = self.rfile.read(content_length)
try:
return json.loads(body.decode("utf-8"))
except UnicodeDecodeError:
self._send_error_response("Invalid UTF-8 encoding", 400)
return None
except json.JSONDecodeError as e:
self._send_error_response(f"Invalid JSON: {e}", 400)
return None
# ─── Static File Serving ─────────────────────────────────────────────
def _is_compressible(self, mime):
"""Return True if this MIME type should be gzip-compressed."""
return any(mime.startswith(p) for p in _COMPRESSIBLE_MIME)
def _accepts_gzip(self, accept_encoding):
"""Parse Accept-Encoding for gzip with q-value support.
Returns True if client accepts gzip with q > 0.
Handles 'gzip;q=0' (explicit reject) correctly — plain 'in' check misses this.
v1.5.4.2 L1 fix.
"""
if not accept_encoding:
return False
for part in accept_encoding.split(','):
tok = part.strip().split(';')
name = tok[0].strip().lower()
if name in ('gzip', 'x-gzip'):
q = 1.0
for p in tok[1:]:
p = p.strip()
if p.startswith('q='):
try:
q = float(p[2:])
except (ValueError, TypeError):
pass
return q > 0
return False
def _serve_static_file(self, path):
"""Serve a static file from the static directory.
v1.5.4 enhancements:
- gzip compression for compressible MIME types when client accepts it
- ETag (weak, mtime+size) + If-None-Match → 304 Not Modified
- Cache-Control: HTML → no-cache; JS/CSS → no-cache (v1.5.4.2); fonts/images → max-age=86400
"""
# Default to index.html
if path == "/" or path == "":
path = "/index.html"
# Security: prevent path traversal and symlink attacks
file_path = os.path.realpath(os.path.join(STATIC_DIR, path.lstrip("/")))
if not file_path.startswith(os.path.realpath(STATIC_DIR) + os.sep):
self._send_error_response("Forbidden", 403)
return
if not os.path.isfile(file_path):
# SPA fallback: serve index.html for unknown routes
file_path = os.path.join(STATIC_DIR, "index.html")
if not os.path.isfile(file_path):
self._send_error_response("Not Found", 404)
return
# Determine content type
content_type, _ = mimetypes.guess_type(file_path)
if content_type is None:
content_type = "application/octet-stream"
try:
# v1.5.4.3 B2 fix: open + os.fstat 결합 — stat()↔open() 사이의 TOCTOU 제거.
# 이전 패턴(os.stat → open)은 deploy.ps1 atomic-swap 윈도우 사이에 mtime/size가
# 갱신될 수 있어 ETag와 body가 서로 다른 파일 표현이 될 위험이 있었다.
with open(file_path, "rb") as f:
content = f.read()
stat = os.fstat(f.fileno())
etag_raw_suffix = f'{int(stat.st_mtime)}-{stat.st_size}'
# v1.5.4.3 B1 fix: Cache-Control 정책을 304 분기 전에 계산해 304 응답에도 포함.
# RFC 7232 §4.1 — 304는 200에서 보낼 Cache-Control 등 freshness 헤더를 포함해야 한다.
if content_type.startswith("text/html"):
cache_control = "no-cache, must-revalidate"
elif (content_type.startswith("font/") or
content_type.startswith("image/") or
file_path.endswith(".woff2")):
cache_control = "public, max-age=86400"
else:
# v1.5.4.2 H1+M2 fix: JS/CSS use no-cache (always revalidate via ETag).
# 304 Not Modified with gzip is essentially 0-byte body — perf almost
# identical to max-age, but deploy propagation is immediate (no 5-min
# stale window where deployed HTML+old JS coexist).
cache_control = "no-cache, must-revalidate"
# v1.5.4: gzip compression for compressible types (>256 bytes) when
# client sends Accept-Encoding: gzip. .woff2/images are excluded via
# _is_compressible().
# v1.5.4.3 B4 fix: gzip 표현과 비압축 표현을 서로 다른 ETag로 구분.
accepts = self.headers.get("Accept-Encoding") or ""
wants_gzip = (self._accepts_gzip(accepts)
and self._is_compressible(content_type)
and len(content) > 256)
content_encoding = None
etag = f'W/"{etag_raw_suffix}{"-gz" if wants_gzip else ""}"'
# v1.5.4: Conditional GET — If-None-Match → 304 Not Modified
# v1.5.4.3 B1+B5 fix: 304 응답에도 Cache-Control + Vary 포함 (compressible MIME일 때).
if_none_match = self.headers.get("If-None-Match") or ""
if if_none_match == etag:
self.send_response(304)
self.send_header("ETag", etag)
self.send_header("Cache-Control", cache_control)
if self._is_compressible(content_type):
self.send_header("Vary", "Accept-Encoding")
self._send_cors_headers()
self.end_headers()
return
if wants_gzip:
content = gzip.compress(content, compresslevel=6)
content_encoding = "gzip"
self.send_response(200)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(content)))
self.send_header("Cache-Control", cache_control)
self.send_header("ETag", etag)
# v1.5.4.3 B5 fix: 압축 가능 MIME이면 비압축 응답에도 Vary 포함 (proxy 캐시 안전).
if self._is_compressible(content_type):
self.send_header("Vary", "Accept-Encoding")
if content_encoding:
self.send_header("Content-Encoding", content_encoding)
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("X-Frame-Options", "DENY")
self._send_cors_headers()
self.end_headers()
self.wfile.write(content)
except IOError:
self._send_error_response("Internal Server Error", 500)
# ─── OPTIONS (CORS Preflight) ────────────────────────────────────────
def do_OPTIONS(self):
"""Handle CORS preflight requests."""
self.send_response(204)
self._send_cors_headers()
self.end_headers()
# ─── GET Handlers ────────────────────────────────────────────────────
def do_GET(self):
"""Route GET requests."""
parsed = urlparse(self.path)
path = parsed.path
if path == "/setting/get-device":
self._handle_get_device()
elif path == "/setting/get-protocol":
self._handle_get_protocol()
elif path == "/setting/log-files":
self._handle_get_log_files()
elif path == "/setting/kernel-bundle":
self._handle_get_kernel_bundle()
elif path == "/setting/log-stats":
self._handle_get_log_stats()
elif path == "/api/mac":
self._handle_get_mac()
elif path == "/api/health":
self._handle_health()
elif path == "/api/system-status":
self._handle_system_status()
elif path == "/api/support-bundle":
self._handle_support_bundle()
elif path == "/api/firmware/status":
self._handle_firmware_status()
elif path == "/api/network/state":
self._net_json(lambda: _NET.state())
elif path == "/api/network/drift":
# v1.7.0: 경량 전역 미적용 카운트 — drift 만 (subprocess 0)
self._net_json(lambda: _NET.drift())
elif path == "/api/network/apply/status":
from urllib.parse import parse_qs
q = parse_qs(parsed.query)
aid = (q.get("id") or [None])[0]
self._net_json(lambda: _NET.status(aid))
elif path == "/api/network/journal":
from urllib.parse import parse_qs
q = parse_qs(parsed.query)
lim = (q.get("limit") or ["50"])[0]
self._net_json(lambda: _NET.journal_tail(lim))
elif path == "/api/network/config":
# #2: watchdog kill-switch — 현재 net_config (효과적 기본값 merge) 조회
self._net_json(lambda: _NET.get_config())
elif path == "/api/network/uplink":
self._net_json(lambda: _NET.get_uplink())
# AP: status route
elif path == "/api/network/ap/status":
self._ap_json(lambda: _AP.status())
elif path.startswith("/setting/") or path.startswith("/api/"):
# v1.5.0: /api/ prefix guard — unknown API routes return 404 instead
# of falling through to _serve_static_file (which would serve
# index.html via SPA fallback, masking client typos in API URLs).
self._send_error_response("Not Found", 404)
else:
self._serve_static_file(path)
def _handle_get_device(self):
"""GET /setting/get-device — Retrieve device configuration.
Log-management settings are stored separately in `log_config` (out of
reach of the legacy Java app); they are merged into the response so the
client still sees one combined device config.
review F1/merge: empty {} also triggers 204 so the frontend F2 guard
fires for the seed-ordering window (config_initialized = present AND
non-empty dict).
"""
try:
if not db.config_initialized("device_config"):
self._send_no_content()
return
config = db.get_config("device_config")
if isinstance(config, dict) and db.config_exists("log_config"):
log_config = db.get_config("log_config")
if isinstance(log_config, dict):
config.update(log_config)
self._send_json_response(config)
except Exception:
traceback.print_exc()
self._send_error_response("Failed to read device_config")
def _handle_get_protocol(self):
"""GET /setting/get-protocol — Retrieve protocol configuration.
review F1/merge: empty {} also triggers 204 (config_initialized guard).
"""
try:
if not db.config_initialized("protocol_config"):
self._send_no_content()
return
config = db.get_config("protocol_config")
self._send_json_response(config)
except Exception:
traceback.print_exc()
self._send_error_response("Failed to read protocol_config")
# #30 fix: uptime must use a monotonic clock so RTC skew / NTP jumps do not
# corrupt it. main() overwrites this at runtime (just before serve_forever).
_server_start_time = time.monotonic()
def _handle_health(self):
"""GET /api/health — Server health check with uptime and status."""
uptime = time.monotonic() - self._server_start_time
# #30 fix: probe the DB instead of reporting db_ok from a mere path bool.
try:
db.config_exists("device_config")
db_ok = True
except Exception: # noqa: BLE001 — any probe failure means not-ok
db_ok = False
# Unauthenticated endpoint: do NOT leak the absolute db path or pid.
# Expose only a boolean readiness flag.
self._send_json_response({
"status": "healthy",
"uptime_sec": round(uptime, 2),
"db_backend": getattr(db, "_backend", "unknown"),
"db_ok": db_ok,
})
def _handle_get_mac(self):
"""GET /api/mac — Return WiFi MAC address for device identification."""
try:
with open("/sys/class/net/wlan0/address", "r") as f:
mac = f.read().strip()
self._send_json_response({"mac": mac})
except (FileNotFoundError, IOError):
# wlan0 not available (e.g., dev PC) — return null gracefully
self._send_json_response({"mac": None})
# ─── Dashboard / Status Handlers ─────────────────────────────
def _handle_system_status(self):
"""GET /api/system-status — Aggregated device health snapshot."""
try:
self._send_json_response(get_system_status(db))
except Exception as e:
traceback.print_exc()
self._send_json_response(
{"error": f"Failed to gather status: {str(e)}"}, 500)
def _handle_support_bundle(self):
"""GET /api/support-bundle — Diagnostics zip download."""
try:
data = build_support_bundle(db)
timestamp = time.strftime("%Y-%m-%d_%H%M%S")
self._send_binary_response(
data, "application/zip", f"support-bundle_{timestamp}.zip")
except Exception:
traceback.print_exc()
# Don't leak internal fs paths / exception detail to the client.
self._send_json_response(
{"error": "Failed to build bundle"}, 500)
def _handle_test_connections(self):
"""POST /api/action/test-connections — TCP probe configured servers."""
try:
self._send_json_response({"results": test_connections(db)})
except Exception as e:
traceback.print_exc()
self._send_json_response(
{"error": f"Connection test failed: {str(e)}"}, 500)
def _handle_restart_dpworldapp(self):
"""POST /api/action/restart-dpworldapp — Restart the dpworldapp unit."""
try:
self._send_json_response(restart_dpworldapp())
except Exception as e:
traceback.print_exc()
self._send_json_response(
{"restarted": False, "running": False,
"error": f"Restart failed: {str(e)}"}, 500)
# ─── POST Handlers ───────────────────────────────────────────────────
def do_POST(self):
"""Route POST requests."""
parsed = urlparse(self.path)
path = parsed.path
if path == "/setting/device":
self._handle_post_device()
elif path == "/setting/protocol":
self._handle_post_protocol()
elif path == "/setting/log-download":
self._handle_post_log_download()
elif path == "/setting/log-compress":
self._handle_post_log_compress()
elif path == "/setting/log-delete":
self._handle_post_log_delete()
elif path == "/api/action/test-connections":
self._handle_test_connections()
elif path == "/api/action/restart-dpworldapp":
self._handle_restart_dpworldapp()
elif path == "/api/firmware/preflight":
self._handle_firmware_preflight()
elif path == "/api/firmware/upload":
# #36 fix: a coarse per-read socket timeout (was settimeout(None),
# which let a slow-trickle client pin a worker thread forever →
# thread exhaustion). 120s/read is generous for large firmware ZIPs
# (each rfile.read() only needs to make progress within the window).
self.connection.settimeout(_FW_UPLOAD_READ_TIMEOUT_S)
try:
self._handle_firmware_upload()
finally:
self.connection.settimeout(None)
elif path == "/api/firmware/flash":
self._handle_firmware_flash()
elif path == "/api/firmware/restore-check":
self._handle_firmware_restore_check()
elif path == "/api/network/apply":
data = self._read_request_body()
if data is None:
return
# v1.6.0 I5 (v1.4.6.3 C1 패턴): JSON array/scalar → 400, {} coerce 금지
if not isinstance(data, dict):
self._send_json_response(
{"ok": False, "error": "request body must be a JSON object"}, 400)
return
self._net_json(lambda: _NET.apply(data))
elif path == "/api/network/apply/confirm":
data = self._read_request_body()
if data is None:
return
# v1.6.0 I5 (v1.4.6.3 C1 패턴): JSON array/scalar → 400, {} coerce 금지
if not isinstance(data, dict):
self._send_json_response(
{"ok": False, "error": "request body must be a JSON object"}, 400)
return
self._net_json(lambda: _NET.confirm(data))
elif path == "/api/network/rollback":
self._net_json(lambda: _NET.rollback())
elif path == "/api/network/config":
# #2: watchdog kill-switch write — net_config 의 watchdog 키 + critical 재무장
data = self._read_request_body()
if data is None:
return
if not isinstance(data, dict):
self._send_json_response(
{"ok": False, "error": "request body must be a JSON object"}, 400)
return
self._net_json(lambda: _NET.config(data))
elif path == "/api/network/uplink":
data = self._read_request_body()
if data is None:
return
if not isinstance(data, dict):
self._send_json_response({"ok": False, "error": "request body must be a JSON object"}, 400)
return
self._net_json(lambda: _NET.post_uplink(data))
# AP: config + apply routes
elif path == "/api/network/ap/config":
data = self._read_request_body()
if data is None:
return
if not isinstance(data, dict):
self._send_json_response({"ok": False, "error": "request body must be a JSON object"}, 400)
return
self._ap_json(lambda: _AP.config(data))
elif path == "/api/network/ap/apply":
data = self._read_request_body()
if data is None:
return
if not isinstance(data, dict):
self._send_json_response({"ok": False, "error": "request body must be a JSON object"}, 400)
return
self._ap_json(lambda: _AP.apply(data))
else:
self._send_error_response("Not Found", 404)
# ─── Firmware OTA Handlers (v1.5.0 Phase 4a) ────────────────────────
def _handle_firmware_status(self):
"""GET /api/firmware/status — Current OTA status snapshot."""
try:
self._send_json_response(_FW.status())
except Exception as e:
traceback.print_exc()
self._send_json_response({"error": str(e)}, 500)
def _handle_firmware_preflight(self):
"""POST /api/firmware/preflight — Verify device readiness before flashing."""
try:
self._send_json_response(_FW.preflight())
except RouteError as e:
self._send_json_response({"error": e.message}, e.status)
except Exception as e:
traceback.print_exc()
self._send_json_response({"error": str(e)}, 500)
def _handle_firmware_upload(self):
"""POST /api/firmware/upload — Stream firmware ZIP to staging area."""
try:
cl = self.headers.get("Content-Length")
result = _FW.upload(self.rfile, cl)
self._send_json_response(result)
except RouteError as e:
self._send_json_response({"error": e.message}, e.status)
except Exception as e:
traceback.print_exc()
self._send_json_response({"error": str(e)}, 500)
def _handle_firmware_flash(self):
"""POST /api/firmware/flash — Start flashing staged firmware."""
try:
self._send_json_response(_FW.flash())
except RouteError as e:
self._send_json_response({"error": e.message}, e.status)
except Exception as e:
traceback.print_exc()
self._send_json_response({"error": str(e)}, 500)
def _handle_firmware_restore_check(self):
"""POST /api/firmware/restore-check — Verify backup config integrity."""
try:
self._send_json_response(_FW.restore_check())
except RouteError as e:
self._send_json_response({"error": e.message}, e.status)
except Exception as e:
traceback.print_exc()
self._send_json_response({"error": str(e)}, 500)
# ─── Network Apply Handlers (v1.6.0) ────────────────────────────────────
def _net_json(self, fn):
"""Common helper for /api/network/* — 503 when subsystem unavailable."""
if _NET is None:
self._send_json_response(
{"ok": False, "error": f"network subsystem unavailable: {_NET_INIT_ERROR}"}, 503)
return
try:
self._send_json_response(fn())
except NetRouteError as e:
self._send_json_response({"ok": False, "error": e.message}, e.status)
except OSError:
traceback.print_exc()
# v1.11.5: sanitize — do not leak internal fs paths from OSError str()
self._send_json_response({"ok": False, "error": "Network configuration write failed"}, 500)
except Exception:
traceback.print_exc()
# Don't leak internal exception details to clients
self._send_json_response({"ok": False, "error": "Network request failed"}, 500)
# AP: routes json helper
def _ap_json(self, fn):
if _AP is None:
self._send_json_response(
{"ok": False, "error": f"AP subsystem unavailable: {_AP_INIT_ERROR}"}, 503)
return
try:
self._send_json_response(fn())
except ApRouteError as e:
self._send_json_response({"ok": False, "error": e.message}, e.status)
except OSError:
traceback.print_exc()
# v1.11.5: sanitize — do not leak internal fs paths from OSError str()
self._send_json_response({"ok": False, "error": "AP configuration write failed"}, 500)
except Exception:
traceback.print_exc()
# Don't leak internal exception details to clients
self._send_json_response({"ok": False, "error": "AP request failed"}, 500)
def _handle_post_device(self):
"""POST /setting/device — Save device configuration."""
try:
data = self._read_request_body()
if data is None:
return # Error response already sent
# v1.4.0.2: reject empty body (was: silently replaced device_config with {})
if not data:
self._send_json_response({"ok": False, "errors": ["empty body — no fields to save"]}, 400)
return
# v1.4.6.3 C1: JSON array/scalar 등 non-dict 차단
if not isinstance(data, dict):
self._send_json_response({"ok": False, "errors": ["request body must be a JSON object"]}, 400)
return
# review F1/merge: never POST-create device_config (dpworldapp-owned).
# Absent or empty {} = seed-ordering window → 409 so the client retries later.
if not db.config_initialized("device_config"):
self._send_json_response(
{"ok": False, "errors": ["device_config is not initialized"],
"code": "not_initialized"}, 409)
return
# v1.4.0.3: case-only alias normalization (e.g. "ON"→"on", "WPA/WPA2"→"wpa/wpa2")
# must run before enum validation so aliases pass validation correctly
data = normalize_device_input(data)
# v1.4.4: Super_Relay 소유 키 strict reject (silent passthrough 방지)
sr_errors = validate_super_relay_keys(data, "device")
if sr_errors:
self._send_json_response({"ok": False, "errors": sr_errors}, 400)
return
# Strict enum validation — reject unknown values immediately (blocking)
enum_errors = validate_device_enums(data)
if enum_errors:
self._send_json_response({"ok": False, "errors": enum_errors}, 400)
return
# v1.4.2 I2: wifi_country_code hard-reject via dedicated function (string-match 제거)
cc_error = validate_wifi_country_code(data.get("wifi_country_code"))
if cc_error:
self._send_json_response({"ok": False, "errors": [cc_error]}, 400)
return
# v1.4.5 fix-1: rs485 integer 타입 hard-reject (sibling pattern with v1.4.1 wifi_country_code / v1.4.3 odo_field fix-1)
rs485_errors = validate_rs485_integers(data)
if rs485_errors:
self._send_json_response({"ok": False, "errors": rs485_errors}, 400)
return
# v1.11.8 D: IP/netmask/gateway/dns/server_ip 형식 hard-reject
# (validate_device_config 가 soft-warn 만 하던 것 → blocking 400).
# 잘못된 IP 가 device_config 에 잔존 → Java/dpworldapp 파싱 실패 wipe 방지.
ip_errors = validate_device_ip_fields_hard(data)
if ip_errors:
self._send_json_response({"ok": False, "errors": ip_errors}, 400)
return
# v1.11.8 C: WIFI_SSID 프로파일 hard-reject (network/validator 규칙 재사용).
# 펌웨어 wpa 렌더 한계 위반(절단/escaping 없음/gap)을 save 경로에서 차단.
ssid_errors = validate_wifi_ssid_profiles(data)
if ssid_errors:
self._send_json_response({"ok": False, "errors": ssid_errors}, 400)
return
# v1.11.8 B: log-config 파괴값 hard-reject (auto_cleanup 이 전부 삭제하는 것 방지).
# log_* split/write 이전에 실행 (validate_device_config soft-warn 보강).
log_errors = validate_log_config_hard(data)
if log_errors:
self._send_json_response({"ok": False, "errors": log_errors}, 400)
return
# Validate (다른 errors는 soft-warn 그대로)
errors = validate_device_config(data)
if errors:
print(f"[WARN] Device config validation: {errors}")
# Return validation errors to client (non-blocking: save proceeds but client is informed)
# Strip None values (legacy app omits null-valued keys on save)
cleaned = strip_none_values(data)
# v1.4.6.9 H5: ports type/range hard-reject — soft-warn에서 invalid port가 device_config에
# string으로 잔존 → 정수 parse fail → wipe path 발생하던 결함 차단.
# validate_rs485_integers / validate_wifi_country_code sibling pattern.
port_errors = validate_device_port_types_hard(cleaned)
if port_errors:
self._send_json_response({"ok": False, "errors": port_errors}, 400)
return
# v1.4.6.7 C-2: backend port type normalize (defense in depth — frontend fix와 sibling).
# 외부 도구가 string port 보내도 dha + Java Integer schema 정합 보장.
normalize_device_port_types(cleaned)
# Split log-management settings into their own `log_config` key —
# `device_config` is overwritten by the legacy Java app, which
# would drop these Python-only fields.
log_part = {k: cleaned.pop(k) for k in LOG_CONFIG_KEYS if k in cleaned}
# v1.5.1 T2: atomic RMW via update_config (H1/H2/H6 race fix).
# Replaces the get_config + merge + save_config triple with a single
# atomic BEGIN IMMEDIATE transaction so concurrent POSTs cannot lose
# each other's updates.
_cleaned_device = cleaned # closure capture
def _merge_device(current):
if not isinstance(current, dict) or not current:
return current
base = current
return {**base, **_cleaned_device}
merged_device = db.update_config("device_config", _merge_device)
if log_part:
_log_part = log_part # closure capture
def _merge_log(current):
base = current if isinstance(current, dict) else {}
return {**base, **_log_part}
merged_log = db.update_config("log_config", _merge_log, default={})
else:
merged_log = db.get_config("log_config") or {}
# Response with structured result + v1.4.0.2 diagnostic fields
self._send_json_response({
"success": True,
"message": "Device config saved",
"warnings": errors or [],
"merged_keys": sorted(list(cleaned.keys()) + list(log_part.keys())),
"preserved_keys_count": max(0, len(merged_device) - len(cleaned)),
})
except Exception as e:
traceback.print_exc()
# Don't leak internal exception details to clients
self._send_json_response({"success": False, "message": "Save failed"}, 500)
def _handle_post_protocol(self):
"""POST /setting/protocol — Save protocol configuration."""
try:
data = self._read_request_body()
if data is None:
return # Error response already sent
# v1.4.0.2: empty body guard
if not data:
self._send_json_response({"ok": False, "errors": ["empty body"]}, 400)
return
# v1.4.6.3 C1: JSON array/scalar 등 non-dict 차단
if not isinstance(data, dict):
self._send_json_response({"ok": False, "errors": ["request body must be a JSON object"]}, 400)
return
# review F1/merge: never POST-create protocol_config (dpworldapp-owned).
if not db.config_initialized("protocol_config"):
self._send_json_response(
{"ok": False, "errors": ["protocol_config is not initialized"],
"code": "not_initialized"}, 409)
return
# v1.4.0.3: case-only alias normalization (e.g. can_input "ON"→"on")
data = normalize_protocol_input(data)
# v1.4.4: Super_Relay 소유 키 strict reject (silent passthrough 방지)
sr_errors = validate_super_relay_keys(data, "protocol")
if sr_errors:
self._send_json_response({"ok": False, "errors": sr_errors}, 400)
return
# Strict enum validation — reject unknown values immediately (blocking)
enum_errors = validate_protocol_enums(data)
if enum_errors:
self._send_json_response({"ok": False, "errors": enum_errors}, 400)
return
# v1.4.3 fix-1: nested odo_field hard-reject
# validate_protocol_config also calls validate_odo_field but soft-warn only —
# server.py must hard-reject explicitly (sibling pattern with v1.4.1 wifi_country_code fix-1)
odo_errors = []
odo_errors.extend(validate_odo_field(data.get("odo_speed"), "odo_speed"))
odo_errors.extend(validate_odo_field(data.get("odo_direction"), "odo_direction"))
if odo_errors:
self._send_json_response({"ok": False, "errors": odo_errors}, 400)
return
# v1.11.8 A: register-entry hard-reject (MODBUS/OPC_UA/CAN per-entry
# expr/shift/mask/id/dv/idt/odt + structural: non-list, >1000 cap,
# required field/addr/id). validate_protocol_config soft-warn 보강 →
# blocking 400 (sibling pattern with validate_odo_field fix-1 above).
#
# v1.11.9 Fix 3: gate the CAN array on the EFFECTIVE can_input. A
# partial POST may carry a CAN array but omit can_input while the DB
# already has it 'on' — compute the prospective merged protocol_config
# (current DB ∪ incoming) so the gate uses the effective value and the
# expr injection guard is not silently skipped.
try:
_db_proto = db.get_config("protocol_config")
except Exception:
_db_proto = None
_merged_proto = {**(_db_proto if isinstance(_db_proto, dict) else {}), **data}
register_errors = validate_register_entries(data, merged_data=_merged_proto)
if register_errors:
self._send_json_response({"ok": False, "errors": register_errors}, 400)
return
# Ensure MEID is string
data = ensure_meid_string(data)
# Validate
errors = validate_protocol_config(data)
if errors:
print(f"[WARN] Protocol config validation warnings: {errors}")
# Strip None values
cleaned = strip_none_values(data)
# 2026-07-08: canonicalize the arrays being WRITTEN (idt space-form,
# drop shift/mask, identity expr for Modbus/OPC-UA). Applied to the
# incoming data only, so preserved/inactive arrays (merged from the DB
# inside _merge_protocol) stay byte-for-byte unchanged (C1 partial-POST
# preservation invariant). Legacy stored data is canonicalized once by
# migrate_protocol_field_cleanup at startup.
cleaned = canonicalize_protocol_entries(cleaned)
# v1.5.1 T2: atomic RMW via update_config (H1/H2/H6 race fix).
# Replaces the get_config + merge + save_config triple with a single
# atomic BEGIN IMMEDIATE transaction so concurrent POSTs cannot lose
# each other's updates. process_protocol_config + odo_on pop run
# inside the mutator so the full transformation is atomic.
_cleaned_protocol = cleaned # closure capture
def _merge_protocol(current):
if not isinstance(current, dict) or not current:
return current
base = current
result = {**base, **_cleaned_protocol}
# v1.4.6.8 C1: partial-merge AFTER process_protocol_config to preserve arrays
result = process_protocol_config(result)
# v1.4.6.2 F5: odo_on=off 시 nested odo objects 명시 제거
if result.get("odo_on") == "off":
result.pop("odo_speed", None)
result.pop("odo_direction", None)
return result
merged = db.update_config("protocol_config", _merge_protocol)
self._send_json_response({
"success": True,
"message": "Protocol config saved",
"warnings": errors or [],
"merged_keys": sorted(cleaned.keys()),
"preserved_keys_count": max(0, len(merged) - len(cleaned)),
}, 200)
except Exception as e:
traceback.print_exc()
# v1.4.6.9 H3: twin _handle_post_device (line 461)과 일관된 JSON 응답.
# 이전 text/plain은 frontend api.js의 response.json() 호출 시 SyntaxError 발생 →
# 사용자에게 actionable error UI 표시 못 함.
self._send_json_response({"success": False, "message": "Save failed"}, 500)
# ─── Log Management Handlers ────────────────────────────────
def _handle_get_log_files(self):
"""GET /setting/log-files — List log files with metadata."""
try:
result = list_log_files()
self._send_json_response(result)
except Exception:
traceback.print_exc()
# Don't leak internal fs paths / exception detail to the client.
self._send_json_response(
{"error": "Failed to list log files"}, 500
)
def _handle_get_kernel_bundle(self):
"""GET /setting/kernel-bundle — Download kernel log bundle as tar.gz."""
tar_path = None
headers_sent = False
try:
tar_path = kernel_log.build_kernel_bundle()
size = os.path.getsize(tar_path)
fname = os.path.basename(tar_path)
self.send_response(200)
self.send_header("Content-Type", "application/gzip")
self.send_header("Content-Length", str(size))
self.send_header("Content-Disposition", f'attachment; filename="{fname}"')
self._send_cors_headers()
self.end_headers()
headers_sent = True
with open(tar_path, "rb") as f:
shutil.copyfileobj(f, self.wfile)
except Exception:
traceback.print_exc()
if not headers_sent:
# Safe to emit a 500 — nothing has been written to the socket yet.
# Don't leak internal fs paths / exception detail to the client.
try:
self._send_json_response(
{"error": "Failed to build kernel bundle"}, 500
)
except Exception:
pass
# else: headers already committed; cannot change status. Just close.
finally:
if tar_path:
shutil.rmtree(os.path.dirname(tar_path), ignore_errors=True)
def _handle_post_log_download(self):
"""POST /setting/log-download — Download selected log files as tar.gz."""
archive_path = None
headers_sent = False
try:
data = self._read_request_body()
if data is None:
return # body-parse error already responded
if not isinstance(data, dict):
self._send_json_response({"error": "Request body must be a JSON object"}, 400)
return
filenames = data.get("files", [])
valid_paths, error = validate_filenames(filenames)
if error:
status = 404 if "not found" in error.lower() else 400
self._send_json_response({"error": error}, status)
return
try:
archive_path = create_download_archive(valid_paths)
except FileNotFoundError as exc:
# v1.4.6.5 L-D: 모든 file이 race로 사라진 경우 → 404 (empty archive 200 응답 차단)
self._send_json_response({"error": str(exc)}, 404)
return
size = os.path.getsize(archive_path)
timestamp = time.strftime("%Y-%m-%d_%H%M%S")
filename = f"logs_{timestamp}.tar.gz"
self.send_response(200)
self.send_header("Content-Type", "application/gzip")
self.send_header("Content-Length", str(size))
self.send_header("Content-Disposition", f'attachment; filename="{filename}"')
self._send_cors_headers()
self.end_headers()
headers_sent = True
with open(archive_path, "rb") as f:
shutil.copyfileobj(f, self.wfile)
except Exception:
traceback.print_exc()
if not headers_sent:
try:
self._send_json_response({"error": "Download failed"}, 500)
except Exception:
pass
finally:
if archive_path:
shutil.rmtree(os.path.dirname(archive_path), ignore_errors=True)
def _handle_get_log_stats(self):
"""GET /setting/log-stats — Return log directory disk usage stats."""
try:
stats = get_log_stats()
self._send_json_response(stats)
except Exception as e:
traceback.print_exc()
self._send_json_response(
{"error": f"Failed to get log stats: {str(e)}"}, 500
)
def _handle_post_log_compress(self):
"""POST /setting/log-compress — Manually compress non-active log files."""
try:
result = run_manual_compression()
self._send_json_response(result)
except Exception as e:
traceback.print_exc()
self._send_json_response(
{"success": False, "message": f"Compression failed: {str(e)}"}, 500
)
def _handle_post_log_delete(self):
"""POST /setting/log-delete — Delete selected log files."""
try:
data = self._read_request_body()
if data is None:
return # Error response already sent
if not isinstance(data, dict):
self._send_json_response(
{"success": False, "message": "Request body must be a JSON object"}, 400
)
return
filenames = data.get("files", [])
if not filenames:
self._send_json_response(
{"success": False, "deleted": 0, "message": "No files specified"}, 400
)
return
result = delete_log_files(filenames)
self._send_json_response(result)
except Exception as e:
traceback.print_exc()
self._send_json_response(
{"success": False, "message": "Delete failed"}, 500
)
# v1.5.1 H4 slowloris hardening: bound socket/request read timeout to 30s.
# Prevents pathological clients from holding connections open indefinitely
# (slowloris: send headers byte-by-byte, never complete the request).
_SOCKET_TIMEOUT_S = 30.0
# #36: firmware upload reads use a more generous per-read timeout than the 30s
# slowloris bound (large ZIPs stream in MB chunks), but still finite so a
# slow-trickle client cannot pin a worker thread forever.
_FW_UPLOAD_READ_TIMEOUT_S = 120.0
class ThreadedHTTPServer(ThreadingMixIn, HTTPServer):
"""Handle requests in separate threads for concurrent access."""
daemon_threads = True
# Per-connection timeout (seconds): applied when the server calls
# get_request() — each accepted socket inherits setdefaulttimeout value,
# limiting how long a slow/malicious client can stall a handler thread.
timeout = _SOCKET_TIMEOUT_S
def main():
"""Start the HTTP server."""
# Route module logging (log_manager auto-compress daemon, etc.) to
# stdout/stderr so systemd's journald captures it.
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
# Ensure DB tables exist (safe for production — uses IF NOT EXISTS).
# review F4: fail-soft like the migrations block below — a transient DB lock during
# concurrent dpworldapp seeding on a fresh device must defer (retry on next restart),
# not crash-loop the service (CREATE TABLE takes a write lock).
try:
db.ensure_tables()
# Migrate log-management settings into their own key (one-time, idempotent)
migrate_log_config(db)
except sqlite3.OperationalError as exc:
print(f"[startup] WARNING: DB busy during table-init/log-migrate ({exc!r}); "
"will retry on next service restart.", flush=True)
except (sqlite3.DatabaseError, json.JSONDecodeError, ValueError) as exc:
print(f"[startup] WARNING: table-init/log-migrate error (skipped, will retry): {exc!r}", flush=True)
# Run all pending schema/data migrations (one-time, idempotent)
try:
_applied = apply_all_migrations(db)
if _applied:
print(f"[startup] applied migrations: {', '.join(_applied)}", flush=True)
except sqlite3.OperationalError as exc:
print(
f"[startup] WARNING: migrations deferred due to DB lock: {exc!r}. "
"Will retry on next service restart.",
flush=True,
)
except (sqlite3.DatabaseError, json.JSONDecodeError, ValueError) as exc:
# v1.4.6.9 H11: corrupt board_config JSON / schema_meta race 등 비-lock 에러도
# fail-soft (server 시작 가능). 운영자가 dashboard로 진단/복구할 수 있는 상태 유지.
# 다음 service restart에서 재시도.
print(
f"[startup] WARNING: migration data error (skipped, will retry): {exc!r}",
flush=True,
)
# Remove leftover temp dirs from prior server crashes
kernel_log.sweep_stale_temp_dirs()
sweep_stale_download_temp_dirs()
# Start log auto-compression daemon thread
start_auto_compress_daemon(db)
# v1.6.0: Network apply engine startup tasks (guarded — subsystem may be unavailable on dev box)
if _NET_ENGINE is not None:
if _NET_ENGINE.recover_on_startup():
print("[startup] network apply recovery: rolled back unfinished apply", flush=True)
if _NET_WATCHDOG is not None:
_NET_WATCHDOG.start()
# v1.6.0: wire network_apply provider into system_status (import 순환 회피)
# I4: 모듈 레벨 fail-soft provider — try/except + snapshot() 1회 공유
# v1.11.5: always wire a provider — when _NET is None the network subsystem failed
# to init; install a fault provider so system_status returns network_apply with an
# error key rather than None, preventing the dashboard from showing green.
if _NET is not None:
set_network_apply_provider(_network_apply_provider)
else:
_init_error_msg = _NET_INIT_ERROR or "network subsystem unavailable"
def _network_apply_fault_provider():
return {"error": _init_error_msg}
set_network_apply_provider(_network_apply_fault_provider)
# v1.5.1 H4: apply default socket timeout so all new sockets (including
# accepted client sockets) block at most _SOCKET_TIMEOUT_S per read.
socket.setdefaulttimeout(_SOCKET_TIMEOUT_S)
server = ThreadedHTTPServer((HOST, PORT), ConfigHandler)
print(f"=" * 60)
print(f" IoT Web Configurator Server")
print(f" Listening on http://{HOST}:{PORT}")
print(f" DB: {db.db_path}")
print(f" Static: {STATIC_DIR}")
print(f"=" * 60)
# Set the server start time at runtime (not class-parse time) so uptime is
# accurate even on a device whose RTC was skewed during import. #30: monotonic
# so wall-clock skew / NTP jumps never corrupt the uptime delta.
ConfigHandler._server_start_time = time.monotonic()
try:
server.serve_forever()
except KeyboardInterrupt:
print("\nShutting down server...")
server.server_close()
if __name__ == "__main__":
main()