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