ON_OFF Phase 0-3: service-controller (.13:8443, token-gated, containers+systemd-user, audit log) + portal toggle cards (live status, per-user who, failed token, RAM) — full E2E verified

This commit is contained in:
2026-10-08 19:10:45 +11:00
parent bb2c6e5d0d
commit 85827be3be
15 changed files with 549 additions and 7 deletions

View File

@@ -0,0 +1,15 @@
[Unit]
Description=Family Home Lab service-controller (on/off API)
After=network-online.target docker.service
Wants=network-online.target
[Service]
Type=simple
User=sam
WorkingDirectory=/home/sam/service-controller
ExecStart=/home/sam/service-controller/run.sh
Restart=on-failure
RestartSec=5
[Install]
WantedBy=default.target

View File

@@ -0,0 +1,2 @@
fastapi>=0.110
uvicorn>=0.29

View File

@@ -0,0 +1,11 @@
#!/usr/bin/env bash
# service-controller — host-side on/off API. Runs as a systemd USER unit.
set -a; [ -f /home/sam/.config/environment.d/10-secrets.conf ] && . /home/sam/.config/environment.d/10-secrets.conf; set +a
export SC_TOKEN="${SC_TOKEN:-}"
export SC_SERVICES="${SC_SERVICES:-/home/sam/service-controller/services.toml}"
export SC_AUDIT="${SC_AUDIT:-/home/sam/service-controller/audit.log}"
# Bind to the docker bridge gateway so the portal container can reach us via
# host.docker.internal. NOT reachable from the LAN; token still required.
export SC_HOST="${SC_HOST:-192.168.144.1}"
exec "${VENV:-/home/sam/service-controller/.venv}/bin/python" \
/home/sam/service-controller/service_controller.py

View File

@@ -0,0 +1,167 @@
"""service-controller — host-side on/off control for the Family Console.
Only allowlisted services can be started/stopped. Requires a token
(X-Controller-Token). SC_HOST default 127.0.0.1; the systemd unit overrides it
with the docker bridge gateway so the portal container can reach us via
host.docker.internal while staying unreachable from the LAN. Covers Docker containers (sam is in the docker
group) and systemd USER services. No generic passthrough — unknown id => 404.
API (see ON_OFF.md §4.2):
GET /health
GET /services
GET /services/{id}
POST /services/{id}/start
POST /services/{id}/stop
Runs as a systemd USER unit: family-service-controller.service
"""
from __future__ import annotations
import json
import os
import subprocess
import threading
import time
import tomllib
from pathlib import Path
from typing import Annotated, Any
import uvicorn
from fastapi import FastAPI, Header, HTTPException, Request
from fastapi.responses import JSONResponse
HERE = Path(__file__).resolve().parent
SERVICES_FILE = Path(os.getenv("SC_SERVICES", HERE / "services.toml"))
AUDIT_FILE = Path(os.getenv("SC_AUDIT", HERE / "audit.log"))
AUDIT_MAX_LINES = int(os.getenv("SC_AUDIT_MAX", "500"))
HOST = os.getenv("SC_HOST", "127.0.0.1")
PORT = int(os.getenv("SC_PORT", "8443")) # in NixOS firewall allowlist; 8099 was not
TOKEN = os.getenv("SC_TOKEN", "") # set by the unit's EnvironmentFile (10-secrets.conf)
app = FastAPI(title="Family service-controller", docs_url=None)
_audit_lock = threading.Lock()
# --------------------------------------------------------------------------- #
# allowlist
# --------------------------------------------------------------------------- #
def load_services() -> list[dict[str, Any]]:
with open(SERVICES_FILE, "rb") as fh:
data = tomllib.load(fh)
return [dict(s) for s in data.get("services", [])]
SERVICES: list[dict[str, Any]] = load_services()
BY_ID = {s["id"]: s for s in SERVICES}
def _auth(x_token: Annotated[str | None, Header(alias="X-Controller-Token")] = None) -> None:
if not TOKEN or not x_token or x_token != TOKEN:
raise HTTPException(status_code=401, detail="unauthorized")
def audit(what: str) -> None:
"""Append one bounded audit line (oldest dropped at AUDIT_MAX_LINES)."""
line = f"{time.strftime('%Y-%m-%dT%H:%M:%S%z')} {what}"
with _audit_lock:
try:
lines = AUDIT_FILE.read_text().splitlines() if AUDIT_FILE.exists() else []
except Exception:
lines = []
lines.append(line)
if len(lines) > AUDIT_MAX_LINES:
lines = lines[-AUDIT_MAX_LINES:]
AUDIT_FILE.write_text("\n".join(lines) + "\n")
# --------------------------------------------------------------------------- #
# probes / actions
# --------------------------------------------------------------------------- #
def _container_state(target: str) -> str:
r = subprocess.run(["docker", "inspect", "-f", "{{.State.Status}}", target],
capture_output=True, text=True)
out = r.stdout.strip()
return out or ("unknown" if r.returncode != 0 else "unknown")
def _systemd_user_state(target: str) -> str:
r = subprocess.run(["systemctl", "--user", "is-active", target],
capture_output=True, text=True)
return (r.stdout.strip() or "inactive").lower()
def _do(kind: str, action: str, target: str) -> str:
"""Run start/stop. Returns the state after a short settle."""
if kind == "container":
subprocess.run(["docker", action, target], capture_output=True, text=True)
time.sleep(1.5)
return _container_state(target)
subprocess.run(["systemctl", "--user", action, target], capture_output=True, text=True)
time.sleep(1.5)
return _systemd_user_state(target)
def _describe(s: dict[str, Any]) -> dict[str, Any]:
state = "unknown"
if s["kind"] == "container":
state = _container_state(s["target"])
else:
state = _systemd_user_state(s["target"])
return {
"id": s["id"],
"kind": s["kind"],
"target": s["target"],
"group": s.get("group", ""),
"label": s.get("label", s["id"]),
"ram_mb": s.get("ram_mb"),
"default_state": s.get("default_state", "stopped"),
"who": s.get("who", []),
"state": "running" if state in ("running", "active") else
("stopped" if state in ("stopped", "inactive", "exited") else "unknown"),
"error": None,
}
# --------------------------------------------------------------------------- #
# routes
# --------------------------------------------------------------------------- #
@app.get("/health")
async def health(request: Request) -> dict:
return {"ok": True, "services": len(SERVICES)}
@app.get("/services")
async def services_list(x_token: Annotated[str | None, Header(alias="X-Controller-Token")] = None) -> dict:
_auth(x_token)
return {"ok": True, "services": [_describe(s) for s in SERVICES]}
@app.get("/services/{sid}")
async def service_get(sid: str, x_token: Annotated[str | None, Header(alias="X-Controller-Token")] = None) -> dict:
_auth(x_token)
s = BY_ID.get(sid)
if s is None:
raise HTTPException(status_code=404, detail="unknown service")
return {"ok": True, "service": _describe(s)}
@app.post("/services/{sid}/{action}")
async def service_action(sid: str, action: str,
x_token: Annotated[str | None, Header(alias="X-Controller-Token")] = None,
who: Annotated[str | None, Header()] = None) -> dict:
"""Start or stop one service. `who` is the acting user (admin gate is done
portal-side; the controller logs it and enforces the allowlist)."""
_auth(x_token)
if action not in ("start", "stop"):
raise HTTPException(status_code=400, detail="action must be start|stop")
s = BY_ID.get(sid)
if s is None:
raise HTTPException(status_code=404, detail="unknown service")
actor = (who or "unknown-user").strip()[:40]
state = _do(s["kind"], action, s["target"])
audit(f"{actor} {action} {sid} -> {state}")
return {"ok": True, "id": sid, "state": state}
if __name__ == "__main__":
uvicorn.run(app, host=HOST, port=PORT)

