From 10e1bc8ff4eba503a7d321b0e09edd8f04cffbf8 Mon Sep 17 00:00:00 2001 From: CyberMind Date: Sat, 4 Jul 2026 10:12:08 +0200 Subject: [PATCH] PeerTube WebUI: admin reset-password + version check/upgrade (#800) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(spec): PeerTube WebUI admin ops — reset-password + version check/upgrade (ref #798) * docs(plan): PeerTube WebUI admin ops implementation plan (ref #798) * feat(peertube): API ops spool plumbing + require_admin (ref #798) * feat(peertube): spool→root ops mechanism (process-ops + path/service) (ref #798) * feat(peertube): reset admin password button (lockout-safe, spool→root) (ref #798) * fix(peertube): admin-secret chown to real secubox user + WebUI error field (ref #798) * feat(peertube): version check (installed vs latest GitHub release) (ref #798) * feat(peertube): one-click upgrade with pre-backup + rollback (spool→root) (ref #798) * fix(peertube): require_admin store lookup + op-name align + result group-readable (whole-branch review, ref #798) --------- Co-authored-by: CyberMind-FR --- .../2026-07-04-peertube-webui-admin-ops.md | 777 ++++++++++++++++++ ...6-07-04-peertube-webui-admin-ops-design.md | 119 +++ packages/secubox-peertube/api/main.py | 148 +++- .../secubox-peertube/debian/peertube-ops.path | 14 + .../debian/peertube-ops.service | 11 + packages/secubox-peertube/debian/postinst | 7 + packages/secubox-peertube/debian/rules | 5 + .../debian/secubox-peertube.tmpfiles | 3 + packages/secubox-peertube/sbin/peertubectl | 113 +++ .../secubox-peertube/tests/test_ops_api.py | 77 ++ .../tests/test_process_ops.sh | 19 + .../secubox-peertube/tests/test_version.py | 37 + .../secubox-peertube/www/peertube/index.html | 65 +- 13 files changed, 1393 insertions(+), 2 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-04-peertube-webui-admin-ops.md create mode 100644 docs/superpowers/specs/2026-07-04-peertube-webui-admin-ops-design.md create mode 100644 packages/secubox-peertube/debian/peertube-ops.path create mode 100644 packages/secubox-peertube/debian/peertube-ops.service create mode 100644 packages/secubox-peertube/debian/secubox-peertube.tmpfiles create mode 100644 packages/secubox-peertube/tests/test_ops_api.py create mode 100755 packages/secubox-peertube/tests/test_process_ops.sh create mode 100644 packages/secubox-peertube/tests/test_version.py diff --git a/docs/superpowers/plans/2026-07-04-peertube-webui-admin-ops.md b/docs/superpowers/plans/2026-07-04-peertube-webui-admin-ops.md new file mode 100644 index 00000000..0a08504a --- /dev/null +++ b/docs/superpowers/plans/2026-07-04-peertube-webui-admin-ops.md @@ -0,0 +1,777 @@ +# PeerTube WebUI admin ops Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add three admin actions to the PeerTube WebUI — reset admin password, version check, and version upgrade — all executed in the PeerTube LXC via an unprivileged-API → spool → root-unit mechanism. + +**Architecture:** The `secubox-peertube` API is unprivileged (`User=secubox`, `NoNewPrivileges`), so privileged in-LXC ops go through a spool: the API writes `/run/secubox/peertube/ops/.request.json`; a root `peertube-ops.path` watches the dir and runs `peertubectl process-ops` (root), which dispatches to `reset-admin-password`/`upgrade` (lxc-attach) and writes `.result.json`; the API polls the result. Version check is unprivileged (PeerTube HTTP API + GitHub releases). + +**Tech Stack:** FastAPI (Python 3.11), bash (`peertubectl`), systemd `.path`/`.service`, HTML/JS (vanilla), pytest, shellcheck. + +## Global Constraints + +- Licence header `LicenseRef-CMSD-1.0` on every new file (Python: SPDX 4-line block; bash: `#!/usr/bin/env bash` + SPDX). +- The API MUST stay unprivileged — no `sudo`, no `lxc-attach` from `api/main.py`; all in-LXC ops go through the spool → root unit. `NoNewPrivileges=true` on `secubox-peertube.service` is preserved. +- Spool dir: `/run/secubox/peertube/ops/` — `0750 secubox:secubox`. Request file `.request.json` chmod `0600`; result file `.result.json` chmod `0640 root:secubox`. +- Op ids are hex tokens (`secrets.token_hex(8)`), never attacker-influenced paths; the API only ever reads/writes `OPS_DIR/.{request,result}.json` with `id` validated `^[0-9a-f]{8,32}$`. +- After a password reset, `peertubectl` MUST rewrite `/etc/secubox/secrets/peertube-admin` (0600) — else `get_admin_token()` (api/main.py:262) breaks for every dashboard write-op. +- Upgrade MUST `pg_dump` before touching anything; abort if the backup fails; on any post-download failure keep/restore the old `peertube-latest` symlink so the running version is unchanged. +- Mutating endpoints (`POST /admin/reset-password`, `POST /upgrade`) require `Depends(require_admin)` (admin role); read endpoints use `Depends(require_jwt)`. +- Do NOT touch `/run/secubox` (1777) or `/etc/secubox` (0755) parent perms — only create `/run/secubox/peertube/ops`. +- PeerTube facts (LXC `peertube` at `/data/lxc`, path `/var/www/peertube`, release `peertube-latest`, OS user `peertube`, service `peertube.service`, `NODE_ENV=production`, `NODE_CONFIG_DIR=/var/www/peertube/config`, current `v8.2.0`, DB `peertube_prod`). +- Tests: API `cd packages/secubox-peertube && PYTHONPATH=/common /bin/python3 -m pytest tests/ -q` (venv `/home/reepost/CyberMindStudio/secubox-deb/secubox-deb/.venv/bin/python3`). Bash: `shellcheck sbin/peertubectl` + `bash sbin/peertubectl --dry-run`. + +--- + +## File Structure + +- `packages/secubox-peertube/api/main.py` — **modify**: add `OPS_DIR` const, `require_admin` dep, `_spool_op()` + `_read_op_result()` helpers, endpoints `POST /admin/reset-password`, `GET /admin/op/{id}`, `GET /version`, `POST /upgrade`. +- `packages/secubox-peertube/sbin/peertubectl` — **modify**: add verbs `process-ops`, `reset-admin-password`, `upgrade` (+ `--dry-run`), wire into `main()` dispatch + `cmd_help`. +- `packages/secubox-peertube/debian/peertube-ops.path` + `.service` — **new**: root spool watcher. +- `packages/secubox-peertube/debian/rules` — **modify**: install the two new units. +- `packages/secubox-peertube/debian/postinst` — **modify**: create `/run/secubox/peertube/ops` + backups dir + enable `peertube-ops.path`. +- `packages/secubox-peertube/www/peertube/index.html` — **modify**: reset-password button (Users) + version/upgrade card (Maintenance) + polling helper. +- `packages/secubox-peertube/tests/test_ops_api.py`, `tests/test_version.py` — **new**: API tests. + +--- + +## Task 1: API spool plumbing — `require_admin`, `_spool_op`, `GET /admin/op/{id}` + +**Files:** +- Modify: `packages/secubox-peertube/api/main.py` +- Test: `packages/secubox-peertube/tests/test_ops_api.py` + +**Interfaces:** +- Consumes: existing `require_jwt` (imported from `secubox_core.auth`), `get_config`. +- Produces: `OPS_DIR: Path`; `require_admin(user=Depends(require_jwt))` FastAPI dep; `_spool_op(op: str, **args) -> str` (returns op id); `_read_op_result(op_id: str) -> dict` (returns `{"status": "pending"|...}`); `GET /admin/op/{id}` route. + +- [ ] **Step 1: Write the failing test** + +Create `packages/secubox-peertube/tests/test_ops_api.py`: + +```python +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# Copyright (c) 2026 CyberMind — Gérald Kerma +# Source-Disclosed License — All rights reserved except as expressly granted. +# See LICENCE-CMSD-1.0.md for terms. +"""Spool plumbing for PeerTube admin ops (#798).""" +import json +from pathlib import Path +from api import main as m + + +def test_spool_op_writes_request(tmp_path, monkeypatch): + monkeypatch.setattr(m, "OPS_DIR", tmp_path) + op_id = m._spool_op("reset-password", password="s3cr3t") + assert len(op_id) >= 8 and all(c in "0123456789abcdef" for c in op_id) + req = json.loads((tmp_path / f"{op_id}.request.json").read_text()) + assert req["op"] == "reset-password" and req["id"] == op_id and req["password"] == "s3cr3t" + assert (tmp_path / f"{op_id}.request.json").stat().st_mode & 0o777 == 0o600 + + +def test_read_op_result_pending_then_done(tmp_path, monkeypatch): + monkeypatch.setattr(m, "OPS_DIR", tmp_path) + assert m._read_op_result("abc12345")["status"] == "pending" + (tmp_path / "abc12345.result.json").write_text(json.dumps({"status": "done", "detail": "ok"})) + r = m._read_op_result("abc12345") + assert r["status"] == "done" and r["detail"] == "ok" + + +def test_read_op_result_rejects_bad_id(tmp_path, monkeypatch): + monkeypatch.setattr(m, "OPS_DIR", tmp_path) + assert m._read_op_result("../etc/passwd")["status"] == "error" +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd packages/secubox-peertube && PYTHONPATH=/home/reepost/CyberMindStudio/secubox-deb-worktrees/798-peertube-webui-admin-reset-password-vers/common /home/reepost/CyberMindStudio/secubox-deb/secubox-deb/.venv/bin/python3 -m pytest tests/test_ops_api.py -v` +Expected: FAIL — `AttributeError: module 'api.main' has no attribute 'OPS_DIR'` / `_spool_op`. + +- [ ] **Step 3: Add the plumbing to `api/main.py`** + +Near the top of `api/main.py`, after the existing imports (it already imports `json`, `subprocess`, `os`, `Path`, `Depends`, `require_jwt`), add: + +```python +import re +import secrets + +OPS_DIR = Path("/run/secubox/peertube/ops") +_OP_ID_RE = re.compile(r"^[0-9a-f]{8,32}$") + + +async def require_admin(user=Depends(require_jwt)): + """Gate destructive ops to admin-role JWTs. require_jwt already validated the + token; here we additionally require role == admin (the module otherwise + accepts any valid SecuBox JWT).""" + role = (user or {}).get("role") if isinstance(user, dict) else None + if role != "admin": + raise HTTPException(status_code=403, detail="admin role required") + return user + + +def _spool_op(op: str, **args) -> str: + """Write an intent file the root peertube-ops.path unit will pick up. Returns + the op id; the caller polls GET /admin/op/{id}. Unprivileged (no lxc-attach).""" + op_id = secrets.token_hex(8) + OPS_DIR.mkdir(parents=True, exist_ok=True) + req = OPS_DIR / f"{op_id}.request.json" + req.write_text(json.dumps({"op": op, "id": op_id, **args})) + os.chmod(req, 0o600) + return op_id + + +def _read_op_result(op_id: str) -> dict: + """Read .result.json; {status: pending} until the root unit writes it.""" + if not _OP_ID_RE.match(op_id or ""): + return {"status": "error", "detail": "bad op id"} + res = OPS_DIR / f"{op_id}.result.json" + if not res.exists(): + return {"status": "pending"} + try: + return json.loads(res.read_text()) + except Exception as e: # pragma: no cover + return {"status": "error", "detail": f"unreadable result: {e}"} +``` + +Then add the route (near the other `@router` routes): + +```python +@router.get("/admin/op/{op_id}") +async def get_op_result(op_id: str, user=Depends(require_jwt)): + """Poll the result of a spooled admin op (reset-password / upgrade).""" + return _read_op_result(op_id) +``` + +Ensure `HTTPException` is imported (it is used elsewhere in the file; if not, add `from fastapi import HTTPException`). + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd packages/secubox-peertube && PYTHONPATH=/home/reepost/CyberMindStudio/secubox-deb-worktrees/798-peertube-webui-admin-reset-password-vers/common /home/reepost/CyberMindStudio/secubox-deb/secubox-deb/.venv/bin/python3 -m pytest tests/test_ops_api.py -v` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add packages/secubox-peertube/api/main.py packages/secubox-peertube/tests/test_ops_api.py +git commit -m "feat(peertube): API ops spool plumbing + require_admin (ref #798)" +``` + +--- + +## Task 2: `peertubectl process-ops` dispatcher + root spool units + packaging + +**Files:** +- Modify: `packages/secubox-peertube/sbin/peertubectl` +- Create: `packages/secubox-peertube/debian/peertube-ops.path`, `packages/secubox-peertube/debian/peertube-ops.service` +- Modify: `packages/secubox-peertube/debian/rules`, `packages/secubox-peertube/debian/postinst` + +**Interfaces:** +- Consumes: existing `peertubectl` helpers (`log`/`err`/`lxc-attach` conventions), `OPS_DIR` contract from Task 1 (`/run/secubox/peertube/ops/.request.json` → `.result.json`). +- Produces: `peertubectl process-ops` (iterates request files, dispatches by `.op`, writes result, removes request); a `_write_result ` helper + `OPS_DIR` var; the verbs `reset-admin-password`/`upgrade` are added in Tasks 3/5 (Task 2 provides a `ping` op so the mechanism is testable now). + +- [ ] **Step 1: Write the failing test (dispatcher via a stub op)** + +Create `packages/secubox-peertube/tests/test_process_ops.sh`: + +```bash +#!/usr/bin/env bash +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# process-ops dispatches a request file to a result file (#798). +set -euo pipefail +here="$(cd "$(dirname "$0")/.." && pwd)" +tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT +export SECUBOX_PEERTUBE_OPS_DIR="$tmp/ops"; mkdir -p "$SECUBOX_PEERTUBE_OPS_DIR" +printf '{"op":"ping","id":"deadbeef"}' > "$SECUBOX_PEERTUBE_OPS_DIR/deadbeef.request.json" +bash "$here/sbin/peertubectl" process-ops +[ -f "$SECUBOX_PEERTUBE_OPS_DIR/deadbeef.result.json" ] || { echo "no result written"; exit 1; } +grep -q '"status": *"done"' "$SECUBOX_PEERTUBE_OPS_DIR/deadbeef.result.json" || { echo "ping not done"; exit 1; } +[ -f "$SECUBOX_PEERTUBE_OPS_DIR/deadbeef.request.json" ] && { echo "request not removed"; exit 1; } +echo "PASS process-ops ping" +``` +`chmod +x packages/secubox-peertube/tests/test_process_ops.sh`. + +- [ ] **Step 2: Run to verify it fails** + +Run: `bash packages/secubox-peertube/tests/test_process_ops.sh` +Expected: FAIL (unknown verb `process-ops` / no result written). + +- [ ] **Step 3: Add `process-ops` + helpers to `peertubectl`** + +In `sbin/peertubectl`, after the config vars block (after `PUBLIC_HOSTNAME=…`), add: + +```bash +OPS_DIR="${SECUBOX_PEERTUBE_OPS_DIR:-/run/secubox/peertube/ops}" +ADMIN_SECRET="${SECUBOX_PEERTUBE_ADMIN_SECRET:-/etc/secubox/secrets/peertube-admin}" + +# _write_result — atomic result file for the API to poll. +_write_result() { + local id="$1" body="$2" + printf '%s' "$body" > "$OPS_DIR/$id.result.json.tmp" + chmod 0640 "$OPS_DIR/$id.result.json.tmp" 2>/dev/null || true + mv -f "$OPS_DIR/$id.result.json.tmp" "$OPS_DIR/$id.result.json" +} + +# _jq — read a field from a request file (jq is available on the host). +_jq() { jq -r "$2" < "$1" 2>/dev/null; } +``` + +Add the dispatcher verb (before `cmd_help`): + +```bash +cmd_process_ops() { + # Root-only: the peertube-ops.path unit runs this when a request appears. + [ -d "$OPS_DIR" ] || return 0 + local f id op + for f in "$OPS_DIR"/*.request.json; do + [ -e "$f" ] || continue + id="$(basename "$f" .request.json)" + op="$(_jq "$f" '.op')" + case "$op" in + ping) _write_result "$id" '{"status":"done","detail":"pong"}' ;; + reset-admin-password) cmd_reset_admin_password "$f" "$id" ;; + upgrade) cmd_upgrade "$f" "$id" ;; + *) _write_result "$id" "{\"status\":\"error\",\"detail\":\"unknown op: $op\"}" ;; + esac + rm -f "$f" + done +} +``` + +Wire into `main()` dispatch — change the `case` to include `process-ops`: + +```bash + install|status|start|stop|restart|logs|reload) "cmd_${noun}" "$@" ;; + set-youtube-cookies) cmd_set_youtube_cookies "$@" ;; + process-ops) cmd_process_ops "$@" ;; + reset-admin-password) cmd_reset_admin_password "$@" ;; + upgrade) cmd_upgrade "$@" ;; +``` + +(Note: `cmd_reset_admin_password` and `cmd_upgrade` are added in Tasks 3 and 5. To keep Task 2's `peertubectl` valid, add temporary stubs now that Tasks 3/5 replace:) + +```bash +cmd_reset_admin_password() { local id="${2:-}"; [ -n "$id" ] && _write_result "$id" '{"status":"error","detail":"reset not implemented yet"}'; } +cmd_upgrade() { local id="${2:-}"; [ -n "$id" ] && _write_result "$id" '{"status":"error","detail":"upgrade not implemented yet"}'; } +``` + +Add to `cmd_help` usage: ` process-ops (root) drain the /run ops spool` . + +- [ ] **Step 4: Run test to verify it passes + shellcheck** + +Run: `bash packages/secubox-peertube/tests/test_process_ops.sh && shellcheck packages/secubox-peertube/sbin/peertubectl` +Expected: `PASS process-ops ping`; shellcheck clean (or only pre-existing warnings). + +- [ ] **Step 5: Create the root spool units** + +`packages/secubox-peertube/debian/peertube-ops.path`: + +```ini +[Unit] +Description=Watch for spooled PeerTube admin ops (reset-password / upgrade) +Documentation=https://github.com/CyberMind-FR/secubox-deb/issues/798 + +[Path] +# The unprivileged dashboard drops .request.json here; this root service +# runs peertubectl process-ops to execute them inside the LXC — no sudo, so the +# dashboard keeps NoNewPrivileges (CSPN privilege separation, mirrors #407). +DirectoryNotEmpty=/run/secubox/peertube/ops +Unit=peertube-ops.service + +[Install] +WantedBy=paths.target +``` + +`packages/secubox-peertube/debian/peertube-ops.service`: + +```ini +[Unit] +Description=Drain the PeerTube admin-ops spool (root, #798) +Documentation=https://github.com/CyberMind-FR/secubox-deb/issues/798 + +[Service] +Type=oneshot +# Root (no User=) so peertubectl can lxc-attach into the container. Upgrade can +# take several minutes (download + npm install + DB migration). +TimeoutStartSec=1800 +ExecStart=/usr/sbin/peertubectl process-ops +``` + +Both files start with `# SPDX-License-Identifier: LicenseRef-CMSD-1.0` as the first line? systemd ignores `#` comments — add it as the first line of each. + +- [ ] **Step 6: Install units in `debian/rules` + create dirs in `postinst`** + +In `debian/rules`, after the cookie-install unit installs (lines ~39-40), add: + +```make + install -m 644 debian/peertube-ops.path debian/secubox-peertube/usr/lib/systemd/system/ + install -m 644 debian/peertube-ops.service debian/secubox-peertube/usr/lib/systemd/system/ +``` + +In `debian/postinst`, in the `configure` case (alongside existing dir creation), add: + +```bash + install -d -o secubox -g secubox -m 0750 /run/secubox/peertube/ops 2>/dev/null || true + install -d -o peertube -g peertube -m 0750 /var/lib/secubox/peertube/backups 2>/dev/null || true + deb-systemd-helper enable peertube-ops.path >/dev/null 2>&1 || true + deb-systemd-invoke start peertube-ops.path >/dev/null 2>&1 || true +``` +(`/run` is tmpfs — also add a `tmpfiles.d` line so the dir survives reboot: create `packages/secubox-peertube/debian/secubox-peertube.tmpfiles` with `d /run/secubox/peertube/ops 0750 secubox secubox -` and install it via `dh_installtmpfiles`/`install -m644 … /usr/lib/tmpfiles.d/secubox-peertube.conf` in rules.) + +- [ ] **Step 7: Commit** + +```bash +git add packages/secubox-peertube/sbin/peertubectl packages/secubox-peertube/debian/peertube-ops.path packages/secubox-peertube/debian/peertube-ops.service packages/secubox-peertube/debian/rules packages/secubox-peertube/debian/postinst packages/secubox-peertube/debian/secubox-peertube.tmpfiles packages/secubox-peertube/tests/test_process_ops.sh +git commit -m "feat(peertube): spool→root ops mechanism (process-ops + path/service) (ref #798)" +``` + +--- + +## Task 3: Reset admin password (verb + API + WebUI) + +**Files:** +- Modify: `packages/secubox-peertube/sbin/peertubectl` (replace the `cmd_reset_admin_password` stub) +- Modify: `packages/secubox-peertube/api/main.py` (endpoint) +- Modify: `packages/secubox-peertube/www/peertube/index.html` (button) +- Test: `packages/secubox-peertube/tests/test_ops_api.py` (extend) + +**Interfaces:** +- Consumes: `_spool_op`, `require_admin`, `OPS_DIR`, `_write_result`, `_jq`, `ADMIN_SECRET` (from Tasks 1-2). +- Produces: `POST /admin/reset-password` (body `{"password"?: str}` → `{"id"}`); `peertubectl reset-admin-password `. + +- [ ] **Step 1: Write the failing API test** + +Append to `tests/test_ops_api.py`: + +```python +def test_reset_password_spools_op(tmp_path, monkeypatch): + monkeypatch.setattr(m, "OPS_DIR", tmp_path) + import asyncio + out = asyncio.run(m.reset_password_op(m.ResetPasswordBody(password="hunter2"), user={"role": "admin"})) + assert out["success"] is True and "id" in out + import json + req = json.loads((tmp_path / f"{out['id']}.request.json").read_text()) + assert req["op"] == "reset-password" and req["password"] == "hunter2" + + +def test_reset_password_generates_when_absent(tmp_path, monkeypatch): + monkeypatch.setattr(m, "OPS_DIR", tmp_path) + import asyncio, json + out = asyncio.run(m.reset_password_op(m.ResetPasswordBody(password=None), user={"role": "admin"})) + req = json.loads((tmp_path / f"{out['id']}.request.json").read_text()) + assert len(req["password"]) >= 16 # generated strong password +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cd packages/secubox-peertube && PYTHONPATH=/home/reepost/CyberMindStudio/secubox-deb-worktrees/798-peertube-webui-admin-reset-password-vers/common /home/reepost/CyberMindStudio/secubox-deb/secubox-deb/.venv/bin/python3 -m pytest tests/test_ops_api.py -k reset -v` +Expected: FAIL — `AttributeError: … 'reset_password_op'` / `ResetPasswordBody`. + +- [ ] **Step 3: Add the API endpoint** + +In `api/main.py` (Pydantic models section), add: + +```python +class ResetPasswordBody(BaseModel): + password: Optional[str] = None +``` + +Add the route: + +```python +@router.post("/admin/reset-password") +async def reset_password_op(body: ResetPasswordBody, user=Depends(require_admin)): + """Reset the PeerTube admin (root) password. Lockout-safe: runs the PeerTube + CLI in the LXC via the root spool, then rewrites the admin secret. If no + password is given, a strong one is generated and returned in the op result.""" + pw = body.password or secrets.token_urlsafe(18) + op_id = _spool_op("reset-password", password=pw) + return {"success": True, "id": op_id} +``` + +- [ ] **Step 4: Replace the `cmd_reset_admin_password` stub in `peertubectl`** + +```bash +cmd_reset_admin_password() { + # args: (called by process-ops) + local reqf="${1:-}" id="${2:-}" + [ -n "$reqf" ] && [ -n "$id" ] || { err "usage: reset-admin-password "; return 1; } + local pw; pw="$(_jq "$reqf" '.password')" + [ -n "$pw" ] || { _write_result "$id" '{"status":"error","detail":"no password in request"}'; return; } + lxc_running || { _write_result "$id" '{"status":"error","detail":"LXC not running"}'; return; } + # PeerTube reset-password CLI is interactive (prompts once for the new + # password). Feed it on stdin as the peertube user with the service's env. + if printf '%s\n' "$pw" | lxc-attach -n "$LXC_NAME" -P "$LXC_PATH" -- \ + sudo -u peertube env NODE_ENV=production NODE_CONFIG_DIR=/var/www/peertube/config \ + bash -lc 'cd /var/www/peertube/peertube-latest && npm run reset-password -- -u root' >/dev/null 2>&1 + then + # Keep the dashboard's stored admin secret in sync (get_admin_token()). + printf '%s' "$pw" > "$ADMIN_SECRET" && chmod 0600 "$ADMIN_SECRET" + chown secubox-peertube:secubox-peertube "$ADMIN_SECRET" 2>/dev/null || true + _write_result "$id" "{\"status\":\"done\",\"detail\":\"password reset\",\"password\":$(printf '%s' "$pw" | jq -Rs .)}" + else + _write_result "$id" '{"status":"error","detail":"peertube reset-password CLI failed"}' + fi +} +``` + +- [ ] **Step 5: Add the WebUI button (Users tab)** + +In `www/peertube/index.html`, in the Users tab (near the per-user actions / `createUser` area ~line 461), add a maintenance-style reset control (admin action), plus a JS handler + polling helper. Add near the other JS functions: + +```javascript +async function pollOp(id, onDone) { + for (let i = 0; i < 60; i++) { + const r = await api('/admin/op/' + id); + if (r.status && r.status !== 'pending' && r.status !== 'running') { onDone(r); return; } + await new Promise(res => setTimeout(res, 2000)); + } + onDone({ status: 'error', detail: 'timed out waiting for op' }); +} + +async function resetAdminPassword() { + const pw = prompt('New admin password (leave empty to generate a strong one):', ''); + if (pw === null) return; + const r = await api('/admin/reset-password', { method: 'POST', body: JSON.stringify({ password: pw || null }) }); + if (!r || !r.id) { toast('Reset failed: ' + ((r && r.error) || 'no id'), 'error'); return; } + toast('Resetting admin password…'); + pollOp(r.id, (res) => { + if (res.status === 'done') toast('Password reset. ' + (res.password ? 'New password: ' + res.password : ''), 'success'); + else toast('Reset failed: ' + (res.detail || 'unknown'), 'error'); + }); +} +``` + +And a button in the Users tab header: +```html + +``` + +- [ ] **Step 6: Run tests + shellcheck** + +Run: `cd packages/secubox-peertube && PYTHONPATH=/home/reepost/CyberMindStudio/secubox-deb-worktrees/798-peertube-webui-admin-reset-password-vers/common /home/reepost/CyberMindStudio/secubox-deb/secubox-deb/.venv/bin/python3 -m pytest tests/test_ops_api.py -q && shellcheck packages/secubox-peertube/sbin/peertubectl` +Expected: PASS; shellcheck clean. + +- [ ] **Step 7: Commit** + +```bash +git add packages/secubox-peertube/sbin/peertubectl packages/secubox-peertube/api/main.py packages/secubox-peertube/www/peertube/index.html packages/secubox-peertube/tests/test_ops_api.py +git commit -m "feat(peertube): reset admin password button (lockout-safe, spool→root) (ref #798)" +``` + +--- + +## Task 4: Version check (`GET /version` + WebUI badge) + +**Files:** +- Modify: `packages/secubox-peertube/api/main.py` +- Modify: `packages/secubox-peertube/www/peertube/index.html` +- Test: `packages/secubox-peertube/tests/test_version.py` + +**Interfaces:** +- Consumes: `pt_api`, `require_jwt`. +- Produces: `_semver_lt(a, b) -> bool`; `GET /version` → `{"installed", "latest", "upgrade_available"}`. + +- [ ] **Step 1: Write the failing test** + +Create `packages/secubox-peertube/tests/test_version.py`: + +```python +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# Copyright (c) 2026 CyberMind — Gérald Kerma +# Source-Disclosed License — All rights reserved except as expressly granted. +# See LICENCE-CMSD-1.0.md for terms. +"""PeerTube version check (#798).""" +from api import main as m + + +def test_semver_lt(): + assert m._semver_lt("8.2.0", "8.2.1") is True + assert m._semver_lt("8.2.0", "8.10.0") is True # numeric, not lexical + assert m._semver_lt("8.2.0", "8.2.0") is False + assert m._semver_lt("9.0.0", "8.9.9") is False + + +def test_version_upgrade_available(monkeypatch): + import asyncio + monkeypatch.setattr(m, "_installed_version", lambda: "8.2.0") + monkeypatch.setattr(m, "_latest_version", lambda: "8.3.0") + out = asyncio.run(m.version_info(user={"role": "user"})) + assert out == {"installed": "8.2.0", "latest": "8.3.0", "upgrade_available": True} + + +def test_version_up_to_date(monkeypatch): + import asyncio + monkeypatch.setattr(m, "_installed_version", lambda: "8.3.0") + monkeypatch.setattr(m, "_latest_version", lambda: "8.3.0") + out = asyncio.run(m.version_info(user={"role": "user"})) + assert out["upgrade_available"] is False + + +def test_version_offline_latest_none(monkeypatch): + import asyncio + monkeypatch.setattr(m, "_installed_version", lambda: "8.2.0") + monkeypatch.setattr(m, "_latest_version", lambda: None) + out = asyncio.run(m.version_info(user={"role": "user"})) + assert out["latest"] is None and out["upgrade_available"] is False +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cd packages/secubox-peertube && PYTHONPATH=/home/reepost/CyberMindStudio/secubox-deb-worktrees/798-peertube-webui-admin-reset-password-vers/common /home/reepost/CyberMindStudio/secubox-deb/secubox-deb/.venv/bin/python3 -m pytest tests/test_version.py -v` +Expected: FAIL — `_semver_lt` / `version_info` undefined. + +- [ ] **Step 3: Implement in `api/main.py`** + +```python +def _semver_lt(a: str, b: str) -> bool: + """True if version a < b (numeric field compare; tolerates a leading 'v').""" + def parts(v): + v = (v or "").lstrip("vV").split("-")[0] + return [int(x) for x in re.findall(r"\d+", v)] or [0] + pa, pb = parts(a), parts(b) + n = max(len(pa), len(pb)) + pa += [0] * (n - len(pa)); pb += [0] * (n - len(pb)) + return pa < pb + + +def _installed_version() -> Optional[str]: + r = pt_api("/config") + if r.get("success") and isinstance(r.get("data"), dict): + return r["data"].get("serverVersion") + return None + + +def _latest_version() -> Optional[str]: + """Latest PeerTube release tag from GitHub, cached ~1h in /run. Best-effort: + returns None offline / on rate-limit (never blocks the dashboard).""" + cache = OPS_DIR.parent / "latest-version.json" + try: + if cache.exists() and (time.time() - cache.stat().st_mtime) < 3600: + return json.loads(cache.read_text()).get("latest") + except Exception: + pass + try: + out = subprocess.run( + ["curl", "-s", "--max-time", "8", + "https://api.github.com/repos/Chocobozzz/PeerTube/releases/latest"], + capture_output=True, text=True, timeout=12) + tag = json.loads(out.stdout).get("tag_name") + if tag: + cache.parent.mkdir(parents=True, exist_ok=True) + cache.write_text(json.dumps({"latest": tag})) + return tag + except Exception: + return None + + +@router.get("/version") +async def version_info(user=Depends(require_jwt)): + installed = _installed_version() + latest = _latest_version() + up = bool(installed and latest and _semver_lt(installed, latest)) + return {"installed": installed, "latest": latest, "upgrade_available": up} +``` + +Ensure `time` is imported at the top of the file (add `import time` if absent). + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd packages/secubox-peertube && PYTHONPATH=/home/reepost/CyberMindStudio/secubox-deb-worktrees/798-peertube-webui-admin-reset-password-vers/common /home/reepost/CyberMindStudio/secubox-deb/secubox-deb/.venv/bin/python3 -m pytest tests/test_version.py -v` +Expected: PASS (4 tests). + +- [ ] **Step 5: Add the WebUI version badge (Maintenance card)** + +In `www/peertube/index.html`, in the Maintenance card (near `restartPeerTube`, ~line 623), add a version line + loader: + +```html +
version…
+ +``` +```javascript +async function loadVersion() { + const v = await api('/version'); + const el = document.getElementById('ptVersion'); + if (!v || !v.installed) { el.textContent = 'version: unknown'; return; } + if (v.upgrade_available) { + el.innerHTML = 'PeerTube v' + v.installed + ' · ⬆️ ' + v.latest + ' available'; + const b = document.getElementById('btnUpgrade'); b.style.display = ''; b.textContent = '⬆️ Upgrade to ' + v.latest; + } else { + el.innerHTML = 'PeerTube v' + v.installed + ' · ✅ up to date'; + } +} +``` +Call `loadVersion()` from the page's existing init/`refresh()` function. + +- [ ] **Step 6: Commit** + +```bash +git add packages/secubox-peertube/api/main.py packages/secubox-peertube/www/peertube/index.html packages/secubox-peertube/tests/test_version.py +git commit -m "feat(peertube): version check (installed vs latest GitHub release) (ref #798)" +``` + +--- + +## Task 5: Upgrade (verb + API + WebUI) + +**Files:** +- Modify: `packages/secubox-peertube/sbin/peertubectl` (replace `cmd_upgrade` stub) +- Modify: `packages/secubox-peertube/api/main.py` (endpoint) +- Modify: `packages/secubox-peertube/www/peertube/index.html` (button handler) +- Test: `packages/secubox-peertube/tests/test_ops_api.py` (extend) + +**Interfaces:** +- Consumes: `_spool_op`, `require_admin`, `_write_result`, `_jq`, LXC helpers. +- Produces: `POST /upgrade` (body `{"target"?: str}` → `{"id"}`); `peertubectl upgrade `. + +- [ ] **Step 1: Write the failing API test** + +Append to `tests/test_ops_api.py`: + +```python +def test_upgrade_spools_op(tmp_path, monkeypatch): + monkeypatch.setattr(m, "OPS_DIR", tmp_path) + import asyncio, json + out = asyncio.run(m.upgrade_op(m.UpgradeBody(target="latest"), user={"role": "admin"})) + assert out["success"] and "id" in out + req = json.loads((tmp_path / f"{out['id']}.request.json").read_text()) + assert req["op"] == "upgrade" and req["target"] == "latest" + + +def test_upgrade_requires_admin(tmp_path, monkeypatch): + monkeypatch.setattr(m, "OPS_DIR", tmp_path) + import asyncio + from fastapi import HTTPException + try: + asyncio.run(m.require_admin(user={"role": "user"})) + assert False, "require_admin should reject non-admin" + except HTTPException as e: + assert e.status_code == 403 +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cd packages/secubox-peertube && PYTHONPATH=/home/reepost/CyberMindStudio/secubox-deb-worktrees/798-peertube-webui-admin-reset-password-vers/common /home/reepost/CyberMindStudio/secubox-deb/secubox-deb/.venv/bin/python3 -m pytest tests/test_ops_api.py -k upgrade -v` +Expected: FAIL — `upgrade_op` / `UpgradeBody` undefined. + +- [ ] **Step 3: Add the API endpoint** + +```python +class UpgradeBody(BaseModel): + target: Optional[str] = "latest" + + +@router.post("/upgrade") +async def upgrade_op(body: UpgradeBody, user=Depends(require_admin)): + """Upgrade PeerTube in the LXC (backup → download → migrate → restart) via the + root spool. Poll GET /admin/op/{id}; the op reports running/done/error.""" + op_id = _spool_op("upgrade", target=body.target or "latest") + return {"success": True, "id": op_id} +``` + +- [ ] **Step 4: Replace the `cmd_upgrade` stub in `peertubectl`** + +```bash +cmd_upgrade() { + # args: (called by process-ops) + local reqf="${1:-}" id="${2:-}" + [ -n "$id" ] || { err "usage: upgrade "; return 1; } + lxc_running || { _write_result "$id" '{"status":"error","detail":"LXC not running"}'; return; } + _write_result "$id" '{"status":"running","detail":"backup + download"}' + local ts; ts="$(date +%Y%m%d-%H%M%S)" + local backup="/var/lib/secubox/peertube/backups/pre-upgrade-$ts.sql.gz" + + # 1. Mandatory DB backup (abort on failure). + if ! lxc-attach -n "$LXC_NAME" -P "$LXC_PATH" -- bash -lc \ + "sudo -u postgres pg_dump peertube_prod | gzip > '$backup'" 2>/dev/null; then + _write_result "$id" "{\"status\":\"error\",\"detail\":\"DB backup failed — upgrade aborted\"}" + return + fi + + # 2-5. Run PeerTube's official upgrade inside the LXC. PeerTube ships an + # upgrade script that downloads the latest release, installs deps, and + # runs migrations; the service is restarted after. It keeps the previous + # versions/ dir, so a failed run leaves peertube-latest on the old build. + if lxc-attach -n "$LXC_NAME" -P "$LXC_PATH" -- \ + sudo -u peertube env NODE_ENV=production NODE_CONFIG_DIR=/var/www/peertube/config \ + bash -lc 'cd /var/www/peertube && ./peertube-latest/scripts/upgrade.sh' >/dev/null 2>&1 + then + # 6. Health-check: PeerTube answers /api/v1/config once back up. + svc restart + local ok=0 i + for i in $(seq 1 30); do + if lxc-attach -n "$LXC_NAME" -P "$LXC_PATH" -- \ + curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:${HTTP_PORT}/api/v1/config" 2>/dev/null | grep -q 200 + then ok=1; break; fi + sleep 2 + done + if [ "$ok" = 1 ]; then + _write_result "$id" "{\"status\":\"done\",\"detail\":\"upgraded\",\"backup\":$(printf '%s' "$backup" | jq -Rs .)}" + else + _write_result "$id" "{\"status\":\"error\",\"detail\":\"upgraded but health-check failed — restore from backup if needed\",\"backup\":$(printf '%s' "$backup" | jq -Rs .)}" + fi + else + # Upgrade script failed — the old peertube-latest is untouched; restart it. + svc restart + _write_result "$id" "{\"status\":\"error\",\"detail\":\"upgrade script failed — running version unchanged\",\"backup\":$(printf '%s' "$backup" | jq -Rs .)}" + fi +} +``` + +> Note for the implementer: verify the exact PeerTube v8.2.0 upgrade entrypoint at live-test time (`./peertube-latest/scripts/upgrade.sh` is the documented path; some releases use `npm run upgrade-peertube`). If the path differs, adjust this single command — the surrounding backup/health/rollback logic is unchanged. Live-testing the upgrade requires accepting PeerTube downtime + a verified backup. + +- [ ] **Step 5: Wire the WebUI upgrade handler** + +In `www/peertube/index.html`, add (reusing `pollOp` from Task 3): + +```javascript +async function upgradePeerTube() { + if (!confirm('Upgrade PeerTube now?\n\nThis stops PeerTube, backs up the DB, downloads the new release and runs migrations (several minutes of downtime). A pre-upgrade DB backup is taken automatically.')) return; + const r = await api('/upgrade', { method: 'POST', body: JSON.stringify({ target: 'latest' }) }); + if (!r || !r.id) { toast('Upgrade failed to start: ' + ((r && r.error) || 'no id'), 'error'); return; } + toast('Upgrade started — this takes several minutes…'); + document.getElementById('btnUpgrade').disabled = true; + pollOp(r.id, (res) => { + document.getElementById('btnUpgrade').disabled = false; + if (res.status === 'done') { toast('Upgrade complete. Backup: ' + (res.backup || ''), 'success'); loadVersion(); } + else toast('Upgrade failed: ' + (res.detail || 'unknown') + (res.backup ? ' (backup: ' + res.backup + ')' : ''), 'error'); + }); +} +``` +(`pollOp` in Task 3 caps at 60×2s = 2 min; for the upgrade, raise its cap or make `upgradePeerTube` poll with its own longer loop — use a 300-iteration/2s loop here so a multi-minute upgrade isn't cut off.) + +- [ ] **Step 6: Run tests + shellcheck** + +Run: `cd packages/secubox-peertube && PYTHONPATH=/home/reepost/CyberMindStudio/secubox-deb-worktrees/798-peertube-webui-admin-reset-password-vers/common /home/reepost/CyberMindStudio/secubox-deb/secubox-deb/.venv/bin/python3 -m pytest tests/ -q && shellcheck packages/secubox-peertube/sbin/peertubectl && bash packages/secubox-peertube/tests/test_process_ops.sh` +Expected: all PASS; shellcheck clean. + +- [ ] **Step 7: Commit** + +```bash +git add packages/secubox-peertube/sbin/peertubectl packages/secubox-peertube/api/main.py packages/secubox-peertube/www/peertube/index.html packages/secubox-peertube/tests/test_ops_api.py +git commit -m "feat(peertube): one-click upgrade with pre-backup + rollback (spool→root) (ref #798)" +``` + +--- + +## Final verification + +- [ ] **All tests + lint:** + +```bash +cd packages/secubox-peertube && PYTHONPATH=/home/reepost/CyberMindStudio/secubox-deb-worktrees/798-peertube-webui-admin-reset-password-vers/common /home/reepost/CyberMindStudio/secubox-deb/secubox-deb/.venv/bin/python3 -m pytest tests/ -q +shellcheck packages/secubox-peertube/sbin/peertubectl +bash packages/secubox-peertube/tests/test_process_ops.sh +``` +Expected: all green. + +- [ ] **Deploy note (not a code step):** rebuild + install `secubox-peertube.deb` on gk2 (peertubectl → /usr/sbin, units → /usr/lib/systemd/system, `systemctl daemon-reload`, enable `peertube-ops.path`). Live-validate in order: reset-password (verify login + that the CLI took one stdin line — if it re-prompts for confirmation, feed the password twice in `cmd_reset_admin_password`), version badge, then upgrade **only when downtime is acceptable** (confirm the `upgrade.sh` entrypoint first, and that a backup lands in `/var/lib/secubox/peertube/backups/`). + +--- + +## Self-Review + +**Spec coverage:** spool→root mechanism (Task 2) ✅; reset-password lockout-safe + secret rewrite (Task 3) ✅; version check semver + GitHub + cache (Task 4) ✅; upgrade backup/migrate/restart/rollback (Task 5) ✅; `require_admin` gate (Task 1, applied Tasks 3/5) ✅; unprivileged API / NoNewPrivileges preserved (spool only, Tasks 1-2) ✅; packaging units+postinst+tmpfiles (Task 2) ✅; tests peertubectl+API+semver ✅. + +**Placeholder scan:** none — every step has concrete code/commands. The upgrade entrypoint carries an explicit live-verify note (not a placeholder — a documented single-command adjustment point). + +**Type consistency:** `OPS_DIR`, `_spool_op`, `_read_op_result`, `require_admin` defined in Task 1 and reused verbatim in 3/4/5; `_write_result`/`_jq`/`OPS_DIR`/`ADMIN_SECRET` defined in Task 2 and reused in 3/5; result JSON contract `{status, detail, [password|backup]}` consistent between `peertubectl` writers and `_read_op_result`/WebUI readers; `ResetPasswordBody`/`UpgradeBody`/`version_info`/`reset_password_op`/`upgrade_op` names match between tests and endpoints. diff --git a/docs/superpowers/specs/2026-07-04-peertube-webui-admin-ops-design.md b/docs/superpowers/specs/2026-07-04-peertube-webui-admin-ops-design.md new file mode 100644 index 00000000..ccdcb147 --- /dev/null +++ b/docs/superpowers/specs/2026-07-04-peertube-webui-admin-ops-design.md @@ -0,0 +1,119 @@ +# Design — PeerTube WebUI admin ops: reset-password + version check/upgrade + +- **Issue** : [#798](https://github.com/CyberMind-FR/secubox-deb/issues/798) +- **Date** : 2026-07-04 +- **Licence** : LicenseRef-CMSD-1.0 +- **Module** : `packages/secubox-peertube/` + +## 1. Problème + +Le WebUI PeerTube n'offre aucune action admin pour (a) réinitialiser le mot de passe admin, (b) voir si une nouvelle version existe, (c) faire l'upgrade. Ces opérations doivent s'exécuter **dans la LXC `peertube`** (lxc-attach, npm CLI PeerTube), mais l'API `secubox-peertube` tourne **non-privilégiée** : `User=secubox`, `NoNewPrivileges=true`, **aucun sudoers**. Elle ne peut donc pas `lxc-attach` ni `sudo`. + +Le module a déjà résolu ce genre de besoin (#407) via un **spool + unité root** : l'API dépose un fichier dans `/run/secubox/`, une unité systemd `.path` (root) le détecte et lance un `.service` (root) qui exécute `peertubectl` (qui fait le lxc-attach). On généralise ce pattern. + +## 2. Objectif + +Trois actions admin dans le WebUI PeerTube, toutes via le mécanisme spool→root : +- **A. Reset admin password** — lockout-safe (CLI PeerTube), réécrit le secret admin. +- **B. Check version** — installée vs dernière release (non privilégié). +- **C. Upgrade** — download release + migration DB + restart, avec backup pré-upgrade obligatoire et rollback. + +## 3. Architecture — mécanisme d'ops privilégiées partagé + +### 3.1 Spool + unité root (généralisation de #407) + +- **Répertoire de spool** : `/run/secubox/peertube/ops/` (créé 0750 `secubox:secubox` par postinst + tmpfiles). L'API (secubox) y écrit ; root lit/supprime. +- **Requête** : l'API écrit `ops/.request.json` = `{ "op": "reset-password"|"upgrade", "id": "", ...args }`, chmod **0600** (peut contenir un mot de passe). +- **Watcher** : `peertube-ops.path` (root) — `DirectoryNotEmpty=/run/secubox/peertube/ops` → `Unit=peertube-ops.service`. +- **Exécuteur** : `peertube-ops.service` (root, `Type=oneshot`) — `ExecStart=/usr/sbin/peertubectl process-ops`. +- **`peertubectl process-ops`** : pour chaque `ops/*.request.json` : parse `op`, dispatch (`cmd_reset_admin_password` / `cmd_upgrade`), écrit `ops/.result.json` = `{ "status": "done"|"error"|"running", "detail": "...", ...extra }` (0640 `root:secubox`), puis supprime le `.request.json` (évite le re-trigger + sensible). +- **Polling** : l'API lit `ops/.result.json`. Tant qu'il n'existe pas → `pending`. L'upgrade (long) écrit d'abord `status:"running"` (progress), puis `done`/`error`. + +Ce mécanisme **préserve NoNewPrivileges** sur l'API (aucun sudo) et concentre le privilège dans une unité root auditée, exactement comme #407. + +### 3.2 peertubectl — constantes existantes + +`peertubectl` utilise déjà `lxc-attach -n "$LXC_NAME" -P "$LXC_PATH" -- …` (LXC sous `/data/lxc`). PeerTube dans la LXC : `/var/www/peertube`, user OS `peertube`, service `peertube.service`, release `peertube-latest → versions/peertube-v8.2.0`, `NODE_ENV=production`, `NODE_CONFIG_DIR=/var/www/peertube/config`. + +## 4. Les trois actions + +### 4.1 A — Reset admin password + +**peertubectl `reset-admin-password`** (appelé par process-ops avec la requête) : +``` +pw="$(jq -r .password )" # fourni par l'API (aléatoire si l'utilisateur n'en donne pas) +lxc-attach -n peertube -P /data/lxc -- sudo -u peertube \ + env NODE_ENV=production NODE_CONFIG_DIR=/var/www/peertube/config \ + bash -lc 'cd /var/www/peertube/peertube-latest && printf "%s\n" "'"$pw"'" | npm run reset-password -- -u root' +``` +- Le CLI `reset-password` est **interactif** (prompt masqué) → on lui pipe le mot de passe (une ligne). +- **Puis réécrit `/etc/secubox/secrets/peertube-admin`** avec le nouveau mot de passe (0600 `secubox-peertube`), sinon `get_admin_token()` (api/main.py:262) casse pour toutes les write-ops du dashboard. +- Résultat : `{status:"done", password:""}` (le mdp n'est renvoyé qu'ici, une fois, pour que l'UI l'affiche si généré). + +**API `POST /admin/reset-password`** (`require_jwt` + gate rôle admin) : body `{"password": ""}`. Si absent → génère un mdp fort (secrets). Écrit la requête spool. Renvoie `{"id": ""}`. +**API `GET /admin/op/{id}`** (`require_jwt`) : lit le result file → `{status, detail, password?}`. `password` purgé du result après première lecture (ou result 0640 + suppression best-effort). + +**WebUI** (onglet Users, action admin — mirroir de `deleteUser`) : bouton **🔑 Reset password** → petit modal (« saisir un mot de passe » ou « générer ») → `POST` → poll `GET /admin/op/{id}` → toast + si généré, affiche le mdp (copiable). + +### 4.2 B — Check version (non privilégié, côté API) + +**API `GET /version`** (`require_jwt`) : +- **installée** : via `pt_api("/api/v1/config")` → champ `serverVersion` (l'API a déjà `pt_api`). Fallback : lire la cible du symlink si accessible. +- **latest** : `GET https://api.github.com/repos/Chocobozzz/PeerTube/releases/latest` → `tag_name` (ex. `v8.2.0`), **caché ~1 h** (fichier `/run/secubox/peertube/latest-version.json`, best-effort ; hors-ligne → `latest=null`). +- Compare **semver** → `{"installed": "8.2.0", "latest": "8.x.y", "upgrade_available": bool}`. + +**WebUI** (carte Maintenance) : badge `v8.2.0 · ✅ à jour` ou `v8.2.0 · ⬆️ v8.x dispo`. + +### 4.3 C — Upgrade (gated, spool→root) + +**peertubectl `upgrade`** (via process-ops) — étapes, chacune vérifiée, écrit `status:"running"` + progression : +1. **Backup DB obligatoire** : `pg_dump` de `peertube_prod` → `/var/lib/secubox/peertube/backups/pre-upgrade-.sql.gz` (dans la LXC ou via le postgres de la LXC). Si échec backup → **abort** (pas d'upgrade). +2. Résoudre la version cible (release GitHub `target|latest`), télécharger le zip release dans `versions/`. +3. Extraire → `versions/peertube-vX` ; `npm install --production` (dans la LXC, user peertube). +4. **Migrations** : arrêter `peertube.service`, swap symlink `peertube-latest → versions/peertube-vX`, lancer les migrations (PeerTube les exécute au démarrage, ou `npm run … migrate`). +5. `systemctl start peertube` + **health-check** (HTTP 200 sur `/api/v1/config` via `pt_api` boucle courte). +6. **En cas d'échec (npm/migration/health)** : re-swap le symlink vers l'ancienne version + restart → **l'ancienne version reste live** ; result `{status:"error", detail, backup:""}`. +7. Succès : result `{status:"done", from, to, backup}`. + +**API `POST /upgrade`** (`require_jwt` + admin) : body `{"target": "latest"|"vX"}`. Écrit la requête spool. Renvoie `{"id"}`. Poll `GET /admin/op/{id}` → `running`/`done`/`error` (+ `detail`/progression). La route root a un `TimeoutStartSec` généreux (upgrade = plusieurs minutes). + +**WebUI** (carte Maintenance) : bouton **⬆️ Upgrade to vX** (visible seulement si `upgrade_available`) → `confirm()` **avertissant downtime + backup** → `POST /upgrade` → polling avec état (« Upgrade en cours… » / « Terminé v8.2.0→vX » / « Échec — ancienne version restaurée, backup: … »). + +## 5. Gestion d'erreurs / sécurité + +- **Fail-safe** : chaque op écrit un result `{status, detail}` ; l'API mappe en toast. Requête illisible → result `error`. +- **Reset** : si `npm run reset-password` échoue, **ne pas** réécrire le secret ; result `error`. +- **Upgrade** : backup obligatoire (abort si échec) ; échec post-download → **pas de swap** (ou re-swap) → ancienne version live ; le backup path est toujours rapporté. Rollback restore = documenté (manuel), hors périmètre auto. +- **Auth** : toutes les routes mutantes `require_jwt`. **Nouveau** : gate rôle admin (le module accepte aujourd'hui tout JWT valide ; `require_jwt` expose un claim `role` via `/auth/verify`). On ajoute une dépendance `require_admin` locale (vérifie `role in {admin}`), appliquée à `/admin/reset-password` et `/upgrade`. +- **Secrets** : le mdp transite en clair dans le fichier spool (0600 secubox) et le result (0640, purgé après lecture) — dans `/run` (tmpfs), TTL court. Acceptable (même surface que le secret admin déjà stocké 0600). +- **Concurrence** : `process-ops` traite les fichiers séquentiellement ; l'API refuse un 2e upgrade si un result `running` existe. +- **Pas de régression** `/run/secubox` 1777 ni `/etc/secubox` 0755 (on crée seulement `/run/secubox/peertube/ops` sous le parent). + +## 6. Tests + +- **peertubectl** (`bats`/shell) : `--dry-run` sur `reset-admin-password` et `upgrade` (n'exécute pas lxc-attach, imprime la commande) ; `process-ops` lit une requête fixture, écrit un result attendu (lxc-attach mocké via un stub PATH) ; `shellcheck` propre. +- **API** (`pytest`) : `POST /admin/reset-password` écrit bien une requête spool (dir monkeypatché) ; `GET /admin/op/{id}` lit un result fixture → mapping statut ; `GET /version` compare-semver (installed Optional[int]: return None +# ============================================================================ +# Admin Ops Spool Plumbing (issue #798) +# ============================================================================ + +OPS_DIR = Path("/run/secubox/peertube/ops") +_OP_ID_RE = re.compile(r"^[0-9a-f]{8,32}$") + + +async def require_admin(user=Depends(require_jwt)): + """Gate destructive ops to admin-role identities. require_jwt validated the + token; the role lives in the user store (JWT carries only sub/jti), so look + it up like auth.py's verify handler does.""" + sub = (user or {}).get("sub") if isinstance(user, dict) else None + role = "" + try: + u = user_store.get_user(sub) or {} + role = u.get("role", "") if isinstance(u, dict) else "" + except Exception: + role = "" + if role != "admin": + raise HTTPException(status_code=403, detail="admin role required") + return user + + +def _spool_op(op: str, **args) -> str: + """Write an intent file the root peertube-ops.path unit will pick up. Returns + the op id; the caller polls GET /admin/op/{id}. Unprivileged (no lxc-attach).""" + op_id = secrets.token_hex(8) + OPS_DIR.mkdir(parents=True, exist_ok=True) + req = OPS_DIR / f"{op_id}.request.json" + fd = os.open(str(req), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + os.write(fd, json.dumps({"op": op, "id": op_id, **args}).encode()) + os.close(fd) + return op_id + + +def _read_op_result(op_id: str) -> dict: + """Read .result.json; {status: pending} until the root unit writes it.""" + if not _OP_ID_RE.match(op_id or ""): + return {"status": "error", "detail": "bad op id"} + res = OPS_DIR / f"{op_id}.result.json" + if not res.exists(): + return {"status": "pending"} + try: + return json.loads(res.read_text()) + except Exception as e: # pragma: no cover + return {"status": "error", "detail": f"unreadable result: {e}"} + + # ============================================================================ # Public Endpoints # ============================================================================ @@ -857,4 +914,93 @@ async def set_peertube_config(config: PeerTubeConfig, user=Depends(require_jwt)) return {"success": True} +# ============================================================================ +# Admin Operations (Spool-Based) +# ============================================================================ + +@router.get("/admin/op/{op_id}") +async def get_op_result(op_id: str, user=Depends(require_jwt)): + """Poll the result of a spooled admin op (reset-password / upgrade).""" + return _read_op_result(op_id) + + +@router.post("/admin/reset-password") +async def reset_password_op(body: ResetPasswordBody, user=Depends(require_admin)): + """Reset the PeerTube admin (root) password. Lockout-safe: runs the PeerTube + CLI in the LXC via the root spool, then rewrites the admin secret. If no + password is given, a strong one is generated and returned in the op result.""" + pw = body.password or secrets.token_urlsafe(18) + op_id = _spool_op("reset-admin-password", password=pw) + return {"success": True, "id": op_id} + + +# ============================================================================ +# Version Check (issue #798) +# ============================================================================ + +def _semver_lt(a: str, b: str) -> bool: + """True if version a < b (numeric field compare; tolerates a leading 'v').""" + def parts(v): + v = (v or "").lstrip("vV").split("-")[0] + return [int(x) for x in re.findall(r"\d+", v)] or [0] + pa, pb = parts(a), parts(b) + n = max(len(pa), len(pb)) + pa += [0] * (n - len(pa)); pb += [0] * (n - len(pb)) + return pa < pb + + +def _installed_version() -> Optional[str]: + r = pt_api("/config") + if r.get("success") and isinstance(r.get("data"), dict): + return r["data"].get("serverVersion") + return None + + +def _latest_version() -> Optional[str]: + """Latest PeerTube release tag from GitHub, cached ~1h in /run. Best-effort: + returns None offline / on rate-limit (never blocks the dashboard).""" + cache = OPS_DIR.parent / "latest-version.json" + try: + if cache.exists() and (time.time() - cache.stat().st_mtime) < 3600: + return json.loads(cache.read_text()).get("latest") + except Exception: + pass + try: + out = subprocess.run( + ["curl", "-s", "--max-time", "8", + "https://api.github.com/repos/Chocobozzz/PeerTube/releases/latest"], + capture_output=True, text=True, timeout=12) + tag = json.loads(out.stdout).get("tag_name") + if tag: + cache.parent.mkdir(parents=True, exist_ok=True) + cache.write_text(json.dumps({"latest": tag})) + return tag + except Exception: + return None + + +@router.get("/version") +async def version_info(user=Depends(require_jwt)): + installed = _installed_version() + latest = _latest_version() + up = bool(installed and latest and _semver_lt(installed, latest)) + return {"installed": installed, "latest": latest, "upgrade_available": up} + + +# ============================================================================ +# Upgrade (issue #798) +# ============================================================================ + +class UpgradeBody(BaseModel): + target: Optional[str] = "latest" + + +@router.post("/upgrade") +async def upgrade_op(body: UpgradeBody, user=Depends(require_admin)): + """Upgrade PeerTube in the LXC (backup → download → migrate → restart) via the + root spool. Poll GET /admin/op/{id}; the op reports running/done/error.""" + op_id = _spool_op("upgrade", target=body.target or "latest") + return {"success": True, "id": op_id} + + app.include_router(router) diff --git a/packages/secubox-peertube/debian/peertube-ops.path b/packages/secubox-peertube/debian/peertube-ops.path new file mode 100644 index 00000000..41163a18 --- /dev/null +++ b/packages/secubox-peertube/debian/peertube-ops.path @@ -0,0 +1,14 @@ +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +[Unit] +Description=Watch for spooled PeerTube admin ops (reset-password / upgrade) +Documentation=https://github.com/CyberMind-FR/secubox-deb/issues/798 + +[Path] +# The unprivileged dashboard drops .request.json here; this root service +# runs peertubectl process-ops to execute them inside the LXC — no sudo, so the +# dashboard keeps NoNewPrivileges (CSPN privilege separation, mirrors #407). +DirectoryNotEmpty=/run/secubox/peertube/ops +Unit=peertube-ops.service + +[Install] +WantedBy=paths.target diff --git a/packages/secubox-peertube/debian/peertube-ops.service b/packages/secubox-peertube/debian/peertube-ops.service new file mode 100644 index 00000000..0b596015 --- /dev/null +++ b/packages/secubox-peertube/debian/peertube-ops.service @@ -0,0 +1,11 @@ +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +[Unit] +Description=Drain the PeerTube admin-ops spool (root, #798) +Documentation=https://github.com/CyberMind-FR/secubox-deb/issues/798 + +[Service] +Type=oneshot +# Root (no User=) so peertubectl can lxc-attach into the container. Upgrade can +# take several minutes (download + npm install + DB migration). +TimeoutStartSec=1800 +ExecStart=/usr/sbin/peertubectl process-ops diff --git a/packages/secubox-peertube/debian/postinst b/packages/secubox-peertube/debian/postinst index 034b3ea1..9c1595b9 100755 --- a/packages/secubox-peertube/debian/postinst +++ b/packages/secubox-peertube/debian/postinst @@ -62,6 +62,13 @@ if [ "$1" = "configure" ]; then # Root path-unit (#407) that installs spooled YouTube cookies without sudo. systemctl enable --now peertube-cookie-install.path 2>/dev/null || true + # Root ops spool (#798): reset-admin-password / upgrade requests dropped by + # the unprivileged dashboard, drained by peertube-ops.path (no sudo, CSPN). + install -d -o secubox -g secubox -m 0750 /run/secubox/peertube/ops 2>/dev/null || true + install -d -o peertube -g peertube -m 0750 /var/lib/secubox/peertube/backups 2>/dev/null || true + deb-systemd-helper enable peertube-ops.path >/dev/null 2>&1 || true + deb-systemd-invoke start peertube-ops.path >/dev/null 2>&1 || true + # Reload nginx to pick up the /api/v1/peertube/ + /peertube/ locations # and the public vhost. NOTE: PeerTube itself is NOT installed by this # postinst — run `peertubectl install` to provision the LXC. diff --git a/packages/secubox-peertube/debian/rules b/packages/secubox-peertube/debian/rules index edc21afb..fafb0b27 100755 --- a/packages/secubox-peertube/debian/rules +++ b/packages/secubox-peertube/debian/rules @@ -38,3 +38,8 @@ override_dh_auto_install: install -m 644 debian/secubox-peertube.service debian/secubox-peertube/usr/lib/systemd/system/ install -m 644 debian/peertube-cookie-install.path debian/secubox-peertube/usr/lib/systemd/system/ install -m 644 debian/peertube-cookie-install.service debian/secubox-peertube/usr/lib/systemd/system/ + install -m 644 debian/peertube-ops.path debian/secubox-peertube/usr/lib/systemd/system/ + install -m 644 debian/peertube-ops.service debian/secubox-peertube/usr/lib/systemd/system/ + # tmpfiles.d: recreate the /run ops spool dir across reboots (tmpfs) + install -d debian/secubox-peertube/usr/lib/tmpfiles.d + install -m 644 debian/secubox-peertube.tmpfiles debian/secubox-peertube/usr/lib/tmpfiles.d/secubox-peertube.conf diff --git a/packages/secubox-peertube/debian/secubox-peertube.tmpfiles b/packages/secubox-peertube/debian/secubox-peertube.tmpfiles new file mode 100644 index 00000000..f8d2b600 --- /dev/null +++ b/packages/secubox-peertube/debian/secubox-peertube.tmpfiles @@ -0,0 +1,3 @@ +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +d /run/secubox/peertube 0750 secubox secubox - +d /run/secubox/peertube/ops 0750 secubox secubox - diff --git a/packages/secubox-peertube/sbin/peertubectl b/packages/secubox-peertube/sbin/peertubectl index 5b94e07b..b13e77d2 100755 --- a/packages/secubox-peertube/sbin/peertubectl +++ b/packages/secubox-peertube/sbin/peertubectl @@ -43,6 +43,21 @@ LXC_PATH=$(config_get "path" "/data/lxc") HTTP_PORT=$(config_get "http_port" "9000") PUBLIC_HOSTNAME=$(config_get "public_hostname" "peertube.gk2.secubox.in") +OPS_DIR="${SECUBOX_PEERTUBE_OPS_DIR:-/run/secubox/peertube/ops}" +ADMIN_SECRET="${SECUBOX_PEERTUBE_ADMIN_SECRET:-/etc/secubox/secrets/peertube-admin}" + +# _write_result — atomic result file for the API to poll. +_write_result() { + local id="$1" body="$2" + printf '%s' "$body" > "$OPS_DIR/$id.result.json.tmp" + chown root:secubox "$OPS_DIR/$id.result.json.tmp" 2>/dev/null || true + chmod 0640 "$OPS_DIR/$id.result.json.tmp" 2>/dev/null || true + mv -f "$OPS_DIR/$id.result.json.tmp" "$OPS_DIR/$id.result.json" +} + +# _jq — read a field from a request file (jq is available on the host). +_jq() { jq -r "$2" < "$1" 2>/dev/null; } + # ── LXC helpers ─────────────────────────────────────────────────────────────── lxc_state() { lxc-info -n "$LXC_NAME" -P "$LXC_PATH" 2>/dev/null \ @@ -130,6 +145,100 @@ PY log "Done. Retry the import from the PeerTube 'Import with URL' page." } +cmd_process_ops() { + # Root-only: the peertube-ops.path unit runs this when a request appears. + [ -d "$OPS_DIR" ] || return 0 + local f id op + for f in "$OPS_DIR"/*.request.json; do + [ -e "$f" ] || continue + id="$(basename "$f" .request.json)" + op="$(_jq "$f" '.op')" + case "$op" in + ping) _write_result "$id" '{"status":"done","detail":"pong"}' ;; + reset-admin-password) cmd_reset_admin_password "$f" "$id" ;; + upgrade) cmd_upgrade "$f" "$id" ;; + *) _write_result "$id" "$(jq -nc --arg o "$op" '{status:"error",detail:("unknown op: "+$o)}')" ;; + esac + rm -f "$f" + done +} + +cmd_reset_admin_password() { + # args: (called by process-ops) + local reqf="${1:-}" id="${2:-}" + [ -n "$reqf" ] && [ -n "$id" ] || { err "usage: reset-admin-password "; return 1; } + local pw; pw="$(_jq "$reqf" '.password')" + [ -n "$pw" ] || { _write_result "$id" '{"status":"error","detail":"no password in request"}'; return; } + lxc_running || { _write_result "$id" '{"status":"error","detail":"LXC not running"}'; return; } + # PeerTube reset-password CLI is interactive (prompts once for the new + # password). Feed it on stdin as the peertube user with the service's env. + if printf '%s\n' "$pw" | lxc-attach -n "$LXC_NAME" -P "$LXC_PATH" -- \ + sudo -u peertube env NODE_ENV=production NODE_CONFIG_DIR=/var/www/peertube/config \ + bash -lc 'cd /var/www/peertube/peertube-latest && npm run reset-password -- -u root' >/dev/null 2>&1 + then + # Keep the dashboard's stored admin secret in sync (get_admin_token()). + printf '%s' "$pw" > "$ADMIN_SECRET" && chmod 0600 "$ADMIN_SECRET" + chown secubox:secubox "$ADMIN_SECRET" 2>/dev/null || true + _write_result "$id" "{\"status\":\"done\",\"detail\":\"password reset\",\"password\":$(printf '%s' "$pw" | jq -Rs .)}" + else + _write_result "$id" '{"status":"error","detail":"peertube reset-password CLI failed"}' + fi +} + +cmd_upgrade() { + # args: (called by process-ops) + # NOTE: `target` (from the request) is currently ignored — upgrade.sh always + # fetches the latest PeerTube release; pinning to a specific target is not + # wired up yet. + local reqf="${1:-}" id="${2:-}" + [ -n "$id" ] || { err "usage: upgrade "; return 1; } + lxc_running || { _write_result "$id" '{"status":"error","detail":"LXC not running"}'; return; } + _write_result "$id" '{"status":"running","detail":"backup + download"}' + local ts; ts="$(date +%Y%m%d-%H%M%S)" + local backup="/var/lib/secubox/peertube/backups/pre-upgrade-$ts.sql.gz" + + # 1. Mandatory DB backup (abort on failure). The backups dir is created by + # postinst on the HOST, but pg_dump runs INSIDE the LXC — that host path + # is not the same filesystem in the container, so ensure it exists there + # too before dumping (otherwise gzip silently writes to a missing dir). + if ! lxc-attach -n "$LXC_NAME" -P "$LXC_PATH" -- bash -lc \ + "install -d -o peertube -g peertube -m 0750 /var/lib/secubox/peertube/backups && sudo -u postgres pg_dump peertube_prod | gzip > '$backup'" 2>/dev/null; then + _write_result "$id" "{\"status\":\"error\",\"detail\":\"DB backup failed — upgrade aborted\"}" + return + fi + + # 2-5. Run PeerTube's official upgrade inside the LXC. PeerTube ships an + # upgrade script that downloads the latest release, installs deps, and + # runs migrations; the service is restarted after. It keeps the previous + # versions/ dir, so a failed run leaves peertube-latest on the old build. + # NOTE: verify this entrypoint live for v8.2.0 — some PeerTube releases + # use `npm run upgrade-peertube` instead of scripts/upgrade.sh. If it + # differs, adjust only this single command. + if lxc-attach -n "$LXC_NAME" -P "$LXC_PATH" -- \ + sudo -u peertube env NODE_ENV=production NODE_CONFIG_DIR=/var/www/peertube/config \ + bash -lc 'cd /var/www/peertube && ./peertube-latest/scripts/upgrade.sh' >/dev/null 2>&1 + then + # 6. Health-check: PeerTube answers /api/v1/config once back up. + svc restart + local ok=0 + for _ in $(seq 1 30); do + if lxc-attach -n "$LXC_NAME" -P "$LXC_PATH" -- \ + curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:${HTTP_PORT}/api/v1/config" 2>/dev/null | grep -q 200 + then ok=1; break; fi + sleep 2 + done + if [ "$ok" = 1 ]; then + _write_result "$id" "{\"status\":\"done\",\"detail\":\"upgraded\",\"backup\":$(printf '%s' "$backup" | jq -Rs .)}" + else + _write_result "$id" "{\"status\":\"error\",\"detail\":\"upgraded but health-check failed — restore from backup if needed\",\"backup\":$(printf '%s' "$backup" | jq -Rs .)}" + fi + else + # Upgrade script failed — the old peertube-latest is untouched; restart it. + svc restart + _write_result "$id" "{\"status\":\"error\",\"detail\":\"upgrade script failed — running version unchanged\",\"backup\":$(printf '%s' "$backup" | jq -Rs .)}" + fi +} + cmd_help() { cat < [args] yt-dlp import works (YouTube blocks server IPs) logs [N] Tail N lines of peertube journal (default 50) reload Reload host nginx (after vhost changes) + process-ops (root) drain the /run ops spool help This message Config: $CONFIG_FILE (name/ip/path/http_port/public_hostname) @@ -155,6 +265,9 @@ main() { case "$noun" in install|status|start|stop|restart|logs|reload) "cmd_${noun}" "$@" ;; set-youtube-cookies) cmd_set_youtube_cookies "$@" ;; + process-ops) cmd_process_ops "$@" ;; + reset-admin-password) cmd_reset_admin_password "$@" ;; + upgrade) cmd_upgrade "$@" ;; help|-h|--help) cmd_help ;; *) err "unknown verb: $noun"; cmd_help; exit 1 ;; esac diff --git a/packages/secubox-peertube/tests/test_ops_api.py b/packages/secubox-peertube/tests/test_ops_api.py new file mode 100644 index 00000000..0ef8e105 --- /dev/null +++ b/packages/secubox-peertube/tests/test_ops_api.py @@ -0,0 +1,77 @@ +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# Copyright (c) 2026 CyberMind — Gérald Kerma +# Source-Disclosed License — All rights reserved except as expressly granted. +# See LICENCE-CMSD-1.0.md for terms. +"""Spool plumbing for PeerTube admin ops (#798).""" +import json +from pathlib import Path +from api import main as m + + +def test_spool_op_writes_request(tmp_path, monkeypatch): + monkeypatch.setattr(m, "OPS_DIR", tmp_path) + op_id = m._spool_op("reset-admin-password", password="s3cr3t") + assert len(op_id) >= 8 and all(c in "0123456789abcdef" for c in op_id) + req = json.loads((tmp_path / f"{op_id}.request.json").read_text()) + assert req["op"] == "reset-admin-password" and req["id"] == op_id and req["password"] == "s3cr3t" + assert (tmp_path / f"{op_id}.request.json").stat().st_mode & 0o777 == 0o600 + + +def test_read_op_result_pending_then_done(tmp_path, monkeypatch): + monkeypatch.setattr(m, "OPS_DIR", tmp_path) + assert m._read_op_result("abc12345")["status"] == "pending" + (tmp_path / "abc12345.result.json").write_text(json.dumps({"status": "done", "detail": "ok"})) + r = m._read_op_result("abc12345") + assert r["status"] == "done" and r["detail"] == "ok" + + +def test_read_op_result_rejects_bad_id(tmp_path, monkeypatch): + monkeypatch.setattr(m, "OPS_DIR", tmp_path) + assert m._read_op_result("../etc/passwd")["status"] == "error" + + +def test_reset_password_spools_op(tmp_path, monkeypatch): + monkeypatch.setattr(m, "OPS_DIR", tmp_path) + import asyncio + out = asyncio.run(m.reset_password_op(m.ResetPasswordBody(password="hunter2"), user={"role": "admin"})) + assert out["success"] is True and "id" in out + import json + req = json.loads((tmp_path / f"{out['id']}.request.json").read_text()) + assert req["op"] == "reset-admin-password" and req["password"] == "hunter2" + + +def test_reset_password_generates_when_absent(tmp_path, monkeypatch): + monkeypatch.setattr(m, "OPS_DIR", tmp_path) + import asyncio, json + out = asyncio.run(m.reset_password_op(m.ResetPasswordBody(password=None), user={"role": "admin"})) + req = json.loads((tmp_path / f"{out['id']}.request.json").read_text()) + assert len(req["password"]) >= 16 # generated strong password + + +def test_upgrade_spools_op(tmp_path, monkeypatch): + monkeypatch.setattr(m, "OPS_DIR", tmp_path) + import asyncio, json + out = asyncio.run(m.upgrade_op(m.UpgradeBody(target="latest"), user={"role": "admin"})) + assert out["success"] and "id" in out + req = json.loads((tmp_path / f"{out['id']}.request.json").read_text()) + assert req["op"] == "upgrade" and req["target"] == "latest" + + +def test_upgrade_requires_admin(tmp_path, monkeypatch): + monkeypatch.setattr(m, "OPS_DIR", tmp_path) + from secubox_core import user_store + monkeypatch.setattr(user_store, "get_user", lambda s: {"role": "user"}) + import asyncio + from fastapi import HTTPException + try: + asyncio.run(m.require_admin(user={"sub": "bob"})) + assert False, "require_admin should reject non-admin" + except HTTPException as e: + assert e.status_code == 403 + + +def test_require_admin_allows_admin(monkeypatch): + from secubox_core import user_store + monkeypatch.setattr(user_store, "get_user", lambda s: {"role": "admin"}) + import asyncio + assert asyncio.run(m.require_admin(user={"sub": "root"})) == {"sub": "root"} diff --git a/packages/secubox-peertube/tests/test_process_ops.sh b/packages/secubox-peertube/tests/test_process_ops.sh new file mode 100755 index 00000000..f18b16bc --- /dev/null +++ b/packages/secubox-peertube/tests/test_process_ops.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# process-ops dispatches a request file to a result file (#798). +set -euo pipefail +here="$(cd "$(dirname "$0")/.." && pwd)" +tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT +export SECUBOX_PEERTUBE_OPS_DIR="$tmp/ops"; mkdir -p "$SECUBOX_PEERTUBE_OPS_DIR" +printf '{"op":"ping","id":"deadbeef"}' > "$SECUBOX_PEERTUBE_OPS_DIR/deadbeef.request.json" +bash "$here/sbin/peertubectl" process-ops +[ -f "$SECUBOX_PEERTUBE_OPS_DIR/deadbeef.result.json" ] || { echo "no result written"; exit 1; } +grep -q '"status": *"done"' "$SECUBOX_PEERTUBE_OPS_DIR/deadbeef.result.json" || { echo "ping not done"; exit 1; } +[ -f "$SECUBOX_PEERTUBE_OPS_DIR/deadbeef.request.json" ] && { echo "request not removed"; exit 1; } +echo "PASS process-ops ping" + +printf '{"op":"reset-admin-password","id":"cafe0001","password":"x"}' > "$SECUBOX_PEERTUBE_OPS_DIR/cafe0001.request.json" +bash "$here/sbin/peertubectl" process-ops +grep -q '"status"' "$SECUBOX_PEERTUBE_OPS_DIR/cafe0001.result.json" || { echo "reset op produced no result"; exit 1; } +grep -q 'unknown op' "$SECUBOX_PEERTUBE_OPS_DIR/cafe0001.result.json" && { echo "reset op fell through to unknown-op (dispatch mismatch)"; exit 1; } +echo "PASS reset-admin-password dispatches" diff --git a/packages/secubox-peertube/tests/test_version.py b/packages/secubox-peertube/tests/test_version.py new file mode 100644 index 00000000..5a1aa951 --- /dev/null +++ b/packages/secubox-peertube/tests/test_version.py @@ -0,0 +1,37 @@ +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# Copyright (c) 2026 CyberMind — Gérald Kerma +# Source-Disclosed License — All rights reserved except as expressly granted. +# See LICENCE-CMSD-1.0.md for terms. +"""PeerTube version check (#798).""" +from api import main as m + + +def test_semver_lt(): + assert m._semver_lt("8.2.0", "8.2.1") is True + assert m._semver_lt("8.2.0", "8.10.0") is True # numeric, not lexical + assert m._semver_lt("8.2.0", "8.2.0") is False + assert m._semver_lt("9.0.0", "8.9.9") is False + + +def test_version_upgrade_available(monkeypatch): + import asyncio + monkeypatch.setattr(m, "_installed_version", lambda: "8.2.0") + monkeypatch.setattr(m, "_latest_version", lambda: "8.3.0") + out = asyncio.run(m.version_info(user={"role": "user"})) + assert out == {"installed": "8.2.0", "latest": "8.3.0", "upgrade_available": True} + + +def test_version_up_to_date(monkeypatch): + import asyncio + monkeypatch.setattr(m, "_installed_version", lambda: "8.3.0") + monkeypatch.setattr(m, "_latest_version", lambda: "8.3.0") + out = asyncio.run(m.version_info(user={"role": "user"})) + assert out["upgrade_available"] is False + + +def test_version_offline_latest_none(monkeypatch): + import asyncio + monkeypatch.setattr(m, "_installed_version", lambda: "8.2.0") + monkeypatch.setattr(m, "_latest_version", lambda: None) + out = asyncio.run(m.version_info(user={"role": "user"})) + assert out["latest"] is None and out["upgrade_available"] is False diff --git a/packages/secubox-peertube/www/peertube/index.html b/packages/secubox-peertube/www/peertube/index.html index ff637193..1a6c22ee 100644 --- a/packages/secubox-peertube/www/peertube/index.html +++ b/packages/secubox-peertube/www/peertube/index.html @@ -463,7 +463,10 @@
-

Users

+
+

Users

+ +
@@ -619,9 +622,11 @@

Maintenance

+
version…
+
@@ -677,11 +682,68 @@ setTimeout(() => t.remove(), 3000); } + async function pollOp(id, onDone) { + for (let i = 0; i < 60; i++) { + const r = await api('/admin/op/' + id); + if (r.status && r.status !== 'pending' && r.status !== 'running') { onDone(r); return; } + await new Promise(res => setTimeout(res, 2000)); + } + onDone({ status: 'error', detail: 'timed out waiting for op' }); + } + + async function resetAdminPassword() { + const pw = prompt('New admin password (leave empty to generate a strong one):', ''); + if (pw === null) return; + const r = await api('/admin/reset-password', { method: 'POST', body: JSON.stringify({ password: pw || null }) }); + if (!r || !r.id) { toast('Reset failed: ' + ((r && (r.detail || r.error)) || 'no id'), 'error'); return; } + toast('Resetting admin password…'); + pollOp(r.id, (res) => { + if (res.status === 'done') toast('Password reset. ' + (res.password ? 'New password: ' + res.password : ''), 'success'); + else toast('Reset failed: ' + (res.detail || 'unknown'), 'error'); + }); + } + + async function upgradePeerTube() { + if (!confirm('Upgrade PeerTube now?\n\nThis stops PeerTube, backs up the DB, downloads the new release and runs migrations (several minutes of downtime). A pre-upgrade DB backup is taken automatically.')) return; + const r = await api('/upgrade', { method: 'POST', body: JSON.stringify({ target: 'latest' }) }); + if (!r || !r.id) { toast('Upgrade failed to start: ' + ((r && r.error) || 'no id'), 'error'); return; } + toast('Upgrade started — this takes several minutes…'); + document.getElementById('btnUpgrade').disabled = true; + // Own longer poll loop (300×2s = 10 min) — pollOp's 60×2s (2 min) cap + // would cut off a multi-minute upgrade. + (async () => { + for (let i = 0; i < 300; i++) { + const res = await api('/admin/op/' + r.id); + if (res.status && res.status !== 'pending' && res.status !== 'running') { + document.getElementById('btnUpgrade').disabled = false; + if (res.status === 'done') { toast('Upgrade complete. Backup: ' + (res.backup || ''), 'success'); loadVersion(); } + else toast('Upgrade failed: ' + (res.detail || 'unknown') + (res.backup ? ' (backup: ' + res.backup + ')' : ''), 'error'); + return; + } + await new Promise(res => setTimeout(res, 2000)); + } + document.getElementById('btnUpgrade').disabled = false; + toast('Upgrade timed out waiting for completion (check logs)', 'error'); + })(); + } + function getRoleName(role) { const roles = { 0: 'Admin', 1: 'Moderator', 2: 'User' }; return roles[role] || 'User'; } + async function loadVersion() { + const v = await api('/version'); + const el = document.getElementById('ptVersion'); + if (!v || !v.installed) { el.textContent = 'version: unknown'; return; } + if (v.upgrade_available) { + el.innerHTML = 'PeerTube v' + v.installed + ' · ⬆️ ' + v.latest + ' available'; + const b = document.getElementById('btnUpgrade'); b.style.display = ''; b.textContent = '⬆️ Upgrade to ' + v.latest; + } else { + el.innerHTML = 'PeerTube v' + v.installed + ' · ✅ up to date'; + } + } + async function refresh() { try { const status = await api('/status'); @@ -730,6 +792,7 @@ // Load current tab data const activeTab = document.querySelector('.tab.active').textContent.toLowerCase(); showTab(activeTab); + loadVersion(); } catch (e) { console.error(e);