""" 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, 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") PORT = int(os.environ.get("PORT", "8080")) 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=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 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]), 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()) # 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. """ try: if not db.config_exists("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.""" try: if not db.config_exists("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)) # 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 # 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): base = current if isinstance(current, dict) else {} return {**base, **_cleaned_device} merged_device = db.update_config("device_config", _merge_device, default={}) 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 # 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) # 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): base = current if isinstance(current, dict) else {} if not isinstance(base, dict): print(f"[WARN] non-dict protocol_config detected (type={type(base).__name__}), falling back to empty") base = {} 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, default={}) 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) db.ensure_tables() # Migrate log-management settings into their own key (one-time, idempotent) migrate_log_config(db) # 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()