's'
This commit is contained in:
808
d2ypp2/src/control_plane.py
Normal file
808
d2ypp2/src/control_plane.py
Normal file
@@ -0,0 +1,808 @@
|
||||
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$")
|
||||
|
||||
|
||||
@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 _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 _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 cumulative counters plus current link bandwidth hints."""
|
||||
dev = Path("/proc/net/dev")
|
||||
interfaces: list[dict[str, Any]] = []
|
||||
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]),
|
||||
"speed_mbps": _iface_speed_mbps(name),
|
||||
"operstate": _iface_operstate(name),
|
||||
}
|
||||
)
|
||||
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_ports = [
|
||||
int(env.get("NEKO_HTTP_PORT_1") or env.get("NEKO_HTTP_PORT", "9200") or 9200),
|
||||
int(env.get("NEKO_HTTP_PORT_2", "9201") or 9201),
|
||||
int(env.get("NEKO_HTTP_PORT_3", "9202") or 9202),
|
||||
]
|
||||
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": {}}
|
||||
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": 3,
|
||||
}
|
||||
|
||||
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_port = env.get("NEKO_HTTP_PORT_1") or env.get("NEKO_HTTP_PORT", "9200")
|
||||
neko_port_2 = env.get("NEKO_HTTP_PORT_2", "9201")
|
||||
neko_port_3 = env.get("NEKO_HTTP_PORT_3", "9202")
|
||||
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}",
|
||||
"neko_browser": f"http://{mdns_host}:{neko_port}",
|
||||
"neko_browser_2": f"http://{mdns_host}:{neko_port_2}",
|
||||
"neko_browser_3": f"http://{mdns_host}:{neko_port_3}",
|
||||
"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",
|
||||
}
|
||||
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": (
|
||||
"Neko 为 multiuser 模式:「显示昵称」可任意填写(如 live);「密码」必须与 NEKO_USER_PASS / NEKO_ADMIN_PASS "
|
||||
"完全一致(默认 12345678)。不要把 live/12345678 填进同一格。密码错误时会一直卡在连接中。"
|
||||
" 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(),
|
||||
}
|
||||
Reference in New Issue
Block a user