from __future__ import annotations import asyncio import json import os import platform import shutil import socket import subprocess import sys import time from dataclasses import dataclass from pathlib import Path from typing import Any, Iterable, Optional from urllib.error import HTTPError, URLError from urllib.request import urlopen BASE_DIR = Path(__file__).resolve().parent.parent CONFIG_DIR = BASE_DIR / "config" LINUX_DIR = BASE_DIR / "scripts" / "linux" SERVICE_CTL = LINUX_DIR / "service_ctl.sh" HW_PROBE = BASE_DIR / "scripts" / "hardware_probe.py" HW_PROFILE_JSON = CONFIG_DIR / "hardware-profile.json" STACK_ENV = CONFIG_DIR / "system-stack.env" MAX_FILES_PER_ROOT = 256 @dataclass(frozen=True) class ManagedRoot: root_id: str label: str description: str path: Path allow_create: bool = True allow_delete: bool = True recursive: bool = False explicit_names: tuple[str, ...] = () allowed_suffixes: tuple[str, ...] = () default_file: str | None = None def exists(self) -> bool: return self.path.is_dir() def allows(self, rel_path: Path) -> bool: rel_posix = rel_path.as_posix() if self.explicit_names: return rel_posix in self.explicit_names return rel_path.suffix.lower() in self.allowed_suffixes @dataclass(frozen=True) class ServiceSpec: service_id: str label: str category: str description: str SERVICE_SPECS: tuple[ServiceSpec, ...] = ( ServiceSpec("mdns", "mDNS / live.local", "system", "Hostname and mDNS discovery"), ServiceSpec("wifi", "Wi-Fi Auto Connect", "system", "Default Wi-Fi onboarding profile"), ServiceSpec( "shellcrash", "ShellCrash", "network", "透明代理/规则引擎;stopped 时一般不影响本机服务,需要翻墙/分流时点 Start", ), ServiceSpec("srs", "SRS", "stream", "RTMP/SRT relay for HDMI and live workflows"), ServiceSpec("webtty", "WebTTY", "access", "Browser terminal for live user"), ServiceSpec("cockpit", "Cockpit", "admin", "System administration dashboard"), ServiceSpec("filebrowser", "File Browser", "admin", "Beginner-friendly file manager"), ServiceSpec("homepage", "Homepage", "admin", "Start page for all LAN services"), ServiceSpec("netdata", "Netdata", "monitor", "System metrics and health monitoring"), ServiceSpec("adb", "ADB", "android", "Android bridge and device inspection"), ServiceSpec( "android_panel", "ws-scrcpy / Web Android", "android", "Browser ADB control (ws-scrcpy stack; clone URL configurable in system-stack.env)", ), ServiceSpec("chromium", "Chromium 浏览器", "browser", "本机 Chromium + 持久化配置 + Remote DevTools(非 Docker)"), ServiceSpec( "neko", "Neko 浏览器 (Docker)", "browser", "可选:Docker 内嵌 Chromium 桌面,适合远程开 YouTube Studio;ENABLE_NEKO=1 时安装", ), ServiceSpec( "neko_firefox", "Neko Firefox (第二桌面)", "browser", "可选:第二套 Neko 桌面(Firefox,arm64/x86 多架构);ENABLE_NEKO_FF=1;口令与 Chromium 相同", ), ServiceSpec( "android_gateway", "Android Gateway", "network", "双网口时下行共享给安卓板;单网口无需启用(状态为 skipped)", ), ) def shellcrash_dir() -> Path: value = os.environ.get("SHELLCRASH_DIR", "/etc/ShellCrash") return Path(os.path.expanduser(os.path.expandvars(value))) def managed_roots() -> dict[str, ManagedRoot]: shell_dir = shellcrash_dir() return { "app-config": ManagedRoot( root_id="app-config", label="Business App Config", description="Core livestream app configuration files", path=CONFIG_DIR, allow_create=True, allow_delete=False, explicit_names=( "config.ini", "youtube.ini", "URL_config.ini", "runtime.env", "runtime.env.example", ), default_file="runtime.env", ), "stack-config": ManagedRoot( root_id="stack-config", label="System Stack Config", description="Ports, feature toggles, hostname, Wi-Fi, and gateway settings", path=CONFIG_DIR, allow_create=True, allow_delete=False, explicit_names=("system-stack.env", "system-stack.env.example"), default_file="system-stack.env", ), "shellcrash-configs": ManagedRoot( root_id="shellcrash-configs", label="ShellCrash Configs", description="ShellCrash cfg/list/env style files", path=shell_dir / "configs", allow_create=True, allow_delete=True, allowed_suffixes=(".cfg", ".env", ".list", ".txt"), default_file="ShellCrash.cfg", ), "shellcrash-yamls": ManagedRoot( root_id="shellcrash-yamls", label="ShellCrash YAML", description="mihomo YAML fragments and config.yaml", path=shell_dir / "yamls", allow_create=True, allow_delete=True, allowed_suffixes=(".yaml", ".yml"), default_file="config.yaml", ), "shellcrash-jsons": ManagedRoot( root_id="shellcrash-jsons", label="ShellCrash JSON", description="sing-box JSON fragments", path=shell_dir / "jsons", allow_create=True, allow_delete=True, allowed_suffixes=(".json",), default_file="config.json", ), "srs-config": ManagedRoot( root_id="srs-config", label="SRS Config", description="SRS compose and conf files", path=BASE_DIR / "services" / "srs", allow_create=True, allow_delete=True, recursive=True, allowed_suffixes=(".conf", ".json", ".env", ".yml", ".yaml"), default_file="conf/srs.conf", ), "homepage-config": ManagedRoot( root_id="homepage-config", label="Homepage Config", description="Dashboard links and widgets", path=BASE_DIR / "services" / "homepage" / "config", allow_create=True, allow_delete=True, recursive=True, allowed_suffixes=(".yaml", ".yml", ".json"), default_file="services.yaml", ), } def _safe_rel_path(raw_path: str) -> Path: rel = Path(raw_path.strip().replace("\\", "/")) if rel.is_absolute(): raise ValueError("Absolute paths are not allowed") if any(part in {"", ".", ".."} for part in rel.parts): raise ValueError("Path traversal is not allowed") return rel def _resolve_managed_file(root_id: str, relative_path: str) -> tuple[ManagedRoot, Path, Path]: roots = managed_roots() root = roots.get(root_id) if not root: raise KeyError(f"Unknown root: {root_id}") rel = _safe_rel_path(relative_path) if not root.allows(rel): raise PermissionError(f"File type not allowed under {root_id}") root.path.mkdir(parents=True, exist_ok=True) target = (root.path / rel).resolve() root_real = root.path.resolve() if root_real not in (target, *target.parents): raise PermissionError("Resolved path escapes managed root") return root, rel, target def resolve_managed_file(root_id: str, relative_path: str) -> tuple[ManagedRoot, Path, Path]: return _resolve_managed_file(root_id, relative_path) def list_root_summaries() -> list[dict]: summaries: list[dict] = [] for root in managed_roots().values(): file_count = 0 if root.exists(): globber: Iterable[Path] globber = root.path.rglob("*") if root.recursive else root.path.glob("*") for file in globber: if file.is_file(): rel = file.relative_to(root.path) if root.allows(rel): file_count += 1 summaries.append( { "root_id": root.root_id, "label": root.label, "description": root.description, "path": str(root.path), "exists": root.exists(), "allow_create": root.allow_create, "allow_delete": root.allow_delete, "recursive": root.recursive, "default_file": root.default_file, "file_count": file_count, } ) return summaries def list_root_files(root_id: str) -> list[dict]: root = managed_roots().get(root_id) if not root: raise KeyError(f"Unknown root: {root_id}") if not root.exists(): return [] files: list[dict] = [] iterator = root.path.rglob("*") if root.recursive else root.path.glob("*") for file in iterator: if not file.is_file(): continue rel = file.relative_to(root.path) if not root.allows(rel): continue stat = file.stat() files.append( { "path": rel.as_posix(), "size": stat.st_size, "mtime": int(stat.st_mtime), } ) files.sort(key=lambda item: item["path"]) return files[:MAX_FILES_PER_ROOT] def read_root_file(root_id: str, relative_path: str) -> dict: root, rel, target = _resolve_managed_file(root_id, relative_path) if not target.is_file(): raise FileNotFoundError(relative_path) return { "root_id": root.root_id, "path": rel.as_posix(), "content": target.read_text(encoding="utf-8"), } def write_root_file(root_id: str, relative_path: str, content: str) -> dict: root, rel, target = _resolve_managed_file(root_id, relative_path) if not root.allow_create and not target.exists(): raise PermissionError(f"Creating files is disabled for {root_id}") target.parent.mkdir(parents=True, exist_ok=True) target.write_text(content, encoding="utf-8") return { "root_id": root.root_id, "path": rel.as_posix(), "message": "Saved successfully", } def delete_root_file(root_id: str, relative_path: str) -> dict: root, rel, target = _resolve_managed_file(root_id, relative_path) if not root.allow_delete: raise PermissionError(f"Deleting files is disabled for {root_id}") if not target.exists(): raise FileNotFoundError(relative_path) target.unlink() return { "root_id": root.root_id, "path": rel.as_posix(), "message": "Deleted successfully", } def _parse_kv_output(text: str) -> dict[str, str]: data: dict[str, str] = {} for raw in text.splitlines(): line = raw.strip() if not line or "=" not in line: continue key, _, value = line.partition("=") data[key.strip().lower()] = value.strip() return data async def _run_command(cmd: list[str], *, timeout: float = 20.0) -> tuple[int, str]: proc = await asyncio.create_subprocess_exec( *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, cwd=str(BASE_DIR), ) try: stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout) except asyncio.TimeoutError: proc.kill() await proc.wait() return 124, "Command timed out" out = (stdout or b"").decode("utf-8", "replace").strip() err = (stderr or b"").decode("utf-8", "replace").strip() merged = out if out else err return proc.returncode or 0, merged def _default_service_status(spec: ServiceSpec, detail: str) -> dict: return { "service_id": spec.service_id, "label": spec.label, "category": spec.category, "description": spec.description, "available": False, "running": False, "status": "unavailable", "detail": detail, "url": "", } async def query_service(spec: ServiceSpec) -> dict: if not SERVICE_CTL.is_file(): return _default_service_status(spec, "Service controller script is missing") code, output = await _run_command(["bash", str(SERVICE_CTL), spec.service_id, "status"]) if code != 0: return _default_service_status(spec, output or "Status command failed") info = _parse_kv_output(output) return { "service_id": spec.service_id, "label": spec.label, "category": spec.category, "description": spec.description, "available": info.get("available", "0") == "1", "running": info.get("running", "0") == "1", "status": info.get("status", "unknown"), "detail": info.get("detail", ""), "url": info.get("url", ""), "host": info.get("host", ""), "port": info.get("port", ""), } async def query_services() -> list[dict]: return list(await asyncio.gather(*(query_service(spec) for spec in SERVICE_SPECS))) async def service_action(service_id: str, action: str) -> dict: if action not in {"start", "stop", "restart"}: raise ValueError(f"Unsupported action: {action}") spec = next((item for item in SERVICE_SPECS if item.service_id == service_id), None) if not spec: raise KeyError(f"Unknown service: {service_id}") if not SERVICE_CTL.is_file(): return _default_service_status(spec, "Service controller script is missing") cmd = ["bash", str(SERVICE_CTL), service_id, action] code, output = await _run_command(cmd, timeout=45.0) if code != 0 and shutil.which("sudo"): code, output = await _run_command(["sudo", "-n", *cmd], timeout=45.0) info = _parse_kv_output(output) message = info.get("message", output) if code != 0: return { "service_id": service_id, "status": "error", "message": message or "Action failed", "detail": output, } status = await query_service(spec) status["message"] = message or f"{action} completed" return status def local_ips() -> list[str]: ips: list[str] = [] if shutil.which("ip"): try: raw = subprocess.check_output( ["ip", "-j", "-4", "addr", "show", "scope", "global"], cwd=str(BASE_DIR), text=True, ) ignored_prefixes = ( "docker", "br-", "veth", "virbr", "tun", "tap", "zt", "tailscale", "wg", "sit", "dummy", ) for item in json.loads(raw): ifname = str(item.get("ifname", "")) if not ifname or ifname == "lo" or ifname.startswith(ignored_prefixes): continue for addr in item.get("addr_info", []): ip = str(addr.get("local", "")).strip() if ip and not ip.startswith("127.") and ip not in ips: ips.append(ip) if ips: return ips except (OSError, ValueError, subprocess.SubprocessError, json.JSONDecodeError): pass hostname = socket.gethostname() try: for info in socket.getaddrinfo(hostname, None, family=socket.AF_INET): ip = info[4][0] if ip and not ip.startswith("127.") and ip not in ips: ips.append(ip) except OSError: pass return ips def load_stack_env() -> dict[str, str]: if not STACK_ENV.is_file(): return {} values: dict[str, str] = {} for raw in STACK_ENV.read_text(encoding="utf-8").splitlines(): line = raw.strip() if not line or line.startswith("#") or "=" not in line: continue key, _, value = line.partition("=") values[key.strip()] = value.strip().strip('"').strip("'") return values def _probe_tcp_port(host: str, port: int, timeout: float = 2.0) -> dict[str, Any]: t0 = time.monotonic() try: with socket.create_connection((host, int(port)), timeout=timeout): ms = (time.monotonic() - t0) * 1000 return {"ok": True, "reachable": True, "latency_ms": round(ms, 2), "error": ""} except OSError as exc: ms = (time.monotonic() - t0) * 1000 return {"ok": False, "reachable": False, "latency_ms": round(ms, 2), "error": str(exc)} def _probe_http_get(url: str, timeout: float = 2.5) -> dict[str, Any]: t0 = time.monotonic() try: with urlopen(url, timeout=timeout) as resp: code = int(resp.getcode() or 0) ms = (time.monotonic() - t0) * 1000 return { "ok": True, "http_code": code, "latency_ms": round(ms, 2), "reachable": True, "error": "", } except HTTPError as exc: # 404/502 等仍表示 TCP/HTTP 栈可达,需保留真实状态码(避免误报 HTTP 0) ms = (time.monotonic() - t0) * 1000 return { "ok": exc.code < 500, "http_code": int(exc.code), "latency_ms": round(ms, 2), "reachable": True, "error": "", } except (URLError, OSError, ValueError) as exc: ms = (time.monotonic() - t0) * 1000 return { "ok": False, "http_code": 0, "latency_ms": round(ms, 2), "reachable": False, "error": str(exc), } def network_iface_stats() -> dict[str, Any]: """Linux /proc/net/dev 累计流量(自开机或计数器复位以来)。""" dev = Path("/proc/net/dev") interfaces: list[dict[str, int | str]] = [] if not dev.is_file(): return {"interfaces": interfaces} skip_prefixes = ("lo", "docker", "veth", "br-", "virbr", "tun", "tap") try: lines = dev.read_text(encoding="utf-8", errors="replace").splitlines() except OSError: return {"interfaces": interfaces} for line in lines[2:]: if ":" not in line: continue name, rest = line.split(":", 1) name = name.strip() if not name or name.startswith(skip_prefixes): continue parts = rest.split() if len(parts) < 16: continue try: interfaces.append( { "name": name, "rx_bytes": int(parts[0]), "rx_packets": int(parts[1]), "tx_bytes": int(parts[8]), "tx_packets": int(parts[9]), } ) except (ValueError, IndexError): continue return {"interfaces": interfaces} def recent_pm2_log_lines(max_lines: int = 14) -> list[str]: """PM2 合并日志尾部(无 pm2 或超时时返回空)。""" if not shutil.which("pm2"): return [] try: proc = subprocess.run( ["pm2", "logs", "--nostream", "--lines", str(max_lines)], capture_output=True, text=True, timeout=12, errors="replace", ) except (OSError, subprocess.TimeoutExpired): return [] raw = (proc.stdout or "") + ("\n" + proc.stderr if proc.stderr else "") lines = [ln.rstrip() for ln in raw.splitlines() if ln.strip()] return lines[-max_lines:] if len(lines) > max_lines else lines async def stack_stream_probes() -> dict[str, Any]: """本机 SRS / Neko HTTP 端口探测(供 live.local 验收)。""" env = load_stack_env() srs_p = int(env.get("SRS_HTTP_PORT", "8080") or 8080) srs_api_p = int(env.get("SRS_API_PORT", "1985") or 1985) neko_p = int(env.get("NEKO_HTTP_PORT", "9200") or 9200) neko_ff_on = env.get("ENABLE_NEKO_FF", "0") == "1" neko_ff_p = int(env.get("NEKO_FF_HTTP_PORT", "9201") or 9201) loop = asyncio.get_event_loop() def run_srs() -> dict[str, Any]: tcp = _probe_tcp_port("127.0.0.1", srs_p) tcp_api = _probe_tcp_port("127.0.0.1", srs_api_p) # 8080 根路径常因空 data 挂载返回 404;以 1985 /api/v1/versions 作为「服务正常」判定 http = _probe_http_get(f"http://127.0.0.1:{srs_api_p}/api/v1/versions", timeout=2.5) http_ui = _probe_http_get(f"http://127.0.0.1:{srs_p}/", timeout=2.5) api_ok = int(http.get("http_code") or 0) == 200 return { "port": srs_p, "api_port": srs_api_p, "tcp": tcp, "tcp_api": tcp_api, "http": http, "http_ui": http_ui, "api_ok": api_ok, } def run_neko() -> dict[str, Any]: tcp = _probe_tcp_port("127.0.0.1", neko_p) http = _probe_http_get(f"http://127.0.0.1:{neko_p}/", timeout=2.5) return {"port": neko_p, "tcp": tcp, "http": http} def run_neko_ff() -> dict[str, Any]: tcp = _probe_tcp_port("127.0.0.1", neko_ff_p) http = _probe_http_get(f"http://127.0.0.1:{neko_ff_p}/", timeout=2.5) return {"port": neko_ff_p, "tcp": tcp, "http": http} if neko_ff_on: srs_d, neko_d, neko_ff_d = await asyncio.gather( loop.run_in_executor(None, run_srs), loop.run_in_executor(None, run_neko), loop.run_in_executor(None, run_neko_ff), ) return {"srs": srs_d, "neko": neko_d, "neko_firefox": neko_ff_d} srs_d, neko_d = await asyncio.gather( loop.run_in_executor(None, run_srs), loop.run_in_executor(None, run_neko), ) return {"srs": srs_d, "neko": neko_d} def stack_summary() -> dict: env = load_stack_env() hostname = env.get("HOSTNAME_ALIAS") or env.get("HOSTNAME") or socket.gethostname() web_port = env.get("PORT", os.environ.get("PORT", "8001")) homepage_port = env.get("HOMEPAGE_PORT", "80") mdns_host = f"{hostname}.local" webtty = env.get("WEBTTY_PORT", "7681") fb = env.get("FILEBROWSER_PORT", "8082") netd = env.get("NETDATA_PORT", "19999") srs_http = env.get("SRS_HTTP_PORT", "8080") srs_api = env.get("SRS_API_PORT", "1985") android_panel = env.get("ANDROID_PANEL_PORT", "5000") chrome_dbg = env.get("BROWSER_REMOTE_DEBUG_PORT", "9222") neko_port = env.get("NEKO_HTTP_PORT", "9200") neko_ff_port = env.get("NEKO_FF_HTTP_PORT", "9201") neko_ff_on = env.get("ENABLE_NEKO_FF", "0") == "1" ql: dict[str, str] = { "control_plane": f"http://{mdns_host}:{web_port}", "webtty": f"http://{mdns_host}:{webtty}", "filebrowser": f"http://{mdns_host}:{fb}", "netdata": f"http://{mdns_host}:{netd}", "srs_http": f"http://{mdns_host}:{srs_http}/", "srs_api": f"http://{mdns_host}:{srs_api}/api/v1/versions", "ws_scrcpy": f"http://{mdns_host}:{android_panel}", "chromium_remote_debug": f"http://{mdns_host}:{chrome_dbg}", "neko_browser": f"http://{mdns_host}:{neko_port}", } if neko_ff_on: ql["neko_firefox_browser"] = f"http://{mdns_host}:{neko_ff_port}" return { "hostname": hostname, "mdns_host": mdns_host, "platform": sys.platform, "machine": platform.machine(), "kernel": platform.release(), "python": platform.python_version(), "ips": local_ips(), "dashboard_url": f"http://{mdns_host}" if homepage_port in {"80", ""} else f"http://{mdns_host}:{homepage_port}", "control_url": f"http://{mdns_host}:{web_port}", "stack_env_present": STACK_ENV.is_file(), "neko_login": { "user_login": "live", "user_password": env.get("NEKO_USER_PASS", "12345678"), "admin_login": "admin", "admin_password": env.get("NEKO_ADMIN_PASS", "12345678"), "note_zh": "与 WebTTY、File Browser 等一致:用户名为 live / admin,口令均为 NEKO_* 环境变量(默认 12345678)。Firefox 若卡在加载:请确认 NEKO_WEBRTC_NAT1TO1 为本机局域网 IP 且 UDP 端口放行。", }, "neko_firefox_enabled": neko_ff_on, "quick_links": ql, } def _read_meminfo() -> dict[str, int]: info: dict[str, int] = {} meminfo = Path("/proc/meminfo") if not meminfo.is_file(): return info for raw in meminfo.read_text(encoding="utf-8").splitlines(): if ":" not in raw: continue key, _, value = raw.partition(":") parts = value.strip().split() if not parts: continue try: info[key] = int(parts[0]) * 1024 except ValueError: continue return info def _read_uptime_seconds() -> Optional[float]: up = Path("/proc/uptime") if not up.is_file(): return None try: first = up.read_text(encoding="utf-8", errors="replace").split() return float(first[0]) if first else None except (OSError, ValueError, IndexError): return None def system_snapshot() -> dict: disk = shutil.disk_usage("/") meminfo = _read_meminfo() load = os.getloadavg() if hasattr(os, "getloadavg") else (0.0, 0.0, 0.0) snapshot = { "cpu_load": {"1m": load[0], "5m": load[1], "15m": load[2]}, "disk": {"total": disk.total, "used": disk.used, "free": disk.free}, "memory": { "total": meminfo.get("MemTotal", 0), "available": meminfo.get("MemAvailable", 0), "free": meminfo.get("MemFree", 0), }, "uptime_seconds": _read_uptime_seconds(), "netdata": {"available": False}, } try: with urlopen("http://127.0.0.1:19999/api/v1/info", timeout=1.5) as response: payload = json.loads(response.read().decode("utf-8", "replace")) snapshot["netdata"] = { "available": True, "version": payload.get("version", ""), "mirrored_hosts": payload.get("mirrored_hosts", []), } except Exception: pass return snapshot async def load_hardware_profile(force_refresh: bool = False) -> dict: if force_refresh and HW_PROBE.is_file() and shutil.which(sys.executable): await _run_command([sys.executable, str(HW_PROBE), "--write"], timeout=30.0) if HW_PROFILE_JSON.is_file(): try: return json.loads(HW_PROFILE_JSON.read_text(encoding="utf-8")) except json.JSONDecodeError: pass if HW_PROBE.is_file(): code, output = await _run_command([sys.executable, str(HW_PROBE)], timeout=30.0) if code == 0 and output: try: return json.loads(output) except json.JSONDecodeError: return {"status": "error", "detail": output} return { "status": "unavailable", "detail": "Hardware probe not available", "arch": platform.machine(), "kernel": platform.release(), }