's'
This commit is contained in:
533
src/control_plane.py
Normal file
533
src/control_plane.py
Normal file
@@ -0,0 +1,533 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
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", "Transparent proxy and rule 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("adb", "ADB", "android", "Android bridge and device inspection"),
|
||||
ServiceSpec("android_panel", "Android Web Panel", "android", "Browser-based Android control with web-scrcpy"),
|
||||
ServiceSpec("chromium", "Chromium", "browser", "Persistent browser profile launcher"),
|
||||
ServiceSpec(
|
||||
"android_gateway",
|
||||
"Android Gateway",
|
||||
"network",
|
||||
"Transparent gateway mode for Android boards",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
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 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 sys.platform == "win32":
|
||||
return _default_service_status(spec, "Linux-only stack integration")
|
||||
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 sys.platform == "win32":
|
||||
return _default_service_status(spec, "Linux-only stack integration")
|
||||
if not SERVICE_CTL.is_file():
|
||||
return _default_service_status(spec, "Service controller script is missing")
|
||||
|
||||
command = ["bash", str(SERVICE_CTL), service_id, action]
|
||||
if shutil.which("sudo"):
|
||||
command = ["sudo", "-n", "bash", str(SERVICE_CTL), service_id, action]
|
||||
code, output = await _run_command(command, 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 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"
|
||||
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(),
|
||||
}
|
||||
|
||||
|
||||
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 system_snapshot() -> dict:
|
||||
disk = shutil.disk_usage(BASE_DIR.anchor if sys.platform == "win32" else "/")
|
||||
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),
|
||||
},
|
||||
"netdata": {"available": False},
|
||||
}
|
||||
if sys.platform != "win32":
|
||||
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