|
|
|
|
"""
|
|
|
|
|
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")
|
|
|
|
|
# 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)
|
|
|
|
|
|
|
|
|
|
# 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()
|