View File

@@ -0,0 +1,85 @@
# service-controller allowlist — ON_OFF.md §3/§4.
#
# Only services listed here can be started/stopped. Everything else 404s.
# Safety (non-negotiable, §6): never list console deps, house-critical audio,
# voice/whisper/HA path, or n8n/prefect-server (shared infra, always on).
#
# kind:
# container -> docker start/stop <target> (sam is in the docker group)
# systemd-user-> systemctl --user start/stop <target>
#
# group "media": family-facing editors (shared by everyone).
# group "pipeline": photo ingestion (prefect worker + dashboard) — admin only.
# group "admin": heavy infra someone may pause when idle — admin only.
[[services]]
id = "gimp"
kind = "container"
target = "family-home-lab-gimp-1"
group = "media"
label = "GIMP"
ram_mb = 233
default_state = "stopped"
who = ["sam", "finn", "harry", "jo"]
[[services]]
id = "video-editor"
kind = "container"
target = "family-home-lab-video-editor-1"
group = "media"
label = "Video Editor (KdenLive)"
ram_mb = 237
default_state = "stopped"
who = ["sam", "finn", "harry", "jo"]
[[services]]
id = "audio-editor"
kind = "container"
target = "family-home-lab-audio-editor-1"
group = "media"
label = "Audio Editor (Audacity)"
ram_mb = 201
default_state = "stopped"
who = ["sam", "finn", "harry", "jo"]
[[services]]
id = "lmms"
kind = "container"
target = "lmms"
group = "media"
label = "LMMS Music Studio"
ram_mb = 798 # incl. webtop desktop; Xvfb fixed to 2560x1440 (2026-10-07)
default_state = "stopped"
who = ["sam", "finn", "harry", "jo"]
[[services]]
id = "paperclip"
kind = "systemd-user"
target = "paperclipai"
group = "admin"
label = "Paperclip"
ram_mb = 495
default_state = "stopped"
who = ["sam"]
# Photo ingestion pipeline — admin controlled. Do NOT stop while ingesting
# Google Photos (user actively working on it, 2026-10-07).
[[services]]
id = "prefect-worker"
kind = "systemd-user"
target = "prefect-worker"
group = "pipeline"
label = "Prefect worker (photo pool)"
ram_mb = 129
default_state = "running"
who = ["sam"]
[[services]]
id = "photo-dashboard"
kind = "systemd-user"
target = "photo-dashboard"
group = "pipeline"
label = "Photo pipeline dashboard"
ram_mb = 112
default_state = "running"
who = ["sam"]