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:
@@ -61,3 +61,51 @@ for b in sam jo harry finn shared-media; do $G bucket allow --read --write "$b"
|
||||
|
||||
> Symmetry: ensure S3_ACCESS_KEY/S3_SECRET_KEY in `.env` match what `key import`
|
||||
> registers, else portal uploads will 403.
|
||||
|
||||
## ON/OFF service-controller (ON_OFF.md)
|
||||
|
||||
Host-side on/off API on `.13`, port **8443** (chosen because it is already in the
|
||||
NixOS `networking.firewall.allowedTCPPorts` allowlist — 8091/8092 were taken by
|
||||
langgraph-service / photo-dashboard, and 8099 was blocked by the firewall).
|
||||
|
||||
Files live in `deploy/service-controller/` and copy to `/home/sam/service-controller/` on .13.
|
||||
|
||||
Install / update:
|
||||
|
||||
```bash
|
||||
# on .27 (this repo), then:
|
||||
scp -q deploy/service-controller/service_controller.py \
|
||||
deploy/service-controller/services.toml \
|
||||
deploy/service-controller/run.sh \
|
||||
deploy/service-controller/family-service-controller.service \
|
||||
sam@192.168.20.13:/home/sam/service-controller/
|
||||
|
||||
# on .13:
|
||||
cd /home/sam/service-controller
|
||||
python3 -m venv .venv # first time only
|
||||
./.venv/bin/pip install -q -r requirements.txt # first time only
|
||||
cp family-service-controller.service ~/.config/systemd/user/
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now family-service-controller.service
|
||||
```
|
||||
|
||||
Secrets:
|
||||
- `SC_TOKEN` lives in `/home/sam/.config/environment.d/10-secrets.conf` (git-ignored; run.sh sources it).
|
||||
- The portal gets `SC_TOKEN` from its `.env` on .13 (injected at deploy; never committed).
|
||||
|
||||
Firewall note: the controller binds `0.0.0.0:8443` and is protected by the token.
|
||||
Binding only to the docker bridge gateway was dropped because this NixOS box drops
|
||||
INPUT from containers to non-allowlisted host listeners; `0.0.0.0` on the already-
|
||||
allowed port 8443 is the workable, token-gated compromise.
|
||||
|
||||
Portal wiring: `docker-compose.yml` sets `SC_URL=http://host.docker.internal:8443`
|
||||
and `SC_TOKEN=${SC_TOKEN:-}` on the portal service, with
|
||||
`extra_hosts: ["host.docker.internal:192.168.144.1"]` (host-gateway resolves to the
|
||||
DOWN docker0 bridge on this box; pinning to the fhl-net gateway works).
|
||||
|
||||
Verify:
|
||||
```bash
|
||||
curl -s http://127.0.0.1:8443/health # {"ok":true,...}
|
||||
curl -s -H "X-Controller-Token: $SC_TOKEN" http://127.0.0.1:8443/services
|
||||
curl -s -X POST -H "X-Controller-Token: $SC_TOKEN" http://127.0.0.1:8443/services/lmms/stop
|
||||
```
|
||||
|
||||
15
deploy/service-controller/family-service-controller.service
Normal file
15
deploy/service-controller/family-service-controller.service
Normal 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
|
||||
2
deploy/service-controller/requirements.txt
Normal file
2
deploy/service-controller/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
fastapi>=0.110
|
||||
uvicorn>=0.29
|
||||
11
deploy/service-controller/run.sh
Executable file
11
deploy/service-controller/run.sh
Executable 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
|
||||
167
deploy/service-controller/service_controller.py
Normal file
167
deploy/service-controller/service_controller.py
Normal 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)
|
||||
85
deploy/service-controller/services.toml
Normal file
85
deploy/service-controller/services.toml
Normal 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"]
|
||||
Reference in New Issue
Block a user