1089 lines
40 KiB
Python
1089 lines
40 KiB
Python
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import contextlib
|
||
import json
|
||
import os
|
||
import platform
|
||
import re
|
||
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
|
||
BASH_BIN = "/bin/bash" if Path("/bin/bash").is_file() else (shutil.which("bash") or "bash")
|
||
|
||
_PRO_URL_CONFIG_INI = re.compile(r"^URL_config\.[a-zA-Z0-9_.-]+\.ini$")
|
||
_PRO_YOUTUBE_INI = re.compile(r"^youtube\.[a-zA-Z0-9_.-]+\.ini$")
|
||
_NETWORK_RATE_CACHE: dict[str, Any] = {"timestamp": 0.0, "interfaces": {}}
|
||
|
||
|
||
@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:
|
||
if rel_posix in self.explicit_names:
|
||
return True
|
||
if len(rel_path.parts) == 1:
|
||
n = rel_path.name
|
||
if _PRO_URL_CONFIG_INI.match(n) or _PRO_YOUTUBE_INI.match(n):
|
||
return True
|
||
return False
|
||
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", "Proxy and route policy engine"),
|
||
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("mitmproxy", "mitmproxy", "network", "Web traffic capture and proxy debugging"),
|
||
ServiceSpec("vscode_server", "VS Code Server", "admin", "Browser IDE for the live workspace"),
|
||
ServiceSpec("uptime_kuma", "Uptime Kuma", "monitor", "Endpoint health checks and alerts"),
|
||
ServiceSpec("adb", "ADB", "android", "Android bridge and device inspection"),
|
||
ServiceSpec("redroid", "Redroid Android", "android", "Android 13 container runtime"),
|
||
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", "browser", "Persistent local browser plus remote DevTools"),
|
||
ServiceSpec(
|
||
"neko",
|
||
"Neko Browser (Docker)",
|
||
"browser",
|
||
"Isolated browser desktop with Chrome on x86 and Chromium on ARM",
|
||
),
|
||
ServiceSpec("pulseaudio", "PulseAudio", "media", "Audio bridge for browser and Android media"),
|
||
ServiceSpec("kmscube", "kmscube", "media", "DRM/KMS diagnostics for HDMI rendering"),
|
||
ServiceSpec("openclaw", "OpenClaw", "automation", "Automation gateway and web workspace"),
|
||
ServiceSpec(
|
||
"android_gateway",
|
||
"Android Gateway",
|
||
"network",
|
||
"Optional NetworkManager shared gateway for dual-NIC Android labs",
|
||
),
|
||
)
|
||
|
||
|
||
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:
|
||
with contextlib.suppress(ProcessLookupError):
|
||
proc.kill()
|
||
with contextlib.suppress(ProcessLookupError):
|
||
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_BIN, 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 {"install", "start", "stop", "restart", "status", "uninstall", "upgrade"}:
|
||
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")
|
||
if action == "status":
|
||
return await query_service(spec)
|
||
|
||
cmd = [BASH_BIN, 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 _env_flag(env: dict[str, str], key: str, default: bool = True) -> bool:
|
||
raw = str(env.get(key, "1" if default else "0")).strip().lower()
|
||
if raw in {"", "default"}:
|
||
return default
|
||
return raw not in {"0", "false", "no", "off", "disable", "disabled"}
|
||
|
||
|
||
def _env_int(env: dict[str, str], key: str, default: int) -> int:
|
||
raw = str(env.get(key, "")).strip()
|
||
if not raw:
|
||
return default
|
||
try:
|
||
return int(raw)
|
||
except ValueError:
|
||
return default
|
||
|
||
|
||
def _neko_http_ports(env: dict[str, str]) -> list[int]:
|
||
max_count = max(2, min(64, _env_int(env, "NEKO_MAX_INSTANCE_COUNT", 64)))
|
||
count = max(2, min(max_count, _env_int(env, "NEKO_INSTANCE_COUNT", 2)))
|
||
base = _env_int(env, "NEKO_HTTP_PORT", 9200)
|
||
return [_env_int(env, f"NEKO_HTTP_PORT_{idx}", base + idx - 1) for idx in range(1, count + 1)]
|
||
|
||
|
||
def _neko_instances(mdns_host: str, env: dict[str, str]) -> list[dict[str, Any]]:
|
||
instances: list[dict[str, Any]] = []
|
||
for idx, port in enumerate(_neko_http_ports(env), start=1):
|
||
url = f"http://{mdns_host}:{port}"
|
||
instances.append(
|
||
{
|
||
"id": idx,
|
||
"label": f"Neko {idx}",
|
||
"port": port,
|
||
"url": url,
|
||
"quick_link_key": "neko_browser" if idx == 1 else f"neko_browser_{idx}",
|
||
"alias_keys": ["neko_browser_1"] if idx == 1 else [],
|
||
}
|
||
)
|
||
return instances
|
||
|
||
|
||
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 _resolve_host_ipv4s(host: str) -> list[str]:
|
||
ips: list[str] = []
|
||
try:
|
||
for info in socket.getaddrinfo(host, None, family=socket.AF_INET):
|
||
ip = str(info[4][0]).strip()
|
||
if ip and ip not in ips:
|
||
ips.append(ip)
|
||
except OSError:
|
||
return []
|
||
return ips
|
||
|
||
|
||
def _deployment_file_check(check_id: str, label: str, path: Path, detail: str) -> dict[str, Any]:
|
||
return {
|
||
"id": check_id,
|
||
"label": label,
|
||
"ok": path.is_file(),
|
||
"kind": "file",
|
||
"target": str(path),
|
||
"detail": detail,
|
||
}
|
||
|
||
|
||
def _deployment_http_check(
|
||
check_id: str,
|
||
label: str,
|
||
url: str,
|
||
*,
|
||
expected_codes: tuple[int, ...] = (200,),
|
||
timeout: float = 2.5,
|
||
) -> dict[str, Any]:
|
||
probe = _probe_http_get(url, timeout=timeout)
|
||
code = int(probe.get("http_code") or 0)
|
||
ok = bool(probe.get("reachable")) and code in expected_codes
|
||
detail = f"HTTP {code}" if code else (probe.get("error") or "unreachable")
|
||
if probe.get("latency_ms") is not None:
|
||
detail = f"{detail} · {probe['latency_ms']} ms"
|
||
return {
|
||
"id": check_id,
|
||
"label": label,
|
||
"ok": ok,
|
||
"kind": "http",
|
||
"url": url,
|
||
"http_code": code,
|
||
"latency_ms": probe.get("latency_ms"),
|
||
"reachable": bool(probe.get("reachable")),
|
||
"detail": detail,
|
||
"error": str(probe.get("error") or ""),
|
||
}
|
||
|
||
|
||
def deployment_report() -> dict[str, Any]:
|
||
env = load_stack_env()
|
||
summary = stack_summary()
|
||
mdns_host = str(summary.get("mdns_host") or "live.local")
|
||
web_port = str(env.get("PORT", os.environ.get("PORT", "8001")) or "8001")
|
||
lan_ips = list(summary.get("ips") or [])
|
||
primary_lan_ip = lan_ips[0] if lan_ips else ""
|
||
live_edge_enabled = _env_flag(env, "ENABLE_LIVE_EDGE_PROXY", True)
|
||
mdns_enabled = _env_flag(env, "ENABLE_MDNS", True)
|
||
|
||
control_assets = BASE_DIR / "web-console" / "out" / "index.html"
|
||
checks: list[dict[str, Any]] = [
|
||
_deployment_file_check(
|
||
"stack_env",
|
||
"system-stack.env",
|
||
STACK_ENV,
|
||
"Live stack environment file",
|
||
),
|
||
_deployment_file_check(
|
||
"console_assets",
|
||
"web-console/out/index.html",
|
||
control_assets,
|
||
"Exported web control panel assets",
|
||
),
|
||
_deployment_http_check(
|
||
"api_loopback",
|
||
"FastAPI loopback",
|
||
f"http://127.0.0.1:{web_port}/health",
|
||
),
|
||
]
|
||
|
||
if primary_lan_ip:
|
||
checks.append(
|
||
_deployment_http_check(
|
||
"api_lan",
|
||
"FastAPI LAN bind",
|
||
f"http://{primary_lan_ip}:{web_port}/health",
|
||
)
|
||
)
|
||
|
||
if live_edge_enabled:
|
||
checks.append(
|
||
_deployment_http_check(
|
||
"edge_loopback",
|
||
"live-edge loopback",
|
||
"http://127.0.0.1/health",
|
||
)
|
||
)
|
||
if primary_lan_ip:
|
||
checks.append(
|
||
_deployment_http_check(
|
||
"edge_lan",
|
||
"live-edge LAN bind",
|
||
f"http://{primary_lan_ip}/health",
|
||
)
|
||
)
|
||
|
||
mdns_ips = _resolve_host_ipv4s(mdns_host) if mdns_enabled else []
|
||
checks.append(
|
||
{
|
||
"id": "mdns_resolution",
|
||
"label": "mDNS resolution",
|
||
"ok": bool(mdns_ips) if mdns_enabled else True,
|
||
"kind": "dns",
|
||
"host": mdns_host,
|
||
"ips": mdns_ips,
|
||
"detail": ", ".join(mdns_ips) if mdns_ips else ("disabled" if not mdns_enabled else "unresolved"),
|
||
}
|
||
)
|
||
|
||
if mdns_enabled and mdns_ips:
|
||
checks.append(
|
||
_deployment_http_check(
|
||
"mdns_api",
|
||
"live.local direct API",
|
||
f"http://{mdns_host}:{web_port}/health",
|
||
)
|
||
)
|
||
if live_edge_enabled:
|
||
checks.append(
|
||
_deployment_http_check(
|
||
"mdns_edge",
|
||
"live.local edge proxy",
|
||
f"http://{mdns_host}/health",
|
||
)
|
||
)
|
||
|
||
required_ids = {
|
||
"stack_env",
|
||
"console_assets",
|
||
"api_loopback",
|
||
}
|
||
if primary_lan_ip:
|
||
required_ids.add("api_lan")
|
||
if live_edge_enabled:
|
||
required_ids.add("edge_loopback")
|
||
if primary_lan_ip:
|
||
required_ids.add("edge_lan")
|
||
if mdns_enabled:
|
||
required_ids.add("mdns_resolution")
|
||
if mdns_ips:
|
||
required_ids.add("mdns_api")
|
||
if live_edge_enabled:
|
||
required_ids.add("mdns_edge")
|
||
|
||
pass_count = sum(1 for item in checks if item.get("ok"))
|
||
total_count = len(checks)
|
||
failing = [item for item in checks if item["id"] in required_ids and not item.get("ok")]
|
||
hints: list[str] = []
|
||
failed_ids = {item["id"] for item in failing}
|
||
if "api_loopback" in failed_ids:
|
||
hints.append("live-console service is unhealthy on 127.0.0.1; inspect systemd logs or scripts/launch.py.")
|
||
if "api_lan" in failed_ids:
|
||
hints.append("The control plane answers on loopback but not on the LAN IP; check HOST binding and local firewall rules.")
|
||
if "edge_loopback" in failed_ids or "edge_lan" in failed_ids:
|
||
hints.append("Port 80 reverse proxy is unhealthy; restart live-edge.service or rerun scripts/linux/reinstall_live_edge.sh.")
|
||
if "mdns_resolution" in failed_ids:
|
||
hints.append("live.local does not resolve locally; check avahi-daemon, libnss-mdns, and same-LAN mDNS visibility.")
|
||
if "mdns_api" in failed_ids and "mdns_resolution" not in failed_ids:
|
||
hints.append("live.local resolves but :PORT is not reachable through that hostname; verify mDNS points to the correct LAN IP.")
|
||
if "mdns_edge" in failed_ids and "mdns_resolution" not in failed_ids:
|
||
hints.append("live.local resolves but port 80 proxy is unhealthy; verify live-edge.service and the upstream PORT value.")
|
||
if "console_assets" in failed_ids:
|
||
hints.append("The exported web console is missing; run upgrade-live.sh or rebuild web-console before relying on live.local.")
|
||
|
||
urls = {
|
||
"loopback_api": f"http://127.0.0.1:{web_port}/health",
|
||
"mdns_control": f"http://{mdns_host}:{web_port}/",
|
||
"mdns_root": f"http://{mdns_host}/",
|
||
"lan_control": f"http://{primary_lan_ip}:{web_port}/" if primary_lan_ip else "",
|
||
"lan_root": f"http://{primary_lan_ip}/" if primary_lan_ip else "",
|
||
}
|
||
return {
|
||
"ready": not failing,
|
||
"pass_count": pass_count,
|
||
"total_count": total_count,
|
||
"required_total": len(required_ids),
|
||
"hostname": summary.get("hostname"),
|
||
"mdns_host": mdns_host,
|
||
"primary_lan_ip": primary_lan_ip,
|
||
"port": web_port,
|
||
"live_edge_enabled": live_edge_enabled,
|
||
"mdns_enabled": mdns_enabled,
|
||
"checks": checks,
|
||
"failing_checks": failing,
|
||
"hints": hints,
|
||
"urls": urls,
|
||
}
|
||
|
||
|
||
def _iface_speed_mbps(name: str) -> int | None:
|
||
path = Path("/sys/class/net") / name / "speed"
|
||
if not path.is_file():
|
||
return None
|
||
try:
|
||
value = int(path.read_text(encoding="utf-8", errors="replace").strip())
|
||
except (OSError, ValueError):
|
||
return None
|
||
return value if value > 0 else None
|
||
|
||
|
||
def _iface_operstate(name: str) -> str:
|
||
path = Path("/sys/class/net") / name / "operstate"
|
||
if not path.is_file():
|
||
return "unknown"
|
||
try:
|
||
return path.read_text(encoding="utf-8", errors="replace").strip() or "unknown"
|
||
except OSError:
|
||
return "unknown"
|
||
|
||
|
||
def network_iface_stats() -> dict[str, Any]:
|
||
"""Linux /proc/net/dev counters with per-refresh bandwidth rates."""
|
||
dev = Path("/proc/net/dev")
|
||
interfaces: list[dict[str, Any]] = []
|
||
if not dev.is_file():
|
||
return {"interfaces": interfaces, "total_rx_bytes": 0, "total_tx_bytes": 0, "total_rx_bps": 0.0, "total_tx_bps": 0.0}
|
||
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, "total_rx_bytes": 0, "total_tx_bytes": 0, "total_rx_bps": 0.0, "total_tx_bps": 0.0}
|
||
now = time.monotonic()
|
||
previous_ts = float(_NETWORK_RATE_CACHE.get("timestamp") or 0.0)
|
||
previous = _NETWORK_RATE_CACHE.get("interfaces") or {}
|
||
next_cache: dict[str, dict[str, int]] = {}
|
||
total_rx = 0
|
||
total_tx = 0
|
||
total_rx_bps = 0.0
|
||
total_tx_bps = 0.0
|
||
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:
|
||
rx_bytes = int(parts[0])
|
||
rx_packets = int(parts[1])
|
||
tx_bytes = int(parts[8])
|
||
tx_packets = int(parts[9])
|
||
total_rx += rx_bytes
|
||
total_tx += tx_bytes
|
||
prev = previous.get(name) if isinstance(previous, dict) else None
|
||
elapsed = max(0.0, now - previous_ts) if previous_ts else 0.0
|
||
rx_bps = 0.0
|
||
tx_bps = 0.0
|
||
if isinstance(prev, dict) and elapsed > 0:
|
||
prev_rx = int(prev.get("rx_bytes", rx_bytes))
|
||
prev_tx = int(prev.get("tx_bytes", tx_bytes))
|
||
rx_bps = max(0.0, (rx_bytes - prev_rx) / elapsed)
|
||
tx_bps = max(0.0, (tx_bytes - prev_tx) / elapsed)
|
||
total_rx_bps += rx_bps
|
||
total_tx_bps += tx_bps
|
||
next_cache[name] = {"rx_bytes": rx_bytes, "tx_bytes": tx_bytes}
|
||
interfaces.append(
|
||
{
|
||
"name": name,
|
||
"rx_bytes": rx_bytes,
|
||
"rx_packets": rx_packets,
|
||
"tx_bytes": tx_bytes,
|
||
"tx_packets": tx_packets,
|
||
"rx_bps": round(rx_bps, 2),
|
||
"tx_bps": round(tx_bps, 2),
|
||
"sample_interval_seconds": round(elapsed, 3),
|
||
"speed_mbps": _iface_speed_mbps(name),
|
||
"operstate": _iface_operstate(name),
|
||
}
|
||
)
|
||
except (ValueError, IndexError):
|
||
continue
|
||
_NETWORK_RATE_CACHE["timestamp"] = now
|
||
_NETWORK_RATE_CACHE["interfaces"] = next_cache
|
||
return {
|
||
"interfaces": interfaces,
|
||
"total_rx_bytes": total_rx,
|
||
"total_tx_bytes": total_tx,
|
||
"total_rx_bps": round(total_rx_bps, 2),
|
||
"total_tx_bps": round(total_tx_bps, 2),
|
||
"sample_interval_seconds": round(max(0.0, now - previous_ts) if previous_ts else 0.0, 3),
|
||
}
|
||
|
||
|
||
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 = _env_int(env, "SRS_HTTP_PORT", 8080)
|
||
srs_api_p = _env_int(env, "SRS_API_PORT", 1985)
|
||
neko_ports = _neko_http_ports(env)
|
||
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]:
|
||
instances = []
|
||
for idx, port in enumerate(neko_ports, start=1):
|
||
tcp = _probe_tcp_port("127.0.0.1", port)
|
||
http = _probe_http_get(f"http://127.0.0.1:{port}/", timeout=2.5)
|
||
instances.append({"id": idx, "port": port, "tcp": tcp, "http": http})
|
||
primary = instances[0] if instances else {"port": 0, "tcp": {}, "http": {}}
|
||
required_count = len(neko_ports)
|
||
return {
|
||
"port": primary["port"],
|
||
"tcp": primary["tcp"],
|
||
"http": primary["http"],
|
||
"instances": instances,
|
||
"running_count": sum(1 for item in instances if item["tcp"].get("ok")),
|
||
"required_count": required_count,
|
||
}
|
||
|
||
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"
|
||
control_url = f"http://{mdns_host}:{web_port}"
|
||
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")
|
||
srs_rtmp = env.get("SRS_RTMP_PORT", "1935")
|
||
android_panel = env.get("ANDROID_PANEL_PORT", "5000")
|
||
chrome_dbg = env.get("BROWSER_REMOTE_DEBUG_PORT", "9222")
|
||
neko_instances = _neko_instances(mdns_host, env)
|
||
redroid_port = env.get("REDROID_DEFAULT_PORT", "5555")
|
||
mitmproxy_port = env.get("MITMPROXY_WEB_PORT", "8086")
|
||
vscode_port = env.get("VSCODE_SERVER_PORT", "8440")
|
||
uptime_kuma_port = env.get("UPTIME_KUMA_PORT", "3001")
|
||
openclaw_port = env.get("OPENCLAW_GATEWAY_PORT", "18789")
|
||
pulseaudio_port = env.get("PULSEAUDIO_TCP_PORT", "4713")
|
||
ql: dict[str, str] = {
|
||
"control_plane": control_url,
|
||
"webtty": f"http://{mdns_host}:{webtty}",
|
||
"filebrowser": f"http://{mdns_host}:{fb}",
|
||
"netdata": f"http://{mdns_host}:{netd}",
|
||
"mitmproxy": f"http://{mdns_host}:{mitmproxy_port}",
|
||
"vscode_server": f"http://{mdns_host}:{vscode_port}",
|
||
"uptime_kuma": f"http://{mdns_host}:{uptime_kuma_port}",
|
||
"srs_http": f"http://{mdns_host}:{srs_http}/",
|
||
"srs_rtmp_publish": f"rtmp://{mdns_host}:{srs_rtmp}/live/livestream",
|
||
"srs_play_flv": f"http://{mdns_host}:{srs_http}/live/livestream.flv",
|
||
"srs_api": f"http://{mdns_host}:{srs_api}/api/v1/versions",
|
||
"ws_scrcpy": f"{control_url}/android",
|
||
"chromium_remote_debug": f"http://{mdns_host}:{chrome_dbg}",
|
||
"redroid_adb": f"{mdns_host}:{redroid_port}",
|
||
"pulseaudio_tcp": f"{mdns_host}:{pulseaudio_port}",
|
||
"openclaw": f"http://{mdns_host}:{openclaw_port}",
|
||
"services_console": f"{control_url}/?view=services",
|
||
}
|
||
for instance in neko_instances:
|
||
quick_link_key = str(instance["quick_link_key"])
|
||
ql[quick_link_key] = str(instance["url"])
|
||
for alias_key in instance.get("alias_keys") or []:
|
||
ql[str(alias_key)] = str(instance["url"])
|
||
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_instances": neko_instances,
|
||
"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": (
|
||
"Neko 为 multiuser 模式:「显示昵称」可任意填写(如 live);「密码」必须与 NEKO_USER_PASS / NEKO_ADMIN_PASS "
|
||
"完全一致(默认 12345678)。不要把 live/12345678 填进同一格。密码错误时会一直卡在连接中。"
|
||
" 默认保留 admin 管理会话,开发者工具开启,Tampermonkey 强制安装并持久化。"
|
||
" WebRTC 需 NEKO_WEBRTC_NAT1TO1 为板子局域网 IP,并放行 UDP 端口段。"
|
||
" x86 默认拉起 Chrome 镜像,ARM 默认拉起 Chromium 镜像。"
|
||
),
|
||
},
|
||
"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(),
|
||
}
|