Files
gitlab-instance-0a899031_sh/d2ypp2/src/control_plane.py
eric 78e1308063 ss
2026-05-19 06:47:07 +00:00

1089 lines
40 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(),
}