mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-29 12:34:38 +00:00
PeerTube WebUI: admin reset-password + version check/upgrade (#800)
Some checks are pending
License Headers / check (push) Waiting to run
Some checks are pending
License Headers / check (push) Waiting to run
* 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 <gandalf@Gk2.net>
This commit is contained in:
parent
a3e19bba4e
commit
10e1bc8ff4
777
docs/superpowers/plans/2026-07-04-peertube-webui-admin-ops.md
Normal file
777
docs/superpowers/plans/2026-07-04-peertube-webui-admin-ops.md
Normal file
|
|
@ -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/<id>.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 `<id>.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 `<id>.request.json` chmod `0600`; result file `<id>.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/<id>.{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=<repo>/common <venv>/bin/python3 -m pytest tests/<f> -q` (venv `/home/reepost/CyberMindStudio/secubox-deb/secubox-deb/.venv/bin/python3`). Bash: `shellcheck sbin/peertubectl` + `bash sbin/peertubectl <verb> --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 <devel@cybermind.fr>
|
||||
# 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 <id>.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/<id>.request.json` → `<id>.result.json`).
|
||||
- Produces: `peertubectl process-ops` (iterates request files, dispatches by `.op`, writes result, removes request); a `_write_result <id> <json>` 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 <id> <json-string> — 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 <file> <filter> — 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 <id>.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 <request-file> <id>`.
|
||||
|
||||
- [ ] **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: <request-file> <id> (called by process-ops)
|
||||
local reqf="${1:-}" id="${2:-}"
|
||||
[ -n "$reqf" ] && [ -n "$id" ] || { err "usage: reset-admin-password <request-file> <id>"; 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
|
||||
<button class="btn btn-danger" onclick="resetAdminPassword()">🔑 Reset admin password</button>
|
||||
```
|
||||
|
||||
- [ ] **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 <devel@cybermind.fr>
|
||||
# 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
|
||||
<div id="ptVersion" style="margin:0.5rem 0;color:var(--text-muted);">version…</div>
|
||||
<button class="btn" id="btnUpgrade" style="display:none;" onclick="upgradePeerTube()">⬆️ Upgrade</button>
|
||||
```
|
||||
```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 <b>v' + v.installed + '</b> · ⬆️ <b>' + v.latest + '</b> available';
|
||||
const b = document.getElementById('btnUpgrade'); b.style.display = ''; b.textContent = '⬆️ Upgrade to ' + v.latest;
|
||||
} else {
|
||||
el.innerHTML = 'PeerTube <b>v' + v.installed + '</b> · ✅ 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 <request-file> <id>`.
|
||||
|
||||
- [ ] **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: <request-file> <id> (called by process-ops)
|
||||
local reqf="${1:-}" id="${2:-}"
|
||||
[ -n "$id" ] || { err "usage: upgrade <request-file> <id>"; 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.
|
||||
|
|
@ -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/<uuid>.request.json` = `{ "op": "reset-password"|"upgrade", "id": "<uuid>", ...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/<id>.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/<id>.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 <request>)" # 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:"<pw>"}` (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": "<optional>"}`. Si absent → génère un mdp fort (secrets). Écrit la requête spool. Renvoie `{"id": "<uuid>"}`.
|
||||
**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-<ts>.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:"<path>"}`.
|
||||
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<latest → upgrade_available true ; égal → false ; latest null → false) avec `pt_api`/GitHub mockés ; `require_admin` refuse un JWT non-admin (403).
|
||||
- **WebUI** : manuel (boutons + polling + toasts).
|
||||
|
||||
## 7. Packaging
|
||||
|
||||
- `sbin/peertubectl` : verbes `reset-admin-password`, `upgrade`, `process-ops` (+ `--dry-run`).
|
||||
- `debian/` : `peertube-ops.path` + `peertube-ops.service` (root, oneshot) ; `rules` les installe (mirroir des unités cookie) ; `postinst` crée `/run/secubox/peertube/ops` (ou tmpfiles.d) + le dir backups ; enable la `.path`.
|
||||
- `api/main.py` : `POST /admin/reset-password`, `GET /admin/op/{id}`, `GET /version`, `POST /upgrade`, dépendance `require_admin`.
|
||||
- `www/peertube/index.html` : bouton reset (Users) + carte version/upgrade (Maintenance) + polling.
|
||||
- `tests/` : peertubectl + API.
|
||||
|
||||
## 8. Séquencement (pour le plan)
|
||||
|
||||
Ordre par risque croissant, chaque étape livrable/testable seule :
|
||||
1. **Mécanisme spool→root partagé** (peertubectl `process-ops` + `.path`/`.service` + postinst dir + `require_admin`).
|
||||
2. **Reset password** (peertubectl verbe + API + WebUI + secret rewrite).
|
||||
3. **Check version** (API `/version` + badge WebUI).
|
||||
4. **Upgrade** (peertubectl `upgrade` + API + WebUI) — **le plus risqué, en dernier** ; live-test = downtime accepté.
|
||||
|
||||
## 9. Risques
|
||||
|
||||
- **Upgrade DB migration** : irréversible sans restore du dump. Mitigation : backup obligatoire + abort-if-backup-fails + pas de swap si migration/health échoue.
|
||||
- **reset-password interactif** : le pipe stdin doit matcher le prompt du CLI v8.2.0 (une ligne). À valider live (le CLI peut redemander confirmation — dans ce cas, piper deux lignes).
|
||||
- **GitHub rate-limit / hors-ligne** : `latest` best-effort caché ; jamais bloquant pour le check.
|
||||
- **Live-test upgrade** : ne pas tester l'upgrade sur l'instance de prod sans accepter le downtime + avoir le backup ; idéalement tester le chemin sur une release identique (no-op) d'abord.
|
||||
|
|
@ -13,14 +13,18 @@ License: Proprietary / ANSSI CSPN candidate
|
|||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Dict
|
||||
|
||||
from fastapi import FastAPI, APIRouter, Depends, Request
|
||||
from fastapi import FastAPI, APIRouter, Depends, Request, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from secubox_core.auth import router as auth_router, require_jwt
|
||||
from secubox_core import user_store
|
||||
from secubox_core.logger import get_logger
|
||||
|
||||
app = FastAPI(title="secubox-peertube", version="1.1.0", root_path="/api/v1/peertube")
|
||||
|
|
@ -123,6 +127,10 @@ class PluginInstall(BaseModel):
|
|||
npm_name: str # e.g., peertube-plugin-auth-ldap
|
||||
|
||||
|
||||
class ResetPasswordBody(BaseModel):
|
||||
password: Optional[str] = None
|
||||
|
||||
|
||||
class VideoImport(BaseModel):
|
||||
"""Import a video from a remote URL (YouTube, Vimeo, direct .mp4, …).
|
||||
|
||||
|
|
@ -295,6 +303,55 @@ def default_channel_id(token: str) -> 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 <id>.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)
|
||||
|
|
|
|||
14
packages/secubox-peertube/debian/peertube-ops.path
Normal file
14
packages/secubox-peertube/debian/peertube-ops.path
Normal file
|
|
@ -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 <id>.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
|
||||
11
packages/secubox-peertube/debian/peertube-ops.service
Normal file
11
packages/secubox-peertube/debian/peertube-ops.service
Normal file
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 -
|
||||
|
|
@ -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 <id> <json-string> — 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 <file> <filter> — 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: <request-file> <id> (called by process-ops)
|
||||
local reqf="${1:-}" id="${2:-}"
|
||||
[ -n "$reqf" ] && [ -n "$id" ] || { err "usage: reset-admin-password <request-file> <id>"; 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: <request-file> <id> (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 <request-file> <id>"; 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 <<EOF
|
||||
peertubectl $VERSION — SecuBox PeerTube (native-LXC) controller
|
||||
|
|
@ -143,6 +252,7 @@ Usage: peertubectl <verb> [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
|
||||
|
|
|
|||
77
packages/secubox-peertube/tests/test_ops_api.py
Normal file
77
packages/secubox-peertube/tests/test_ops_api.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||
# 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"}
|
||||
19
packages/secubox-peertube/tests/test_process_ops.sh
Executable file
19
packages/secubox-peertube/tests/test_process_ops.sh
Executable file
|
|
@ -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"
|
||||
37
packages/secubox-peertube/tests/test_version.py
Normal file
37
packages/secubox-peertube/tests/test_version.py
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||
# 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
|
||||
|
|
@ -463,7 +463,10 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Users</h3>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<h3>Users</h3>
|
||||
<button class="btn btn-danger" onclick="resetAdminPassword()">🔑 Reset admin password</button>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
|
|
@ -619,9 +622,11 @@
|
|||
</div>
|
||||
<div class="card">
|
||||
<h3>Maintenance</h3>
|
||||
<div id="ptVersion" style="margin:0.5rem 0;color:var(--text-muted);">version…</div>
|
||||
<div style="display: flex; gap: 1rem; flex-wrap: wrap;">
|
||||
<button class="btn" onclick="restartPeerTube()">Restart LXC</button>
|
||||
<button class="btn btn-danger" onclick="uninstallPeerTube()">Stop LXC (keep data)</button>
|
||||
<button class="btn" id="btnUpgrade" style="display:none;" onclick="upgradePeerTube()">⬆️ Upgrade</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -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 <b>v' + v.installed + '</b> · ⬆️ <b>' + v.latest + '</b> available';
|
||||
const b = document.getElementById('btnUpgrade'); b.style.display = ''; b.textContent = '⬆️ Upgrade to ' + v.latest;
|
||||
} else {
|
||||
el.innerHTML = 'PeerTube <b>v' + v.installed + '</b> · ✅ 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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user