From 4a6312395a6011759e407cf8addd785f4936bc59 Mon Sep 17 00:00:00 2001 From: TimInTech Date: Mon, 7 Sep 2026 21:00:36 +0200 Subject: [PATCH 01/24] docs: specify secure maintenance web interface --- .../2026-09-07-maintenance-web-design.md | 181 ++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-07-maintenance-web-design.md diff --git a/docs/superpowers/specs/2026-09-07-maintenance-web-design.md b/docs/superpowers/specs/2026-09-07-maintenance-web-design.md new file mode 100644 index 0000000..5307f60 --- /dev/null +++ b/docs/superpowers/specs/2026-09-07-maintenance-web-design.md @@ -0,0 +1,181 @@ +# Sichere Pi-hole-Wartungs-Weboberfläche + +Datum: 2026-09-07 + +## Ziel + +Die vorhandene lokale Pi-hole-Suite erhält eine deutschsprachige Weboberfläche, +über die ein Administrator den Systemzustand prüfen, ein verifiziertes Backup +erstellen und einen kontrollierten Update-Lauf starten kann. Die Oberfläche ist +auf dem produktiven Raspberry Pi unter `https://pi.hole:8443/` und +`https://192.168.178.2:8443/` erreichbar. + +## Plattform und Bestand + +- Zielsystem: Raspberry Pi, Debian 13 (Trixie), systemd, Pi-hole v6 und Unbound. +- Bestehender interner API-Dienst: FastAPI/Uvicorn als Benutzer `pihole-suite` + auf `127.0.0.1:8090`. +- Bestehende Konfiguration: `/etc/pihole-suite/pihole-suite.env`, Modus `0640`, + Eigentümer `root:pihole-suite`. +- Vorhandener Suite-Schlüssel `SUITE_API_KEY` dient als Anmeldepasswort. Er wird + weder in HTML noch in Logs oder API-Antworten ausgegeben. +- Öffentlicher TLS-Endpunkt: Caddy auf TCP-Port `8443`; Uvicorn bleibt auf + Loopback gebunden. + +## Architektur und Vertrauensgrenzen + +```text +Browser --HTTPS :8443--> Caddy --HTTP loopback :8090--> FastAPI (unprivilegiert) + | + | exakt erlaubte + | systemctl-Aufrufe + v + pihole-maintenance-{check,backup,update}.service (root, oneshot) + | + v + root-eigener Python-Runner +``` + +Der Webdienst erhält keine allgemeinen Root-Rechte und führt keine Shellstrings +aus. Er darf über `sudo -n` ausschließlich die drei fest benannten systemd-Units +starten. Die Units rufen einen root-eigenen, nicht durch `pihole-suite` +beschreibbaren Runner mit einer fest verdrahteten Aktion auf. + +Der Runner serialisiert sämtliche Aktionen über +`/run/lock/pihole-maintenance-web.lock`. Damit können Web-, Cron- und manuelle +Läufe nicht gleichzeitig Pi-hole, APT oder die Backupdaten verändern. + +## Authentifizierung und HTTP-Sicherheit + +- Login per `POST /api/session` mit dem vorhandenen `SUITE_API_KEY`. +- Vergleich mit `hmac.compare_digest`. +- Erfolgreicher Login erzeugt eine zufällige serverseitige Sitzung mit 30 Minuten + Inaktivitätszeit und maximal 8 Stunden Gesamtlaufzeit. +- Cookie: `Secure`, `HttpOnly`, `SameSite=Strict`, `Path=/`. +- Zustandsänderungen erfordern `POST`, einen sitzungsgebundenen CSRF-Token sowie + einen erlaubten `Origin` (`https://pi.hole:8443` oder + `https://192.168.178.2:8443`). +- Maximal fünf fehlgeschlagene Anmeldungen je Client innerhalb von fünf Minuten; + danach Sperre bis zum Ende des Zeitfensters. +- Kein CORS. Erlaubte Hosts: `pi.hole`, `192.168.178.2`, `127.0.0.1`, `localhost`. +- Produktiv sind Swagger und OpenAPI deaktiviert. +- Sicherheitsheader: CSP ohne Fremdquellen, `frame-ancestors 'none'`, + `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer` und HSTS. +- Caddy akzeptiert Port 8443 nur aus `192.168.178.0/24` und von Loopback. + +## Aktionen + +### Systemcheck + +Die Aktion `check` verändert das System nicht. Sie ermittelt: + +- Host, Betriebssystem, Kernel, Architektur, Laufzeit, Last, RAM, Root-Dateisystem + und Temperatur; +- Versionen und Aktivzustand von Pi-hole FTL und Unbound; +- Listener auf Port 53 und 5335; +- DNS-Auflösung über Pi-hole (`127.0.0.1:53`) und Unbound + (`127.0.0.1:5335`); +- verfügbaren APT-Aktualisierungszähler nach einem rein lesenden + `apt-get -s upgrade`. + +Ein fehlgeschlagener Pflichtcheck erzeugt den Gesamtstatus `failed` und einen +von null verschiedenen Prozessstatus. Optionale Sensordaten dürfen `warning` +erzeugen. Es werden keine Domains, Clients, MAC-Adressen, sudoers-Inhalte, +Shadow-Daten oder fehlgeschlagenen SSH-Logins ausgegeben. + +### Backup + +Die Aktion `backup` erstellt zunächst ein Staging-Verzeichnis unter +`/var/backups/pihole-suite/.staging-` mit Modus `0700`. Enthalten sind: + +- `/etc/pihole/pihole.toml` und vorhandene relevante Pi-hole-Konfigurationsdateien; +- `/etc/unbound`; +- Drop-ins von `pihole-FTL.service` und `unbound.service`; +- konsistente SQLite-Online-Backups von `gravity.db` und `pihole-FTL.db`, sofern + vorhanden; +- `manifest.json` mit SHA-256-Prüfsummen, Größen, Zeitpunkt, Host und Versionen. + +Jede gesicherte SQLite-Datenbank muss `PRAGMA quick_check` mit Ergebnis `ok` +bestehen. Alle Manifest-Prüfsummen werden nach dem Schreiben erneut geprüft. +Erst danach wird das Staging-Verzeichnis atomar auf +`/var/backups/pihole-suite/-` umbenannt. Jeder Fehler bricht den +Job ab und darf nicht als erfolgreich gemeldet werden. Nach erfolgreicher +Verifikation bleiben die zehn neuesten Backups erhalten. Restore, Download und +Löschen werden nicht über das Web angeboten. + +### Update + +Die Aktion `update` führt zwingend zuerst dieselbe verifizierte Backupfunktion +aus. Nur nach Erfolg folgen in dieser Reihenfolge: + +1. `apt-get update` +2. `DEBIAN_FRONTEND=noninteractive apt-get -y upgrade` +3. `pihole -up` +4. `pihole -g` +5. vollständiger Systemcheck + +`autoremove`, freies Paketmanagement, frei wählbare Flags und automatische +Neustarts des Raspberry Pi sind ausgeschlossen. Ein Fehler beendet die Kette; +der Jobstatus bleibt `failed`. Falls `/var/run/reboot-required` existiert, zeigt +das Ergebnis ausschließlich `reboot_required: true`; ein Reboot wird nie +automatisch ausgelöst. + +## Jobzustand und Webdarstellung + +Der Runner schreibt Zustände atomar als JSON nach +`/var/lib/pihole-suite/jobs/current.json` und zusätzlich als Historie nach +`/var/lib/pihole-suite/jobs/.json`. Dateien erhalten +`root:pihole-suite` und Modus `0640`. + +Das Schema enthält mindestens `schema_version`, `job_id`, `action`, `state`, +`started_at`, `finished_at`, `steps`, `result` und `error`. Zulässige Zustände +sind `running`, `succeeded` und `failed`. Befehlsausgaben werden auf eine feste +Länge begrenzt und von Steuerzeichen bereinigt. + +Die Weboberfläche zeigt drei Karten für Systemcheck, Backup und Update, den +aktuellen Jobstatus, die einzelnen Schritte, den Backup-Pfad, den +Reboot-Hinweis und klare Fehlertexte. Backup und Update verlangen eine sichtbare +Bestätigung; Update zusätzlich die exakte Eingabe `UPDATE`. + +## Dateieigentum und systemd + +- Anwendungscode und virtuelle Umgebung: `root:root`, nicht für + `pihole-suite` beschreibbar. +- Veränderliche Suite-Daten: `root:pihole-suite`, nur die expliziten + Zustandsverzeichnisse sind für den Dienst lesbar beziehungsweise für + Sessiondaten schreibbar. +- Webdienst behält `NoNewPrivileges=true`, leere `CapabilityBoundingSet`, + `ProtectSystem=strict`, `ProtectHome=true`, `PrivateTmp=true` und die + bestehenden Kernel-/Namespace-Schutzoptionen. +- Root-Jobs sind kurzlebige Oneshots, verwenden absolute Pfade, einen festen + `PATH`, `UMask=0077`, Zeitlimits und keine benutzerkontrollierten Argumente. + +## Installation und Rollback + +Ein idempotenter Installer installiert App, Runner, Units, sudoers-Regel und +Caddy-Konfiguration. Er validiert Python, sudoers, systemd-Units und Caddy vor +dem Aktivieren. Vor dem Austausch werden die zuvor installierten Artefakte in +einem zeitgestempelten Root-Verzeichnis unter +`/var/backups/pihole-suite-deploy/` gesichert. + +Rollback stellt genau diese Artefakte wieder her, lädt systemd/Caddy neu und +prüft anschließend Pi-hole, Unbound sowie DNS. Backupdateien des Benutzers +werden durch Rollback nicht gelöscht. + +## Akzeptanzkriterien auf dem produktiven Pi + +1. Unauthentisierte Wartungs-API-Aufrufe liefern `401`; falscher Origin oder + CSRF-Token liefert `403`. +2. HTTPS-Port 8443 ist aus dem LAN erreichbar, Port 8090 ausschließlich lokal. +3. Der FastAPI-Prozess läuft nicht als Root und kann keine beliebigen + `sudo`-Befehle ausführen. +4. Ein über die HTTPS-Web-API gestarteter Systemcheck endet `succeeded` und + bestätigt Pi-hole, Unbound sowie beide DNS-Pfade. +5. Ein über die HTTPS-Web-API gestartetes Backup endet `succeeded`; Manifest, + Prüfsummen und vorhandene SQLite-Backups werden erneut unabhängig geprüft. +6. Ein über die HTTPS-Web-API gestartetes Update endet `succeeded`; es besitzt + ein verifiziertes Vorab-Backup und der abschließende Systemcheck ist grün. +7. Gleichzeitige zweite Jobstarts werden mit HTTP 409 abgewiesen. +8. Nach allen Tests sind `pihole-FTL`, `unbound`, `pihole-suite` und `caddy` + aktiv; DNS über Port 53 und 5335 funktioniert. + From d265cbdc978b482e9e6ccb7080ac7d2bb120c378 Mon Sep 17 00:00:00 2001 From: TimInTech Date: Mon, 7 Sep 2026 21:09:17 +0200 Subject: [PATCH 02/24] docs: plan maintenance web implementation --- .../plans/2026-09-07-maintenance-web.md | 225 ++++++++++++++++++ 1 file changed, 225 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-07-maintenance-web.md diff --git a/docs/superpowers/plans/2026-09-07-maintenance-web.md b/docs/superpowers/plans/2026-09-07-maintenance-web.md new file mode 100644 index 0000000..7252df2 --- /dev/null +++ b/docs/superpowers/plans/2026-09-07-maintenance-web.md @@ -0,0 +1,225 @@ +# Secure Pi-hole Maintenance Web Interface 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:** Build and deploy an authenticated HTTPS maintenance panel that safely runs system check, verified backup, and update jobs on the production Pi-hole. + +**Architecture:** Extend the loopback-only FastAPI suite with session-authenticated maintenance routes and a static UI. The web process may start only three fixed root systemd oneshots; a root-owned Python runner serializes jobs and writes atomic JSON state. Caddy exposes the app on HTTPS port 8443 to the local management subnet. + +**Tech Stack:** Python 3.11+ standard library, FastAPI, Uvicorn, pytest, Bash 5+, systemd, sudoers, Caddy. + +**Spec:** docs/superpowers/specs/2026-09-07-maintenance-web-design.md + +## Global Constraints + +- Public URLs are exactly https://pi.hole:8443/ and https://192.168.178.2:8443/; Uvicorn remains on 127.0.0.1:8090. +- The FastAPI service runs as pihole-suite, never as root, and accepts no arbitrary command, path, package, URL, or shell input. +- Allowed root actions are exactly check, backup, and update; they are serialized with /run/lock/pihole-maintenance-web.lock. +- Update order is verified backup, apt-get update, noninteractive apt-get -y upgrade, pihole -up, pihole -g, then full check; no autoremove and no reboot. +- Backups live below /var/backups/pihole-suite, use SQLite online backup, manifest SHA-256 verification, atomic publication, mode 0700, and retention of ten verified backups. +- Job JSON lives below /var/lib/pihole-suite/jobs, is atomic, root:pihole-suite mode 0640, and never contains domains, clients, MACs, sudoers, shadow data, failed-login data, API keys, cookies, or CSRF tokens. +- Browser authentication uses the existing SUITE_API_KEY only as a submitted password; successful login replaces it with a server-side session cookie using Secure, HttpOnly, and SameSite=Strict. +- All mutating API calls require an authenticated session, an exact allowed Origin, and a matching CSRF token. +- Production OpenAPI/Swagger is disabled and CORS is not enabled. +- Every Bash file added or changed uses set -euo pipefail. +- Production deployment is not complete until all three actions have been started through the HTTPS API and independently verified on pi@192.168.178.2. + +--- + +### Task 1: Privileged maintenance runner + +**Files:** +- Create: maintenance_runner.py +- Create: tests/test_maintenance_runner.py + +**Interfaces:** +- Consumes: one fixed action from systemd: check, backup, or update. +- Produces: main(argv: Sequence[str] | None = None) -> int and run_job(action: Action, context: RunnerContext) -> dict[str, Any]. +- Produces for Task 2: /var/lib/pihole-suite/jobs/current.json with state running, succeeded, or failed. + +- [ ] **Step 1: Write failing runner contract tests** + +Use temporary etc, state, backup, and lock directories plus a recording command executor. Tests must cover unknown action rejection, failed DNS as failed status, corrupt SQLite preventing publication, and failed required backup preventing apt. + +~~~python +def test_rejects_unknown_action_without_commands(tmp_path): + context, commands = make_context(tmp_path) + with pytest.raises(ValueError, match="unsupported action"): + run_job("shell", context) + assert commands.calls == [] + +def test_update_stops_before_apt_when_backup_fails(tmp_path): + context, commands = make_context(tmp_path, corrupt_database=True) + result = run_job("update", context) + assert result["state"] == "failed" + assert ["/usr/bin/apt-get", "update"] not in commands.calls +~~~ + +- [ ] **Step 2: Run pytest -q tests/test_maintenance_runner.py and verify collection fails because the module is absent.** + +- [ ] **Step 3: Implement immutable paths, injectable command execution, an exact action enum, bounded sanitized output, UTC job IDs, and atomic JSON writes using os.replace. Reject symlink state and backup roots.** + +- [ ] **Step 4: Add failing tests for nonblocking flock, running then terminal transitions, state mode 0640, backup mode 0700, failed staging cleanup, ten-backup retention, SHA-256 verification, and exact update command order.** + +~~~python +def test_second_job_reports_busy(tmp_path): + context, _ = make_context(tmp_path) + with held_lock(context.lock_file): + result = run_job("check", context) + assert result["error"]["code"] == "busy" + +def test_update_command_order(tmp_path): + context, commands = make_context(tmp_path) + assert run_job("update", context)["state"] == "succeeded" + assert update_calls(commands.calls) == [ + ["/usr/bin/apt-get", "update"], + ["/usr/bin/apt-get", "-y", "upgrade"], + [context.pihole_bin, "-up"], + [context.pihole_bin, "-g"], + ] +~~~ + +- [ ] **Step 5: Run the new focused tests and verify they fail for the missing behavior.** + +- [ ] **Step 6: Implement system checks, SQLite online backup plus quick_check, config copying without symlink traversal, manifest generation and re-verification, atomic backup publication, retention, update sequencing, and final check.** + +- [ ] **Step 7: Run pytest -q tests/test_maintenance_runner.py and require zero failures and warnings.** + +- [ ] **Step 8: Run Python compilation and git diff --check, then commit as feat: add privileged maintenance job runner.** + +--- + +### Task 2: Authenticated maintenance API and browser UI + +**Files:** +- Create: maintenance_web.py +- Create: web/maintenance.html +- Create: web/maintenance.css +- Create: web/maintenance.js +- Create: tests/test_maintenance_web.py +- Modify: start_suite.py + +**Interfaces:** +- Consumes Task 1 state schema and exact actions. +- Produces create_maintenance_router(settings: MaintenanceSettings) -> APIRouter and the UI at /. +- Dispatches only /usr/bin/sudo -n /usr/bin/systemctl start --no-block pihole-maintenance-ACTION.service. + +- [ ] **Step 1: Write failing TestClient tests for unauthenticated 401, login secrecy and cookie flags, missing or wrong CSRF 403, wrong Origin 403, and login rate limiting.** + +~~~python +def test_job_status_requires_session(client): + assert client.get("/api/maintenance/status").status_code == 401 + +def test_update_rejects_missing_csrf(client, authenticated_cookie): + response = client.post( + "/api/maintenance/update", + cookies=authenticated_cookie, + headers={"Origin": "https://pi.hole:8443"}, + ) + assert response.status_code == 403 +~~~ + +- [ ] **Step 2: Run pytest -q tests/test_maintenance_web.py and verify collection fails because the module is absent.** + +- [ ] **Step 3: Implement constant-time key comparison, random server-side sessions, 30-minute idle and 8-hour absolute expiry, exact Host/Origin allowlists, cookie flags, rate limiting, security headers, and disabled production docs.** + +- [ ] **Step 4: Add failing tests for the fixed systemctl argv, running-job 409, unknown-route 404, malformed or oversized state rejection, logout, and response headers.** + +~~~python +def test_check_dispatches_only_fixed_unit(client, session, dispatcher): + response = post_action(client, session, "check") + assert response.status_code == 202 + assert dispatcher.calls == [[ + "/usr/bin/sudo", "-n", "/usr/bin/systemctl", "start", "--no-block", + "pihole-maintenance-check.service", + ]] + +def test_unknown_action_is_not_routable(client, session): + assert post_action(client, session, "../../bin/sh").status_code == 404 +~~~ + +- [ ] **Step 5: Run the dispatch tests and verify they fail for missing routes and dispatch behavior.** + +- [ ] **Step 6: Implement three explicit POST routes, bounded schema-checked state reading, fixed dispatch, and no raw journal output.** + +- [ ] **Step 7: Build an accessible German login/dashboard UI. Backup requires confirmation; update requires exact UPDATE text. JavaScript keeps CSRF only in memory, uses textContent for server values, and polls every two seconds only while running.** + +- [ ] **Step 8: Add tests for UI controls, MIME types, no API-key output, docs 404, and CSP/HSTS headers. Run the entire web suite until clean.** + +- [ ] **Step 9: Run both Python suites, compile start_suite.py and maintenance_web.py, run git diff --check, then commit as feat: add authenticated maintenance web panel.** + +--- + +### Task 3: Hardened system integration and installer + +**Files:** +- Create: deploy/pihole-maintenance-check.service +- Create: deploy/pihole-maintenance-backup.service +- Create: deploy/pihole-maintenance-update.service +- Create: deploy/pihole-maintenance-web.sudoers +- Create: deploy/Caddyfile.maintenance +- Create: scripts/install_maintenance_web.sh +- Create: scripts/rollback_maintenance_web.sh +- Create: tests/test_install_maintenance_web.sh +- Modify: install.sh + +**Interfaces:** +- Consumes /usr/local/libexec/pihole-maintenance-runner ACTION. +- Produces HTTPS :8443, three root oneshots, exact sudo authorization, root-owned app files, deploy backup, and rollback. + +- [ ] **Step 1: Write a failing staged-install shell test that uses a temporary destination root and checks all paths, file modes, root-owned-code intent, sudoers syntax, and systemd units.** + +~~~bash +test "$(stat -c %a "$DESTDIR/usr/local/libexec/pihole-maintenance-runner")" = 755 +test "$(stat -c %a "$DESTDIR/var/lib/pihole-suite/app/start_suite.py")" = 644 +grep -F "/usr/bin/systemctl start --no-block pihole-maintenance-update.service" "$DESTDIR/etc/sudoers.d/pihole-maintenance-web" +~~~ + +- [ ] **Step 2: Run bash tests/test_install_maintenance_web.sh and verify RED because artifacts are absent.** + +- [ ] **Step 3: Create three fixed units with UMask 0077, fixed PATH, 15-minute check/backup timeout, 120-minute update timeout, and no user-controlled arguments. Create exact sudoers commands. Configure Caddy :8443 with tls internal, LAN/loopback restriction, and loopback proxy to 8090.** + +- [ ] **Step 4: Implement an idempotent set -euo pipefail installer. Live mode requires root and Caddy, backs up replaced artifacts, installs code as root:root, creates explicit state ownership, validates sudoers/units/Caddy, reloads services, and checks loopback health. Staged mode touches no services.** + +- [ ] **Step 5: Implement rollback constrained to a resolved nonsymlink directory below /var/backups/pihole-suite-deploy. Restore only manifest-listed artifacts, validate, reload, and verify DNS; never remove user backups.** + +- [ ] **Step 6: Add main-installer flags --with-maintenance-web and --skip-maintenance-web. Default stays skipped; enabled mode delegates after the Python suite succeeds.** + +- [ ] **Step 7: Run the staged installer test, Bash syntax checks, both Python suites, and git diff --check; require zero failures.** + +- [ ] **Step 8: Commit as feat: install hardened maintenance web services.** + +--- + +### Task 4: Documentation, review, and production acceptance + +**Files:** +- Modify: README.de.md +- Modify: README.md +- Modify: docs/CONSOLE_MENU.md +- Create: docs/MAINTENANCE_WEB.md +- Modify only after live verification: /home/z2-ubuntu/Wissensbasis/10_Projekte/Pi-hole-Unbound-PiAlert-Setup/Pi-hole Unbound PiAlert Setup Handover 2026-09-01.md + +**Interfaces:** +- Consumes Tasks 1-3 and produces operator guidance plus authoritative live evidence. + +- [ ] **Step 1: Document exact installation, URLs, local key retrieval without shell-history exposure, action semantics, job state, backup contents, certificate trust, security boundary, verification, and rollback. Include no real secret or private raw log.** + +- [ ] **Step 2: Run pytest -q, repo selftest, staged install test, all Bash syntax checks, ShellCheck when available, and git diff --check.** + +- [ ] **Step 3: Complete an independent whole-branch security and spec review. Fix every Critical or Important issue and rerun covering tests.** + +- [ ] **Step 4: Over verified SSH, capture and inspect a root-owned pre-deployment backup of current suite app, unit, Caddy/sudoers artifacts if present, and service state.** + +- [ ] **Step 5: Transfer only reviewed tracked files to a fresh /tmp directory, verify their SHA-256 manifest, install Caddy from its signed official repository if absent, and run the maintenance web installer.** + +- [ ] **Step 6: Through HTTPS, prove 401 without session, 403 for wrong Origin or CSRF, 404 for unknown action, and 409 for a simultaneous second job. Keep the Suite key and cookie jar inside the remote shell and remove temporary credentials.** + +- [ ] **Step 7: Through HTTPS, start check and require succeeded. Start backup and independently verify manifest, hashes, SQLite quick_check, ownership, and mode. Start update and require succeeded, a verified pre-update backup, and a passing final check.** + +- [ ] **Step 8: Require pihole-FTL, unbound, pihole-suite, and caddy active; successful DNS on ports 53 and 5335; loopback-only 8090; LAN HTTPS 8443; non-root FastAPI UID; and denial of an unlisted sudo command.** + +- [ ] **Step 9: Append the final architecture, verified production state, exact rollback point, and genuine blockers to the existing Vault handover. Run diff checks, secret scan, and the canonical Vault sync helper.** + +- [ ] **Step 10: Commit documentation as docs: document maintenance web operations and record exact acceptance evidence. Mark complete only when every spec criterion has current direct evidence.** + From 8a78fb340e76982e222df68d7a12c959a311f2d8 Mon Sep 17 00:00:00 2001 From: TimInTech Date: Mon, 7 Sep 2026 21:20:48 +0200 Subject: [PATCH 03/24] feat: add privileged maintenance job runner --- maintenance_runner.py | 438 +++++++++++++++++++++++++++++++ tests/test_maintenance_runner.py | 329 +++++++++++++++++++++++ 2 files changed, 767 insertions(+) create mode 100644 maintenance_runner.py create mode 100644 tests/test_maintenance_runner.py diff --git a/maintenance_runner.py b/maintenance_runner.py new file mode 100644 index 0000000..a46f1f4 --- /dev/null +++ b/maintenance_runner.py @@ -0,0 +1,438 @@ +#!/usr/bin/env python3 +"""Root-only runner for the fixed Pi-hole maintenance jobs.""" + +from __future__ import annotations + +import datetime as dt +import enum +import fcntl +import grp +import hashlib +import json +import os +import re +import shutil +import sqlite3 +import subprocess +import sys +import tempfile +import uuid +import platform +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable, Sequence + + +MAX_OUTPUT_LENGTH = 2_000 +STATE_FILE_MODE = 0o640 +BACKUP_DIR_MODE = 0o700 +ALLOWED_ACTIONS = ("check", "backup", "update") + + +class Action(str, enum.Enum): + CHECK = "check" + BACKUP = "backup" + UPDATE = "update" + + +@dataclass(frozen=True) +class CommandResult: + returncode: int + stdout: str = "" + stderr: str = "" + + +CommandExecutor = Callable[[list[str]], CommandResult] + + +@dataclass(frozen=True) +class RunnerContext: + etc_root: Path = Path("/etc") + state_dir: Path = Path("/var/lib/pihole-suite/jobs") + backup_root: Path = Path("/var/backups/pihole-suite") + lock_file: Path = Path("/run/lock/pihole-maintenance-web.lock") + command_executor: CommandExecutor | None = None + pihole_bin: str = "/usr/local/bin/pihole" + state_gid: int | None = None + reboot_required_path: Path = Path("/var/run/reboot-required") + + def __post_init__(self) -> None: + for field_name in ("etc_root", "state_dir", "backup_root", "lock_file", "reboot_required_path"): + object.__setattr__(self, field_name, Path(getattr(self, field_name))) + if self.command_executor is None: + object.__setattr__(self, "command_executor", _run_command) + + +def _run_command(argv: list[str]) -> CommandResult: + environment = {"PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"} + if argv == ["/usr/bin/apt-get", "-y", "upgrade"]: + environment["DEBIAN_FRONTEND"] = "noninteractive" + try: + completed = subprocess.run( + argv, + check=False, + capture_output=True, + text=True, + timeout=300, + env=environment, + ) + except (OSError, subprocess.TimeoutExpired) as error: + return CommandResult(returncode=1, stderr=str(error)) + return CommandResult(completed.returncode, completed.stdout, completed.stderr) + + +def _sanitize_output(value: str) -> str: + clean = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]", "", value) + return clean[:MAX_OUTPUT_LENGTH] + + +def _utc_now() -> str: + return dt.datetime.now(tz=dt.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def _job_id() -> str: + timestamp = dt.datetime.now(tz=dt.timezone.utc).strftime("%Y%m%dT%H%M%S%fZ") + return f"{timestamp}-{uuid.uuid4().hex}" + + +def _as_action(action: Action | str) -> Action: + try: + return Action(action) + except ValueError as error: + raise ValueError(f"unsupported action: {action}") from error + + +def _reject_symlink(path: Path, label: str) -> None: + if path.is_symlink(): + raise RuntimeError(f"{label} must not be a symlink") + + +def _prepare_directory(path: Path, mode: int, label: str) -> None: + _reject_symlink(path, label) + path.mkdir(mode=mode, parents=True, exist_ok=True) + _reject_symlink(path, label) + if not path.is_dir(): + raise RuntimeError(f"{label} is not a directory") + + +def _atomic_json_write(path: Path, payload: dict[str, Any], context: RunnerContext) -> None: + _prepare_directory(path.parent, 0o750, "state directory") + descriptor, temporary_name = tempfile.mkstemp(prefix=".tmp-", dir=path.parent) + temporary_path = Path(temporary_name) + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + json.dump(payload, handle, sort_keys=True, separators=(",", ":")) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) + os.chmod(temporary_path, STATE_FILE_MODE) + state_gid = context.state_gid + if state_gid is None: + state_gid = grp.getgrnam("pihole-suite").gr_gid + os.chown(temporary_path, -1, state_gid) + os.replace(temporary_path, path) + finally: + temporary_path.unlink(missing_ok=True) + + +def _write_state(payload: dict[str, Any], context: RunnerContext, *, history: bool = False) -> None: + _atomic_json_write(context.state_dir / "current.json", payload, context) + if history: + _atomic_json_write(context.state_dir / f"{payload['job_id']}.json", payload, context) + + +def _command_step(name: str, argv: list[str], context: RunnerContext, *, required: bool = True) -> dict[str, Any]: + assert context.command_executor is not None + result = context.command_executor(argv) + return { + "name": name, + "required": required, + "ok": result.returncode == 0, + "output": _sanitize_output(result.stdout), + "error_output": _sanitize_output(result.stderr), + } + + +def _run_check(context: RunnerContext) -> tuple[list[dict[str, Any]], str | None]: + steps = [ + _command_step("pihole_ftl", ["/usr/bin/systemctl", "is-active", "pihole-FTL"], context), + _command_step("unbound", ["/usr/bin/systemctl", "is-active", "unbound"], context), + _command_step("pihole_version", [context.pihole_bin, "-v"], context), + _command_step("unbound_version", ["/usr/sbin/unbound", "-V"], context), + _command_step("unbound_config", ["/usr/sbin/unbound-checkconf"], context), + _command_step("listeners", ["/usr/bin/ss", "-ltnu"], context), + _command_step( + "dns_pihole", + ["/usr/bin/dig", "+short", "@127.0.0.1", "example.com", "+time=3", "+tries=1"], + context, + ), + _command_step( + "dns_unbound", + ["/usr/bin/dig", "+short", "-p", "5335", "@127.0.0.1", "example.com", "+time=3", "+tries=1"], + context, + ), + _command_step("apt_simulation", ["/usr/bin/apt-get", "-s", "upgrade"], context), + ] + for step in steps: + if step["name"] in {"dns_pihole", "dns_unbound"} and step["ok"] and not step["output"].strip(): + step["ok"] = False + step["error_output"] = "DNS query returned no answer" + listener_step = next(step for step in steps if step["name"] == "listeners") + if listener_step["ok"] and not all( + re.search(rf":{port}(?:\s|$)", listener_step["output"]) for port in (53, 5335) + ): + listener_step["ok"] = False + listener_step["error_output"] = "required DNS listener is missing" + if any(not step["ok"] and step["required"] for step in steps): + return steps, "check_failed" + return steps, None + + +def _sqlite_backup(source: Path, destination: Path) -> None: + destination.parent.mkdir(mode=BACKUP_DIR_MODE, parents=True, exist_ok=True) + with sqlite3.connect(source) as source_connection, sqlite3.connect(destination) as destination_connection: + source_connection.backup(destination_connection) + quick_check = destination_connection.execute("PRAGMA quick_check").fetchone() + if quick_check != ("ok",): + raise RuntimeError(f"SQLite quick_check failed for {source.name}") + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _manifest(staging: Path, job_id: str) -> Path: + entries = [] + for candidate in sorted(staging.rglob("*")): + if candidate.is_file() and candidate.name != "manifest.json": + entries.append({ + "path": candidate.relative_to(staging).as_posix(), + "sha256": _sha256(candidate), + "size": candidate.stat().st_size, + }) + manifest_path = staging / "manifest.json" + manifest_path.write_text( + json.dumps( + { + "job_id": job_id, + "created_at": _utc_now(), + "host": platform.node(), + "versions": {"runner": "1", "python": platform.python_version()}, + "files": entries, + }, + sort_keys=True, + ) + + "\n", + encoding="utf-8", + ) + return manifest_path + + +def _verify_manifest(staging: Path) -> None: + manifest = json.loads((staging / "manifest.json").read_text(encoding="utf-8")) + for entry in manifest["files"]: + candidate = staging / entry["path"] + if not candidate.is_file() or candidate.stat().st_size != entry["size"] or _sha256(candidate) != entry["sha256"]: + raise RuntimeError(f"manifest verification failed for {entry['path']}") + + +def _copy_regular_file(source: Path, destination: Path) -> None: + if source.is_symlink(): + return + destination.parent.mkdir(mode=BACKUP_DIR_MODE, parents=True, exist_ok=True) + shutil.copyfile(source, destination) + os.chmod(destination, 0o600) + + +def _copy_directory_without_symlinks(source: Path, destination: Path) -> None: + if source.is_symlink() or not source.is_dir(): + return + for root, directories, files in os.walk(source, followlinks=False): + root_path = Path(root) + directories[:] = [name for name in directories if not (root_path / name).is_symlink()] + relative_path = root_path.relative_to(source) + target_directory = destination / relative_path + target_directory.mkdir(mode=BACKUP_DIR_MODE, parents=True, exist_ok=True) + os.chmod(target_directory, BACKUP_DIR_MODE) + for name in files: + _copy_regular_file(root_path / name, target_directory / name) + + +def _copy_configuration(context: RunnerContext, staging: Path) -> None: + pihole_dir = context.etc_root / "pihole" + pihole_toml = pihole_dir / "pihole.toml" + unbound_dir = context.etc_root / "unbound" + if not pihole_toml.is_file() or pihole_toml.is_symlink(): + raise RuntimeError("required Pi-hole configuration is missing or unsafe") + if not unbound_dir.is_dir() or unbound_dir.is_symlink(): + raise RuntimeError("required Unbound configuration is missing or unsafe") + for name in ("pihole.toml", "custom.list", "dhcp.leases", "setupVars.conf"): + source = pihole_dir / name + if source.exists() and not source.is_symlink(): + _copy_regular_file(source, staging / "pihole" / name) + _copy_directory_without_symlinks(pihole_dir / "dnsmasq.d", staging / "pihole" / "dnsmasq.d") + _copy_directory_without_symlinks(unbound_dir, staging / "unbound") + systemd_dir = context.etc_root / "systemd" / "system" + for name in ("pihole-FTL.service.d", "unbound.service.d"): + _copy_directory_without_symlinks(systemd_dir / name, staging / "systemd" / name) + + +def _retain_newest_backups(backup_root: Path) -> None: + verified_backups: list[Path] = [] + for candidate in backup_root.iterdir(): + if candidate.name.startswith(".staging-") or candidate.is_symlink() or not candidate.is_dir(): + continue + try: + _verify_manifest(candidate) + except (OSError, ValueError, KeyError, TypeError, RuntimeError): + continue + verified_backups.append(candidate) + for obsolete in sorted(verified_backups, key=lambda path: path.name, reverse=True)[10:]: + shutil.rmtree(obsolete) + + +def _system_summary() -> dict[str, Any]: + try: + uptime_seconds = int(float(Path("/proc/uptime").read_text(encoding="utf-8").split()[0])) + except (OSError, ValueError, IndexError): + uptime_seconds = None + try: + memory_total = os.sysconf("SC_PAGE_SIZE") * os.sysconf("SC_PHYS_PAGES") + memory_available = os.sysconf("SC_PAGE_SIZE") * os.sysconf("SC_AVPHYS_PAGES") + except (ValueError, OSError, AttributeError): + memory_total = None + memory_available = None + filesystem = shutil.disk_usage("/") + summary: dict[str, Any] = { + "host": platform.node(), + "operating_system": platform.platform(), + "kernel": platform.release(), + "architecture": platform.machine(), + "uptime_seconds": uptime_seconds, + "load_average": list(os.getloadavg()), + "memory": {"total_bytes": memory_total, "available_bytes": memory_available}, + "root_filesystem": {"total_bytes": filesystem.total, "used_bytes": filesystem.used, "free_bytes": filesystem.free}, + } + thermal_path = Path("/sys/class/thermal/thermal_zone0/temp") + try: + summary["temperature_celsius"] = int(thermal_path.read_text(encoding="utf-8").strip()) / 1000 + except (OSError, ValueError): + summary["temperature_celsius"] = None + return summary + + +def _run_backup(context: RunnerContext, job_id: str) -> tuple[list[dict[str, Any]], str | None, str | None]: + staging = context.backup_root / f".staging-{job_id}" + try: + _prepare_directory(context.backup_root, BACKUP_DIR_MODE, "backup root") + _reject_symlink(staging, "backup staging directory") + staging.mkdir(mode=BACKUP_DIR_MODE) + os.chmod(staging, BACKUP_DIR_MODE) + pihole_dir = context.etc_root / "pihole" + _copy_configuration(context, staging) + for database_name in ("gravity.db", "pihole-FTL.db"): + source = pihole_dir / database_name + if source.exists(): + if source.is_symlink(): + raise RuntimeError(f"SQLite source must not be a symlink: {source.name}") + _sqlite_backup(source, staging / "pihole" / database_name) + _manifest(staging, job_id) + _verify_manifest(staging) + published = context.backup_root / f"{_utc_now().replace(':', '').replace('-', '')}-{job_id}" + os.replace(staging, published) + _retain_newest_backups(context.backup_root) + return [{"name": "backup", "required": True, "ok": True}], None, str(published) + except (OSError, sqlite3.Error, ValueError, RuntimeError) as error: + shutil.rmtree(staging, ignore_errors=True) + return [{"name": "backup", "required": True, "ok": False, "error_output": _sanitize_output(str(error))}], "backup_failed", None + + +def _run_update(context: RunnerContext, job_id: str) -> tuple[list[dict[str, Any]], str | None, str | None]: + steps, error, backup_path = _run_backup(context, job_id) + if error: + return steps, error, backup_path + for name, argv in ( + ("apt_update", ["/usr/bin/apt-get", "update"]), + ("apt_upgrade", ["/usr/bin/apt-get", "-y", "upgrade"]), + ("pihole_update", [context.pihole_bin, "-up"]), + ("pihole_gravity", [context.pihole_bin, "-g"]), + ): + step = _command_step(name, argv, context) + steps.append(step) + if not step["ok"]: + return steps, "update_failed", backup_path + check_steps, check_error = _run_check(context) + steps.extend(check_steps) + return steps, check_error, backup_path + + +def run_job(action: Action | str, context: RunnerContext) -> dict[str, Any]: + selected_action = _as_action(action) + _prepare_directory(context.lock_file.parent, 0o750, "lock directory") + _reject_symlink(context.lock_file, "lock file") + with context.lock_file.open("a+", encoding="utf-8") as lock_handle: + try: + fcntl.flock(lock_handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + return {"state": "failed", "error": {"code": "busy", "message": "another maintenance job is running"}} + job_id = _job_id() + payload: dict[str, Any] = { + "schema_version": 1, + "job_id": job_id, + "action": selected_action.value, + "state": "running", + "started_at": _utc_now(), + "finished_at": None, + "steps": [], + "result": {}, + "error": None, + } + try: + _write_state(payload, context) + if selected_action is Action.CHECK: + steps, error = _run_check(context) + backup_path = None + elif selected_action is Action.BACKUP: + steps, error, backup_path = _run_backup(context, job_id) + else: + steps, error, backup_path = _run_update(context, job_id) + payload["steps"] = steps + payload["finished_at"] = _utc_now() + if error: + payload["state"] = "failed" + payload["error"] = {"code": error, "message": error.replace("_", " ")} + else: + payload["state"] = "succeeded" + result: dict[str, Any] = {} + if selected_action in {Action.CHECK, Action.UPDATE}: + result["system"] = _system_summary() + if backup_path: + result["backup_path"] = backup_path + if selected_action is Action.UPDATE: + result["reboot_required"] = context.reboot_required_path.exists() + payload["result"] = result + except (OSError, RuntimeError) as error: + payload["state"] = "failed" + payload["finished_at"] = _utc_now() + payload["error"] = {"code": "runner_failed", "message": _sanitize_output(str(error))} + _write_state(payload, context, history=True) + return payload + + +def main(argv: Sequence[str] | None = None) -> int: + arguments = list(sys.argv[1:] if argv is None else argv) + if len(arguments) != 1: + return 2 + try: + result = run_job(arguments[0], RunnerContext()) + except ValueError: + return 2 + return 0 if result["state"] == "succeeded" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_maintenance_runner.py b/tests/test_maintenance_runner.py new file mode 100644 index 0000000..b7a5144 --- /dev/null +++ b/tests/test_maintenance_runner.py @@ -0,0 +1,329 @@ +import sqlite3 +import sys +from pathlib import Path +import fcntl +import hashlib +import json +import os +import shutil +from contextlib import contextmanager + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from maintenance_runner import CommandResult, RunnerContext, run_job + + +class RecordingExecutor: + def __init__(self, *, fail_dns: bool = False, dns_output: str = "203.0.113.7\n", listener_output: str | None = None): + self.calls: list[list[str]] = [] + self.fail_dns = fail_dns + self.dns_output = dns_output + self.listener_output = listener_output or "udp UNCONN 0 0 127.0.0.1:53 0.0.0.0:*\nudp UNCONN 0 0 127.0.0.1:5335 0.0.0.0:*\n" + + def __call__(self, argv: list[str]) -> CommandResult: + self.calls.append(argv) + if self.fail_dns and argv[0] == "/usr/bin/dig": + return CommandResult(returncode=1, stdout="", stderr="DNS unavailable") + if argv[0] == "/usr/bin/dig": + return CommandResult(returncode=0, stdout=self.dns_output, stderr="") + if argv == ["/usr/bin/ss", "-ltnu"]: + return CommandResult(returncode=0, stdout=self.listener_output, stderr="") + return CommandResult(returncode=0, stdout="ok\n", stderr="") + + +@contextmanager +def held_lock(lock_file: Path): + lock_file.parent.mkdir(parents=True, exist_ok=True) + with lock_file.open("a+", encoding="utf-8") as handle: + fcntl.flock(handle.fileno(), fcntl.LOCK_EX) + yield + + +def published_backups(context: RunnerContext) -> list[Path]: + return sorted(path for path in context.backup_root.iterdir() if not path.name.startswith(".staging-")) + + +def make_context(tmp_path: Path, *, corrupt_database: bool = False, fail_dns: bool = False): + etc_root = tmp_path / "etc" + pihole_dir = etc_root / "pihole" + unbound_dir = etc_root / "unbound" + pihole_dir.mkdir(parents=True) + unbound_dir.mkdir() + (pihole_dir / "pihole.toml").write_text("upstreams = [\"127.0.0.1#5335\"]\n") + (unbound_dir / "unbound.conf").write_text("server:\n") + + for database_name in ("gravity.db", "pihole-FTL.db"): + database_path = pihole_dir / database_name + if corrupt_database: + database_path.write_bytes(b"not a sqlite database") + else: + with sqlite3.connect(database_path) as connection: + connection.execute("CREATE TABLE records (id INTEGER PRIMARY KEY)") + + commands = RecordingExecutor(fail_dns=fail_dns) + context = RunnerContext( + etc_root=etc_root, + state_dir=tmp_path / "state" / "jobs", + backup_root=tmp_path / "backups", + lock_file=tmp_path / "lock" / "maintenance.lock", + command_executor=commands, + pihole_bin="/usr/local/bin/pihole", + state_gid=os.getgid(), + ) + return context, commands + + +def test_rejects_unknown_action_without_commands(tmp_path): + context, commands = make_context(tmp_path) + + with pytest.raises(ValueError, match="unsupported action"): + run_job("shell", context) + + assert commands.calls == [] + + +def test_check_is_failed_when_a_required_dns_query_fails(tmp_path): + context, _ = make_context(tmp_path, fail_dns=True) + + result = run_job("check", context) + + assert result["state"] == "failed" + assert result["error"]["code"] == "check_failed" + + +def test_corrupt_sqlite_database_prevents_backup_publication(tmp_path): + context, _ = make_context(tmp_path, corrupt_database=True) + + result = run_job("backup", context) + + assert result["state"] == "failed" + assert list(context.backup_root.iterdir()) == [] + + +def test_update_stops_before_apt_when_backup_fails(tmp_path): + context, commands = make_context(tmp_path, corrupt_database=True) + + result = run_job("update", context) + + assert result["state"] == "failed" + assert ["/usr/bin/apt-get", "update"] not in commands.calls + + +def test_second_job_reports_busy(tmp_path): + context, _ = make_context(tmp_path) + + with held_lock(context.lock_file): + result = run_job("check", context) + + assert result["error"]["code"] == "busy" + + +def test_command_execution_observes_running_state_before_terminal_state(tmp_path): + context, commands = make_context(tmp_path) + observed_states: list[str] = [] + original_call = commands.__call__ + + def observe_state(argv: list[str]) -> CommandResult: + observed_states.append(json.loads((context.state_dir / "current.json").read_text())["state"]) + return original_call(argv) + + commands.__call__ = observe_state + context = RunnerContext( + etc_root=context.etc_root, + state_dir=context.state_dir, + backup_root=context.backup_root, + lock_file=context.lock_file, + command_executor=commands.__call__, + pihole_bin=context.pihole_bin, + state_gid=context.state_gid, + ) + + result = run_job("check", context) + + assert observed_states == ["running"] * len(commands.calls) + assert result["state"] == "succeeded" + assert json.loads((context.state_dir / "current.json").read_text())["state"] == "succeeded" + + +def test_state_files_have_mode_0640(tmp_path): + context, _ = make_context(tmp_path) + + result = run_job("check", context) + + assert os.stat(context.state_dir / "current.json").st_mode & 0o777 == 0o640 + assert os.stat(context.state_dir / f"{result['job_id']}.json").st_mode & 0o777 == 0o640 + + +def test_published_backup_directory_has_mode_0700(tmp_path): + context, _ = make_context(tmp_path) + + result = run_job("backup", context) + + assert result["state"] == "succeeded" + assert os.stat(result["result"]["backup_path"]).st_mode & 0o777 == 0o700 + + +def test_failed_backup_removes_its_staging_directory(tmp_path): + context, _ = make_context(tmp_path, corrupt_database=True) + + run_job("backup", context) + + assert not list(context.backup_root.glob(".staging-*")) + + +def test_backup_retention_keeps_only_the_ten_newest_verified_backups(tmp_path): + context, _ = make_context(tmp_path) + + for _ in range(11): + assert run_job("backup", context)["state"] == "succeeded" + + assert len(published_backups(context)) == 10 + + +def test_backup_manifest_hashes_match_each_published_file(tmp_path): + context, _ = make_context(tmp_path) + + result = run_job("backup", context) + + backup = Path(result["result"]["backup_path"]) + manifest = json.loads((backup / "manifest.json").read_text()) + assert manifest["files"] + for entry in manifest["files"]: + file_path = backup / entry["path"] + assert entry["sha256"] == hashlib.sha256(file_path.read_bytes()).hexdigest() + assert entry["size"] == file_path.stat().st_size + + +def test_backup_copies_configuration_without_following_symlinks(tmp_path): + context, _ = make_context(tmp_path) + outside_file = tmp_path / "outside.conf" + outside_file.write_text("must never be copied") + (context.etc_root / "unbound" / "outside-link.conf").symlink_to(outside_file) + + result = run_job("backup", context) + + backup = Path(result["result"]["backup_path"]) + assert (backup / "pihole" / "pihole.toml").read_text() == 'upstreams = ["127.0.0.1#5335"]\n' + assert (backup / "unbound" / "unbound.conf").read_text() == "server:\n" + assert not (backup / "unbound" / "outside-link.conf").exists() + + +def test_update_command_order(tmp_path): + context, commands = make_context(tmp_path) + + assert run_job("update", context)["state"] == "succeeded" + + assert [call for call in commands.calls if call in ( + ["/usr/bin/apt-get", "update"], + ["/usr/bin/apt-get", "-y", "upgrade"], + [context.pihole_bin, "-up"], + [context.pihole_bin, "-g"], + )] == [ + ["/usr/bin/apt-get", "update"], + ["/usr/bin/apt-get", "-y", "upgrade"], + [context.pihole_bin, "-up"], + [context.pihole_bin, "-g"], + ] + + +def test_check_fails_when_dns_returns_no_answers(tmp_path): + context, commands = make_context(tmp_path) + commands.dns_output = "" + + result = run_job("check", context) + + assert result["state"] == "failed" + assert result["error"]["code"] == "check_failed" + + +def test_check_fails_when_a_required_listener_is_missing(tmp_path): + context, commands = make_context(tmp_path) + commands.listener_output = "udp UNCONN 0 0 127.0.0.1:53 0.0.0.0:*\n" + + result = run_job("check", context) + + assert result["state"] == "failed" + assert result["error"]["code"] == "check_failed" + + +def test_symlinked_state_directory_is_rejected_before_commands_run(tmp_path): + context, commands = make_context(tmp_path) + state_target = tmp_path / "state-target" + state_target.mkdir() + context.state_dir.parent.mkdir(parents=True) + context.state_dir.symlink_to(state_target, target_is_directory=True) + + with pytest.raises(RuntimeError, match="state directory must not be a symlink"): + run_job("check", context) + + assert commands.calls == [] + + +def test_symlinked_backup_root_is_rejected_without_publication(tmp_path): + context, _ = make_context(tmp_path) + backup_target = tmp_path / "backup-target" + backup_target.mkdir() + context.backup_root.symlink_to(backup_target, target_is_directory=True) + + result = run_job("backup", context) + + assert result["state"] == "failed" + assert result["error"]["code"] == "backup_failed" + assert not list(backup_target.iterdir()) + + +def test_check_result_contains_safe_system_summary(tmp_path): + context, _ = make_context(tmp_path) + + result = run_job("check", context) + + assert result["result"]["system"].keys() >= {"host", "kernel", "architecture", "uptime_seconds", "root_filesystem"} + assert "example.com" not in json.dumps(result) + + +def test_update_reports_reboot_requirement_without_rebooting(tmp_path): + context, commands = make_context(tmp_path) + reboot_marker = tmp_path / "reboot-required" + reboot_marker.touch() + context = RunnerContext( + etc_root=context.etc_root, + state_dir=context.state_dir, + backup_root=context.backup_root, + lock_file=context.lock_file, + command_executor=commands, + pihole_bin=context.pihole_bin, + state_gid=context.state_gid, + reboot_required_path=reboot_marker, + ) + + result = run_job("update", context) + + assert result["state"] == "succeeded" + assert result["result"]["reboot_required"] is True + assert not any("reboot" in call for call in commands.calls) + + +def test_check_collects_unbound_version_with_a_fixed_command(tmp_path): + context, commands = make_context(tmp_path) + + assert run_job("check", context)["state"] == "succeeded" + + assert ["/usr/sbin/unbound", "-V"] in commands.calls + + +@pytest.mark.parametrize("relative_path", ("pihole/pihole.toml", "unbound")) +def test_backup_fails_when_required_configuration_is_missing(tmp_path, relative_path): + context, _ = make_context(tmp_path) + required_path = context.etc_root / relative_path + if required_path.is_dir(): + shutil.rmtree(required_path) + else: + required_path.unlink() + + result = run_job("backup", context) + + assert result["state"] == "failed" + assert result["error"]["code"] == "backup_failed" + assert not published_backups(context) From 73effc7f3df1c838161bf67f8884fbf36b2b5896 Mon Sep 17 00:00:00 2001 From: TimInTech Date: Mon, 7 Sep 2026 21:38:17 +0200 Subject: [PATCH 04/24] fix: harden maintenance runner execution --- maintenance_runner.py | 171 +++++++++++++++++++++++-------- tests/test_maintenance_runner.py | 141 +++++++++++++++++++++++-- 2 files changed, 262 insertions(+), 50 deletions(-) diff --git a/maintenance_runner.py b/maintenance_runner.py index a46f1f4..d784323 100644 --- a/maintenance_runner.py +++ b/maintenance_runner.py @@ -10,23 +10,25 @@ import hashlib import json import os +import platform import re import shutil +import signal import sqlite3 import subprocess import sys import tempfile import uuid -import platform from dataclasses import dataclass -from pathlib import Path +from pathlib import Path, PurePosixPath from typing import Any, Callable, Sequence -MAX_OUTPUT_LENGTH = 2_000 STATE_FILE_MODE = 0o640 BACKUP_DIR_MODE = 0o700 -ALLOWED_ACTIONS = ("check", "backup", "update") +CHECK_COMMAND_TIMEOUT_SECONDS = 30 +UPDATE_COMMAND_TIMEOUT_SECONDS = 7_200 +PROCESS_TERMINATION_GRACE_SECONDS = 5 class Action(str, enum.Enum): @@ -42,7 +44,7 @@ class CommandResult: stderr: str = "" -CommandExecutor = Callable[[list[str]], CommandResult] +CommandExecutor = Callable[[list[str], int], CommandResult] @dataclass(frozen=True) @@ -63,27 +65,48 @@ def __post_init__(self) -> None: object.__setattr__(self, "command_executor", _run_command) -def _run_command(argv: list[str]) -> CommandResult: +def _run_command(argv: list[str], timeout_seconds: int = CHECK_COMMAND_TIMEOUT_SECONDS) -> CommandResult: environment = {"PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"} if argv == ["/usr/bin/apt-get", "-y", "upgrade"]: environment["DEBIAN_FRONTEND"] = "noninteractive" try: - completed = subprocess.run( + process = subprocess.Popen( argv, - check=False, - capture_output=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True, - timeout=300, env=environment, + start_new_session=True, ) - except (OSError, subprocess.TimeoutExpired) as error: - return CommandResult(returncode=1, stderr=str(error)) - return CommandResult(completed.returncode, completed.stdout, completed.stderr) + except OSError: + return CommandResult(returncode=127) + try: + stdout, stderr = process.communicate(timeout=timeout_seconds) + except subprocess.TimeoutExpired: + _terminate_process_group(process) + return CommandResult(returncode=124) + return CommandResult(process.returncode, stdout, stderr) -def _sanitize_output(value: str) -> str: - clean = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]", "", value) - return clean[:MAX_OUTPUT_LENGTH] +def _terminate_process_group(process: subprocess.Popen[str]) -> None: + try: + os.killpg(process.pid, signal.SIGTERM) + except ProcessLookupError: + return + try: + process.communicate(timeout=PROCESS_TERMINATION_GRACE_SECONDS) + except subprocess.TimeoutExpired: + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + return + process.communicate() + return + try: + os.killpg(process.pid, 0) + except ProcessLookupError: + return + os.killpg(process.pid, signal.SIGKILL) def _utc_now() -> str: @@ -141,48 +164,63 @@ def _write_state(payload: dict[str, Any], context: RunnerContext, *, history: bo _atomic_json_write(context.state_dir / f"{payload['job_id']}.json", payload, context) -def _command_step(name: str, argv: list[str], context: RunnerContext, *, required: bool = True) -> dict[str, Any]: +def _command_step( + name: str, + argv: list[str], + context: RunnerContext, + *, + timeout_seconds: int, + required: bool = True, +) -> tuple[dict[str, Any], CommandResult]: assert context.command_executor is not None - result = context.command_executor(argv) + result = context.command_executor(argv, timeout_seconds) + detail = "completed" + if result.returncode == 124: + detail = "timed_out" + elif result.returncode != 0: + detail = "command_failed" return { "name": name, "required": required, "ok": result.returncode == 0, - "output": _sanitize_output(result.stdout), - "error_output": _sanitize_output(result.stderr), - } + "detail": detail, + }, result def _run_check(context: RunnerContext) -> tuple[list[dict[str, Any]], str | None]: - steps = [ - _command_step("pihole_ftl", ["/usr/bin/systemctl", "is-active", "pihole-FTL"], context), - _command_step("unbound", ["/usr/bin/systemctl", "is-active", "unbound"], context), - _command_step("pihole_version", [context.pihole_bin, "-v"], context), - _command_step("unbound_version", ["/usr/sbin/unbound", "-V"], context), - _command_step("unbound_config", ["/usr/sbin/unbound-checkconf"], context), - _command_step("listeners", ["/usr/bin/ss", "-ltnu"], context), + executions = [ + _command_step("pihole_ftl", ["/usr/bin/systemctl", "is-active", "pihole-FTL"], context, timeout_seconds=CHECK_COMMAND_TIMEOUT_SECONDS), + _command_step("unbound", ["/usr/bin/systemctl", "is-active", "unbound"], context, timeout_seconds=CHECK_COMMAND_TIMEOUT_SECONDS), + _command_step("pihole_version", [context.pihole_bin, "-v"], context, timeout_seconds=CHECK_COMMAND_TIMEOUT_SECONDS), + _command_step("unbound_version", ["/usr/sbin/unbound", "-V"], context, timeout_seconds=CHECK_COMMAND_TIMEOUT_SECONDS), + _command_step("unbound_config", ["/usr/sbin/unbound-checkconf"], context, timeout_seconds=CHECK_COMMAND_TIMEOUT_SECONDS), + _command_step("listeners", ["/usr/bin/ss", "-ltnu"], context, timeout_seconds=CHECK_COMMAND_TIMEOUT_SECONDS), _command_step( "dns_pihole", ["/usr/bin/dig", "+short", "@127.0.0.1", "example.com", "+time=3", "+tries=1"], context, + timeout_seconds=CHECK_COMMAND_TIMEOUT_SECONDS, ), _command_step( "dns_unbound", ["/usr/bin/dig", "+short", "-p", "5335", "@127.0.0.1", "example.com", "+time=3", "+tries=1"], context, + timeout_seconds=CHECK_COMMAND_TIMEOUT_SECONDS, ), - _command_step("apt_simulation", ["/usr/bin/apt-get", "-s", "upgrade"], context), + _command_step("apt_simulation", ["/usr/bin/apt-get", "-s", "upgrade"], context, timeout_seconds=CHECK_COMMAND_TIMEOUT_SECONDS), ] + steps = [step for step, _ in executions] + outputs = {step["name"]: result.stdout for step, result in executions} for step in steps: - if step["name"] in {"dns_pihole", "dns_unbound"} and step["ok"] and not step["output"].strip(): + if step["name"] in {"dns_pihole", "dns_unbound"} and step["ok"] and not outputs[step["name"]].strip(): step["ok"] = False - step["error_output"] = "DNS query returned no answer" + step["detail"] = "no_answer" listener_step = next(step for step in steps if step["name"] == "listeners") if listener_step["ok"] and not all( - re.search(rf":{port}(?:\s|$)", listener_step["output"]) for port in (53, 5335) + re.search(rf":{port}(?:\s|$)", outputs["listeners"]) for port in (53, 5335) ): listener_step["ok"] = False - listener_step["error_output"] = "required DNS listener is missing" + listener_step["detail"] = "required_listener_missing" if any(not step["ok"] and step["required"] for step in steps): return steps, "check_failed" return steps, None @@ -218,6 +256,7 @@ def _manifest(staging: Path, job_id: str) -> Path: manifest_path.write_text( json.dumps( { + "schema_version": 1, "job_id": job_id, "created_at": _utc_now(), "host": platform.node(), @@ -233,11 +272,57 @@ def _manifest(staging: Path, job_id: str) -> Path: def _verify_manifest(staging: Path) -> None: - manifest = json.loads((staging / "manifest.json").read_text(encoding="utf-8")) + try: + manifest = json.loads((staging / "manifest.json").read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + raise RuntimeError("manifest is unreadable") from None + required_keys = {"schema_version", "job_id", "created_at", "host", "versions", "files"} + if not isinstance(manifest, dict) or set(manifest) != required_keys: + raise RuntimeError("manifest schema is invalid") + if manifest["schema_version"] != 1 or not all(isinstance(manifest[key], str) and manifest[key] for key in ("job_id", "created_at", "host")): + raise RuntimeError("manifest metadata is invalid") + if not isinstance(manifest["versions"], dict) or not manifest["versions"] or not all( + isinstance(key, str) and key and isinstance(value, str) and value for key, value in manifest["versions"].items() + ): + raise RuntimeError("manifest versions are invalid") + if not isinstance(manifest["files"], list) or not manifest["files"]: + raise RuntimeError("manifest file list is invalid") + + actual_files: dict[str, Path] = {} + for candidate in staging.rglob("*"): + if candidate == staging / "manifest.json": + continue + if candidate.is_symlink(): + raise RuntimeError("manifest staging contains a symlink") + if candidate.is_file(): + actual_files[candidate.relative_to(staging).as_posix()] = candidate + elif not candidate.is_dir(): + raise RuntimeError("manifest staging contains an unsupported entry") + + listed_files: dict[str, dict[str, Any]] = {} for entry in manifest["files"]: - candidate = staging / entry["path"] - if not candidate.is_file() or candidate.stat().st_size != entry["size"] or _sha256(candidate) != entry["sha256"]: - raise RuntimeError(f"manifest verification failed for {entry['path']}") + if not isinstance(entry, dict) or set(entry) != {"path", "sha256", "size"}: + raise RuntimeError("manifest file entry is invalid") + path_value = entry["path"] + if not isinstance(path_value, str) or not path_value or "\\" in path_value: + raise RuntimeError("manifest file path is invalid") + relative_path = PurePosixPath(path_value) + if relative_path.is_absolute() or path_value != relative_path.as_posix() or any(part in {".", ".."} for part in relative_path.parts): + raise RuntimeError("manifest file path is invalid") + if not isinstance(entry["sha256"], str) or not re.fullmatch(r"[0-9a-f]{64}", entry["sha256"]): + raise RuntimeError("manifest file hash is invalid") + if not isinstance(entry["size"], int) or isinstance(entry["size"], bool) or entry["size"] < 0: + raise RuntimeError("manifest file size is invalid") + if path_value in listed_files: + raise RuntimeError("manifest contains duplicate files") + listed_files[path_value] = entry + + if "pihole/pihole.toml" not in actual_files or set(listed_files) != set(actual_files): + raise RuntimeError("manifest file coverage is incomplete") + for path_value, entry in listed_files.items(): + candidate = actual_files[path_value] + if candidate.stat().st_size != entry["size"] or _sha256(candidate) != entry["sha256"]: + raise RuntimeError("manifest file verification failed") def _copy_regular_file(source: Path, destination: Path) -> None: @@ -345,10 +430,10 @@ def _run_backup(context: RunnerContext, job_id: str) -> tuple[list[dict[str, Any published = context.backup_root / f"{_utc_now().replace(':', '').replace('-', '')}-{job_id}" os.replace(staging, published) _retain_newest_backups(context.backup_root) - return [{"name": "backup", "required": True, "ok": True}], None, str(published) - except (OSError, sqlite3.Error, ValueError, RuntimeError) as error: + return [{"name": "backup", "required": True, "ok": True, "detail": "completed"}], None, str(published) + except (OSError, sqlite3.Error, ValueError, RuntimeError): shutil.rmtree(staging, ignore_errors=True) - return [{"name": "backup", "required": True, "ok": False, "error_output": _sanitize_output(str(error))}], "backup_failed", None + return [{"name": "backup", "required": True, "ok": False, "detail": "backup_failed"}], "backup_failed", None def _run_update(context: RunnerContext, job_id: str) -> tuple[list[dict[str, Any]], str | None, str | None]: @@ -361,7 +446,7 @@ def _run_update(context: RunnerContext, job_id: str) -> tuple[list[dict[str, Any ("pihole_update", [context.pihole_bin, "-up"]), ("pihole_gravity", [context.pihole_bin, "-g"]), ): - step = _command_step(name, argv, context) + step, _ = _command_step(name, argv, context, timeout_seconds=UPDATE_COMMAND_TIMEOUT_SECONDS) steps.append(step) if not step["ok"]: return steps, "update_failed", backup_path @@ -415,10 +500,10 @@ def run_job(action: Action | str, context: RunnerContext) -> dict[str, Any]: if selected_action is Action.UPDATE: result["reboot_required"] = context.reboot_required_path.exists() payload["result"] = result - except (OSError, RuntimeError) as error: + except (OSError, RuntimeError): payload["state"] = "failed" payload["finished_at"] = _utc_now() - payload["error"] = {"code": "runner_failed", "message": _sanitize_output(str(error))} + payload["error"] = {"code": "runner_failed", "message": "maintenance runner failed"} _write_state(payload, context, history=True) return payload diff --git a/tests/test_maintenance_runner.py b/tests/test_maintenance_runner.py index b7a5144..b0f3d5f 100644 --- a/tests/test_maintenance_runner.py +++ b/tests/test_maintenance_runner.py @@ -6,6 +6,7 @@ import json import os import shutil +import time from contextlib import contextmanager import pytest @@ -13,6 +14,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from maintenance_runner import CommandResult, RunnerContext, run_job +import maintenance_runner class RecordingExecutor: @@ -21,16 +23,19 @@ def __init__(self, *, fail_dns: bool = False, dns_output: str = "203.0.113.7\n", self.fail_dns = fail_dns self.dns_output = dns_output self.listener_output = listener_output or "udp UNCONN 0 0 127.0.0.1:53 0.0.0.0:*\nudp UNCONN 0 0 127.0.0.1:5335 0.0.0.0:*\n" + self.timeouts: list[int | None] = [] + self.private_output = "" - def __call__(self, argv: list[str]) -> CommandResult: + def __call__(self, argv: list[str], timeout_seconds: int | None = None) -> CommandResult: self.calls.append(argv) + self.timeouts.append(timeout_seconds) if self.fail_dns and argv[0] == "/usr/bin/dig": - return CommandResult(returncode=1, stdout="", stderr="DNS unavailable") + return CommandResult(returncode=1, stdout="", stderr=f"DNS unavailable {self.private_output}") if argv[0] == "/usr/bin/dig": - return CommandResult(returncode=0, stdout=self.dns_output, stderr="") + return CommandResult(returncode=0, stdout=f"{self.dns_output}{self.private_output}", stderr=self.private_output) if argv == ["/usr/bin/ss", "-ltnu"]: - return CommandResult(returncode=0, stdout=self.listener_output, stderr="") - return CommandResult(returncode=0, stdout="ok\n", stderr="") + return CommandResult(returncode=0, stdout=f"{self.listener_output}{self.private_output}", stderr=self.private_output) + return CommandResult(returncode=0, stdout=f"ok\n{self.private_output}", stderr=self.private_output) @contextmanager @@ -125,9 +130,9 @@ def test_command_execution_observes_running_state_before_terminal_state(tmp_path observed_states: list[str] = [] original_call = commands.__call__ - def observe_state(argv: list[str]) -> CommandResult: + def observe_state(argv: list[str], timeout_seconds: int | None = None) -> CommandResult: observed_states.append(json.loads((context.state_dir / "current.json").read_text())["state"]) - return original_call(argv) + return original_call(argv, timeout_seconds) commands.__call__ = observe_state context = RunnerContext( @@ -327,3 +332,125 @@ def test_backup_fails_when_required_configuration_is_missing(tmp_path, relative_ assert result["state"] == "failed" assert result["error"]["code"] == "backup_failed" assert not published_backups(context) + + +def test_job_state_persists_only_allowlisted_structured_command_results(tmp_path): + context, commands = make_context(tmp_path) + commands.private_output = "private-zone.example --token=never-persist" + + result = run_job("check", context) + + persisted = (context.state_dir / "current.json").read_text() + assert commands.private_output not in json.dumps(result) + assert commands.private_output not in persisted + for step in result["steps"]: + assert set(step) <= {"name", "required", "ok", "detail"} + + +def test_update_uses_long_timeouts_while_final_check_uses_short_timeouts(tmp_path): + context, commands = make_context(tmp_path) + + assert run_job("update", context)["state"] == "succeeded" + + timeouts_by_command = dict(zip((tuple(call) for call in commands.calls), commands.timeouts, strict=True)) + assert timeouts_by_command[("/usr/bin/apt-get", "update")] == 7200 + assert timeouts_by_command[("/usr/bin/apt-get", "-y", "upgrade")] == 7200 + assert timeouts_by_command[(context.pihole_bin, "-up")] == 7200 + assert timeouts_by_command[("/usr/bin/dig", "+short", "@127.0.0.1", "example.com", "+time=3", "+tries=1")] == 30 + + +def test_timed_out_command_terminates_its_entire_process_group(tmp_path, monkeypatch): + child_pid_path = tmp_path / "child.pid" + child_code = "import signal, time; signal.signal(signal.SIGTERM, signal.SIG_IGN); time.sleep(60)" + parent_code = ( + "from pathlib import Path; import subprocess, sys, time; " + f"child = subprocess.Popen([sys.executable, '-c', {child_code!r}], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL); " + f"Path({str(child_pid_path)!r}).write_text(str(child.pid)); time.sleep(60)" + ) + monkeypatch.setattr(maintenance_runner, "PROCESS_TERMINATION_GRACE_SECONDS", 0.05) + + try: + result = maintenance_runner._run_command([sys.executable, "-c", parent_code], timeout_seconds=0.1) + except TypeError: + pytest.fail("runner does not support a bounded command timeout") + + child_pid = int(child_pid_path.read_text()) + deadline = time.monotonic() + 2 + while time.monotonic() < deadline: + try: + os.kill(child_pid, 0) + except ProcessLookupError: + break + time.sleep(0.05) + else: + pytest.fail("child process survived command timeout") + assert result.returncode != 0 + + +def manifest_entry(path: Path, relative_path: str) -> dict[str, object]: + return { + "path": relative_path, + "sha256": hashlib.sha256(path.read_bytes()).hexdigest(), + "size": path.stat().st_size, + } + + +def test_manifest_rejects_empty_or_incomplete_file_coverage(tmp_path): + staging = tmp_path / "staging" + pihole_toml = staging / "pihole" / "pihole.toml" + unbound_conf = staging / "unbound" / "unbound.conf" + pihole_toml.parent.mkdir(parents=True) + unbound_conf.parent.mkdir() + pihole_toml.write_text("config") + unbound_conf.write_text("server:") + base = {"schema_version": 1, "job_id": "job", "created_at": "2026-01-01T00:00:00Z", "host": "host", "versions": {"runner": "1"}} + + for files in ([], [manifest_entry(pihole_toml, "pihole/pihole.toml")]): + (staging / "manifest.json").write_text(json.dumps({**base, "files": files})) + with pytest.raises(RuntimeError, match="manifest"): + maintenance_runner._verify_manifest(staging) + + +@pytest.mark.parametrize("unsafe_kind", ("absolute", "traversal")) +def test_manifest_rejects_absolute_and_traversal_paths(tmp_path, unsafe_kind): + staging = tmp_path / "staging" + staging.mkdir() + external = tmp_path / "external" + external.write_text("external file") + unsafe_path = str(external) if unsafe_kind == "absolute" else "../external" + manifest = { + "schema_version": 1, + "job_id": "job", + "created_at": "2026-01-01T00:00:00Z", + "host": "host", + "versions": {"runner": "1"}, + "files": [manifest_entry(external, unsafe_path)], + } + (staging / "manifest.json").write_text(json.dumps(manifest)) + + with pytest.raises(RuntimeError, match="manifest"): + maintenance_runner._verify_manifest(staging) + + +def test_manifest_accepts_a_covered_nested_file_named_manifest_json(tmp_path): + staging = tmp_path / "staging" + pihole_toml = staging / "pihole" / "pihole.toml" + nested_manifest = staging / "unbound" / "manifest.json" + pihole_toml.parent.mkdir(parents=True) + nested_manifest.parent.mkdir() + pihole_toml.write_text("config") + nested_manifest.write_text("nested config") + manifest = { + "schema_version": 1, + "job_id": "job", + "created_at": "2026-01-01T00:00:00Z", + "host": "host", + "versions": {"runner": "1"}, + "files": [ + manifest_entry(pihole_toml, "pihole/pihole.toml"), + manifest_entry(nested_manifest, "unbound/manifest.json"), + ], + } + (staging / "manifest.json").write_text(json.dumps(manifest)) + + maintenance_runner._verify_manifest(staging) From 926cf7801a78fab01b736e3eb938fcffb1f53f41 Mon Sep 17 00:00:00 2001 From: TimInTech Date: Mon, 7 Sep 2026 21:50:03 +0200 Subject: [PATCH 05/24] feat: add authenticated maintenance web panel --- maintenance_web.py | 344 ++++++++++++++++++++++++++++++++++ start_suite.py | 13 +- tests/test_maintenance_web.py | 258 +++++++++++++++++++++++++ web/maintenance.css | 7 + web/maintenance.html | 33 ++++ web/maintenance.js | 107 +++++++++++ 6 files changed, 761 insertions(+), 1 deletion(-) create mode 100644 maintenance_web.py create mode 100644 tests/test_maintenance_web.py create mode 100644 web/maintenance.css create mode 100644 web/maintenance.html create mode 100644 web/maintenance.js diff --git a/maintenance_web.py b/maintenance_web.py new file mode 100644 index 0000000..cef59d7 --- /dev/null +++ b/maintenance_web.py @@ -0,0 +1,344 @@ +"""Authenticated, least-privilege browser API for Pi-hole maintenance jobs.""" + +from __future__ import annotations + +import hmac +import json +import os +import secrets +import stat +import subprocess +import time +from collections.abc import Callable +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, Request, Response +from fastapi.responses import FileResponse, JSONResponse, PlainTextResponse + +SESSION_IDLE_SECONDS = 30 * 60 +SESSION_MAX_SECONDS = 8 * 60 * 60 +LOGIN_WINDOW_SECONDS = 5 * 60 +LOGIN_MAX_FAILURES = 5 +MAX_STATE_BYTES = 64 * 1024 +COOKIE_NAME = "pihole_maintenance_session" +ALLOWED_HOSTS = frozenset({"pi.hole", "192.168.178.2", "127.0.0.1", "localhost"}) +ALLOWED_ORIGINS = frozenset({"https://pi.hole:8443", "https://192.168.178.2:8443"}) +WEB_DIRECTORY = Path(__file__).resolve().parent / "web" + +Dispatcher = Callable[[list[str]], None] + + +class StateUnavailable(RuntimeError): + """The runner state exists but is not safe to expose or act on.""" + + +@dataclass +class Session: + csrf_token: str + created_at: float + last_seen_at: float + + +@dataclass +class MaintenanceSettings: + """Configuration owned by the local service, never supplied by a request.""" + + api_key: str + state_file: Path = Path("/var/lib/pihole-suite/jobs/current.json") + dispatcher: Dispatcher | None = None + allowed_hosts: frozenset[str] = ALLOWED_HOSTS + allowed_origins: frozenset[str] = ALLOWED_ORIGINS + sessions: dict[str, Session] = field(default_factory=dict) + login_failures: dict[str, list[float]] = field(default_factory=dict) + now: Callable[[], float] = time.monotonic + + def __post_init__(self) -> None: + self.state_file = Path(self.state_file) + if self.dispatcher is None: + self.dispatcher = _dispatch + + @classmethod + def from_environment(cls) -> MaintenanceSettings: + api_key = os.environ.get("SUITE_API_KEY", "") + if not api_key: + raise RuntimeError("Missing required environment variable: SUITE_API_KEY") + return cls(api_key=api_key) + + +def _dispatch(argv: list[str]) -> None: + """Start one predefined unit without a shell or caller-controlled arguments.""" + subprocess.run(argv, check=True, timeout=15, capture_output=True, text=True) + + +def _security_headers(response: Response) -> None: + response.headers["Content-Security-Policy"] = ( + "default-src 'self'; base-uri 'none'; connect-src 'self'; form-action 'self'; " + "frame-ancestors 'none'; object-src 'none'; script-src 'self'; style-src 'self'" + ) + response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains" + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["Referrer-Policy"] = "no-referrer" + response.headers["X-Frame-Options"] = "DENY" + response.headers["Cache-Control"] = "no-store" + + +def configure_maintenance_security(app: Any, settings: MaintenanceSettings) -> None: + """Install the host boundary and response headers once on the FastAPI app.""" + + @app.middleware("http") + async def restrict_host_and_add_security_headers(request: Request, call_next: Any) -> Response: + host = request.headers.get("host", "").split(":", 1)[0].lower() + if host not in settings.allowed_hosts: + response: Response = PlainTextResponse("Invalid host", status_code=400) + else: + response = await call_next(request) + _security_headers(response) + return response + + +def _client_key(request: Request) -> str: + return request.client.host if request.client is not None else "unknown" + + +def _purge_expired_failures(settings: MaintenanceSettings, client_key: str, now: float) -> list[float]: + recent = [timestamp for timestamp in settings.login_failures.get(client_key, []) if now - timestamp < LOGIN_WINDOW_SECONDS] + if recent: + settings.login_failures[client_key] = recent + else: + settings.login_failures.pop(client_key, None) + return recent + + +def _session_from_request(request: Request, settings: MaintenanceSettings) -> tuple[str, Session]: + session_id = request.cookies.get(COOKIE_NAME) + if not session_id: + raise HTTPException(status_code=401, detail="Authentication required") + session = settings.sessions.get(session_id) + now = settings.now() + if session is None or now - session.last_seen_at >= SESSION_IDLE_SECONDS or now - session.created_at >= SESSION_MAX_SECONDS: + settings.sessions.pop(session_id, None) + raise HTTPException(status_code=401, detail="Authentication required") + session.last_seen_at = now + return session_id, session + + +def _require_mutation(request: Request, settings: MaintenanceSettings) -> tuple[str, Session]: + session_id, session = _session_from_request(request, settings) + if request.headers.get("origin") not in settings.allowed_origins: + raise HTTPException(status_code=403, detail="Request rejected") + csrf_token = request.headers.get("x-csrf-token", "") + if not csrf_token or not hmac.compare_digest(csrf_token, session.csrf_token): + raise HTTPException(status_code=403, detail="Request rejected") + return session_id, session + + +def _bounded_string(value: object, *, maximum: int = 256) -> bool: + return isinstance(value, str) and len(value) <= maximum and "\x00" not in value and all(ord(char) >= 32 for char in value) + + +def _bounded_integer(value: object) -> bool: + return isinstance(value, int) and not isinstance(value, bool) + + +def _validate_system(system: object) -> bool: + if not isinstance(system, dict): + return False + allowed = { + "host", "operating_system", "kernel", "architecture", "uptime_seconds", "load_average", + "memory", "root_filesystem", "temperature_celsius", + } + if not set(system) <= allowed: + return False + for key in ("host", "operating_system", "kernel", "architecture"): + if key in system and not _bounded_string(system[key]): + return False + if "uptime_seconds" in system and system["uptime_seconds"] is not None and not _bounded_integer(system["uptime_seconds"]): + return False + if "load_average" in system and ( + not isinstance(system["load_average"], list) + or len(system["load_average"]) != 3 + or not all(isinstance(value, (int, float)) and not isinstance(value, bool) for value in system["load_average"]) + ): + return False + for key, expected_keys in (("memory", {"total_bytes", "available_bytes"}), ("root_filesystem", {"total_bytes", "used_bytes", "free_bytes"})): + if key not in system: + continue + value = system[key] + if not isinstance(value, dict) or set(value) != expected_keys: + return False + if not all(item is None or _bounded_integer(item) for item in value.values()): + return False + return not ( + "temperature_celsius" in system + and system["temperature_celsius"] is not None + and not isinstance(system["temperature_celsius"], (int, float)) + ) + + +def _validate_state(payload: object) -> dict[str, Any] | None: + if not isinstance(payload, dict): + return None + required = {"schema_version", "job_id", "action", "state", "started_at", "finished_at", "steps", "result", "error"} + if set(payload) != required or payload["schema_version"] != 1: + return None + if not _bounded_string(payload["job_id"], maximum=128) or not _bounded_string(payload["started_at"]): + return None + if payload["action"] not in {"check", "backup", "update"} or payload["state"] not in {"running", "succeeded", "failed"}: + return None + if payload["finished_at"] is not None and not _bounded_string(payload["finished_at"]): + return None + if not isinstance(payload["steps"], list) or len(payload["steps"]) > 64: + return None + for step in payload["steps"]: + if not isinstance(step, dict) or set(step) != {"name", "required", "ok", "detail"}: + return None + if not _bounded_string(step["name"]) or not isinstance(step["required"], bool) or not isinstance(step["ok"], bool) or not _bounded_string(step["detail"]): + return None + result = payload["result"] + if not isinstance(result, dict) or not set(result) <= {"system", "backup_path", "reboot_required"}: + return None + if "system" in result and not _validate_system(result["system"]): + return None + if "backup_path" in result and not _bounded_string(result["backup_path"], maximum=512): + return None + if "reboot_required" in result and not isinstance(result["reboot_required"], bool): + return None + error = payload["error"] + if error is not None and ( + not isinstance(error, dict) + or set(error) != {"code", "message"} + or not _bounded_string(error["code"]) + or not _bounded_string(error["message"]) + ): + return None + return payload + + +def _read_current_state(settings: MaintenanceSettings) -> dict[str, Any] | None: + try: + metadata = settings.state_file.lstat() + except FileNotFoundError: + return None + except OSError as error: + raise StateUnavailable from error + try: + if not stat.S_ISREG(metadata.st_mode) or metadata.st_size > MAX_STATE_BYTES: + raise StateUnavailable + raw = settings.state_file.read_bytes() + if len(raw) > MAX_STATE_BYTES: + raise StateUnavailable + state = _validate_state(json.loads(raw.decode("utf-8"))) + if state is None: + raise StateUnavailable + return state + except (OSError, UnicodeDecodeError, json.JSONDecodeError): + raise StateUnavailable from None + + +def _state_or_error(settings: MaintenanceSettings) -> dict[str, Any]: + try: + state = _read_current_state(settings) + except StateUnavailable: + raise HTTPException(status_code=503, detail="Job status unavailable") + return state if state is not None else {"state": "idle"} + + +def _file_response(filename: str, media_type: str) -> FileResponse: + return FileResponse(WEB_DIRECTORY / filename, media_type=media_type) + + +def create_maintenance_router(settings: MaintenanceSettings) -> APIRouter: + """Build routes for one configured maintenance service instance.""" + router = APIRouter() + + def require_session(request: Request) -> tuple[str, Session]: + return _session_from_request(request, settings) + + def require_mutation(request: Request) -> tuple[str, Session]: + return _require_mutation(request, settings) + + @router.get("/", include_in_schema=False) + def dashboard() -> FileResponse: + return _file_response("maintenance.html", "text/html; charset=utf-8") + + @router.get("/maintenance.css", include_in_schema=False) + def stylesheet() -> FileResponse: + return _file_response("maintenance.css", "text/css; charset=utf-8") + + @router.get("/maintenance.js", include_in_schema=False) + def script() -> FileResponse: + return _file_response("maintenance.js", "application/javascript; charset=utf-8") + + @router.post("/api/session", include_in_schema=False) + async def create_session(request: Request, response: Response) -> dict[str, str]: + now = settings.now() + client_key = _client_key(request) + if len(_purge_expired_failures(settings, client_key, now)) >= LOGIN_MAX_FAILURES: + raise HTTPException(status_code=429, detail="Too many login attempts") + try: + body = await request.body() + parsed = json.loads(body) if len(body) <= 8192 else {} + password = parsed.get("password") if isinstance(parsed, dict) and set(parsed) == {"password"} else None + except (UnicodeDecodeError, json.JSONDecodeError): + password = None + if not isinstance(password, str) or not password or len(password) > 4096 or not hmac.compare_digest(password, settings.api_key): + settings.login_failures.setdefault(client_key, []).append(now) + raise HTTPException(status_code=401, detail="Invalid credentials") + settings.login_failures.pop(client_key, None) + session_id = secrets.token_urlsafe(32) + csrf_token = secrets.token_urlsafe(32) + settings.sessions[session_id] = Session(csrf_token=csrf_token, created_at=now, last_seen_at=now) + response.set_cookie( + COOKIE_NAME, + session_id, + secure=True, + httponly=True, + samesite="strict", + path="/", + ) + return {"csrf_token": csrf_token} + + @router.post("/api/session/logout", status_code=204, include_in_schema=False) + def logout(session: tuple[str, Session] = Depends(require_mutation)) -> Response: + settings.sessions.pop(session[0], None) + response = Response(status_code=204) + response.delete_cookie(COOKIE_NAME, path="/", secure=True, httponly=True, samesite="strict") + return response + + @router.get("/api/maintenance/status", include_in_schema=False) + def status(_: tuple[str, Session] = Depends(require_session)) -> dict[str, Any]: + return _state_or_error(settings) + + def start_action(action: str) -> JSONResponse: + try: + state = _read_current_state(settings) + except StateUnavailable: + raise HTTPException(status_code=503, detail="Job status unavailable") from None + if state is not None and state["state"] == "running": + raise HTTPException(status_code=409, detail="A maintenance job is already running") + argv = [ + "/usr/bin/sudo", "-n", "/usr/bin/systemctl", "start", "--no-block", + f"pihole-maintenance-{action}.service", + ] + try: + assert settings.dispatcher is not None + settings.dispatcher(argv) + except (OSError, subprocess.SubprocessError): + raise HTTPException(status_code=503, detail="Maintenance job could not be started") from None + return JSONResponse(status_code=202, content={"action": action, "state": "accepted"}) + + @router.post("/api/maintenance/check", status_code=202, include_in_schema=False) + def start_check(_: tuple[str, Session] = Depends(require_mutation)) -> JSONResponse: + return start_action("check") + + @router.post("/api/maintenance/backup", status_code=202, include_in_schema=False) + def start_backup(_: tuple[str, Session] = Depends(require_mutation)) -> JSONResponse: + return start_action("backup") + + @router.post("/api/maintenance/update", status_code=202, include_in_schema=False) + def start_update(_: tuple[str, Session] = Depends(require_mutation)) -> JSONResponse: + return start_action("update") + + return router diff --git a/start_suite.py b/start_suite.py index 563f0a8..8607df4 100755 --- a/start_suite.py +++ b/start_suite.py @@ -33,6 +33,8 @@ from fastapi import Depends, FastAPI, Header, HTTPException import uvicorn +from maintenance_web import MaintenanceSettings, configure_maintenance_security, create_maintenance_router + APP_VERSION = "1.0.0" START_TIME = time.time() @@ -54,7 +56,16 @@ def require_api_key(x_api_key: str | None = Header(default=None, alias="X-API-Ke raise HTTPException(status_code=401, detail="Unauthorized") -app = FastAPI(title="Pi-hole Suite API", version=APP_VERSION) +app = FastAPI( + title="Pi-hole Suite API", + version=APP_VERSION, + docs_url=None, + redoc_url=None, + openapi_url=None, +) +maintenance_settings = MaintenanceSettings.from_environment() +configure_maintenance_security(app, maintenance_settings) +app.include_router(create_maintenance_router(maintenance_settings)) def _read_lines(path: str, limit: int) -> list[str]: diff --git a/tests/test_maintenance_web.py b/tests/test_maintenance_web.py new file mode 100644 index 0000000..a5dbf1a --- /dev/null +++ b/tests/test_maintenance_web.py @@ -0,0 +1,258 @@ +import importlib +import json +import sys +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from maintenance_web import ( + MaintenanceSettings, + configure_maintenance_security, + create_maintenance_router, +) + + +class RecordingDispatcher: + def __init__(self): + self.calls: list[list[str]] = [] + + def __call__(self, argv: list[str]) -> None: + self.calls.append(argv) + + +def state_payload(*, state: str = "succeeded") -> dict[str, object]: + return { + "schema_version": 1, + "job_id": "20260907T120000000000Z-0123456789abcdef", + "action": "check", + "state": state, + "started_at": "2026-09-07T12:00:00Z", + "finished_at": None if state == "running" else "2026-09-07T12:00:01Z", + "steps": [{"name": "pihole_ftl", "required": True, "ok": True, "detail": "completed"}], + "result": {"system": {"host": "pi.hole", "kernel": "6.18", "architecture": "aarch64"}}, + "error": None, + } + + +@pytest.fixture +def state_file(tmp_path: Path) -> Path: + path = tmp_path / "current.json" + path.write_text(json.dumps(state_payload()), encoding="utf-8") + return path + + +@pytest.fixture +def dispatcher() -> RecordingDispatcher: + return RecordingDispatcher() + + +@pytest.fixture +def settings(state_file: Path, dispatcher: RecordingDispatcher) -> MaintenanceSettings: + return MaintenanceSettings(api_key="correct horse battery staple", state_file=state_file, dispatcher=dispatcher) + + +@pytest.fixture +def client(settings: MaintenanceSettings) -> TestClient: + app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None) + configure_maintenance_security(app, settings) + app.include_router(create_maintenance_router(settings)) + return TestClient(app, base_url="https://pi.hole:8443") + + +def login(client: TestClient) -> tuple[dict[str, str], str]: + response = client.post("/api/session", json={"password": "correct horse battery staple"}) + assert response.status_code == 200 + return {"pihole_maintenance_session": response.cookies["pihole_maintenance_session"]}, response.json()["csrf_token"] + + +def post_action(client: TestClient, cookies: dict[str, str], csrf_token: str, action: str): + return client.post( + f"/api/maintenance/{action}", + cookies=cookies, + headers={"Origin": "https://pi.hole:8443", "X-CSRF-Token": csrf_token}, + ) + + +def test_job_status_requires_session(client: TestClient): + assert client.get("/api/maintenance/status").status_code == 401 + + +def test_login_does_not_return_password_and_sets_secure_cookie(client: TestClient): + response = client.post("/api/session", json={"password": "correct horse battery staple"}) + + assert response.status_code == 200 + assert "correct horse battery staple" not in response.text + cookie = response.headers["set-cookie"] + assert "HttpOnly" in cookie + assert "Secure" in cookie + assert "samesite=strict" in cookie.lower() + + +def test_mutation_rejects_missing_or_wrong_csrf(client: TestClient): + cookies, csrf_token = login(client) + + missing = client.post("/api/maintenance/update", cookies=cookies, headers={"Origin": "https://pi.hole:8443"}) + wrong = post_action(client, cookies, f"{csrf_token}-wrong", "update") + + assert missing.status_code == 403 + assert wrong.status_code == 403 + + +def test_mutation_rejects_wrong_origin(client: TestClient): + cookies, csrf_token = login(client) + + response = client.post( + "/api/maintenance/check", + cookies=cookies, + headers={"Origin": "https://attacker.invalid", "X-CSRF-Token": csrf_token}, + ) + + assert response.status_code == 403 + + +def test_login_rate_limits_five_failures(client: TestClient): + for _ in range(5): + assert client.post("/api/session", json={"password": "wrong"}).status_code == 401 + + response = client.post("/api/session", json={"password": "correct horse battery staple"}) + + assert response.status_code == 429 + assert "correct horse battery staple" not in response.text + + +def test_invalid_login_body_never_reflects_password(client: TestClient): + secret = "must-not-be-reflected" + + response = client.post("/api/session", json={"password": [secret]}) + + assert response.status_code == 401 + assert secret not in response.text + + +def test_check_dispatches_only_fixed_unit(client: TestClient, dispatcher: RecordingDispatcher): + cookies, csrf_token = login(client) + + response = post_action(client, cookies, csrf_token, "check") + + assert response.status_code == 202 + assert dispatcher.calls == [[ + "/usr/bin/sudo", "-n", "/usr/bin/systemctl", "start", "--no-block", + "pihole-maintenance-check.service", + ]] + + +def test_running_job_rejects_second_start(client: TestClient, state_file: Path): + state_file.write_text(json.dumps(state_payload(state="running")), encoding="utf-8") + cookies, csrf_token = login(client) + + response = post_action(client, cookies, csrf_token, "backup") + + assert response.status_code == 409 + + +def test_first_job_can_start_without_a_preexisting_state_file(client: TestClient, state_file: Path, dispatcher: RecordingDispatcher): + state_file.unlink() + cookies, csrf_token = login(client) + + response = post_action(client, cookies, csrf_token, "check") + + assert response.status_code == 202 + assert dispatcher.calls + + +def test_unknown_action_is_not_routable(client: TestClient): + cookies, csrf_token = login(client) + + assert post_action(client, cookies, csrf_token, "shell").status_code == 404 + + +@pytest.mark.parametrize("contents", ("{}", "{" + "x" * 70_000 + "}")) +def test_status_rejects_malformed_or_oversized_state(client: TestClient, state_file: Path, contents: str): + state_file.write_text(contents, encoding="utf-8") + cookies, _ = login(client) + + response = client.get("/api/maintenance/status", cookies=cookies) + + assert response.status_code == 503 + assert "x" * 100 not in response.text + + +def test_logout_requires_csrf_and_invalidates_session(client: TestClient): + cookies, csrf_token = login(client) + + missing = client.post("/api/session/logout", cookies=cookies, headers={"Origin": "https://pi.hole:8443"}) + logged_out = client.post( + "/api/session/logout", + cookies=cookies, + headers={"Origin": "https://pi.hole:8443", "X-CSRF-Token": csrf_token}, + ) + + assert missing.status_code == 403 + assert logged_out.status_code == 204 + assert client.get("/api/maintenance/status", cookies=cookies).status_code == 401 + + +def test_session_expires_after_idle_or_absolute_lifetime(client: TestClient, settings: MaintenanceSettings): + clock = [0.0] + settings.now = lambda: clock[0] + cookies, _ = login(client) + clock[0] = 30 * 60 + assert client.get("/api/maintenance/status", cookies=cookies).status_code == 401 + + clock[0] = 0.0 + cookies, _ = login(client) + clock[0] = 30 * 60 - 1 + assert client.get("/api/maintenance/status", cookies=cookies).status_code == 200 + clock[0] = 8 * 60 * 60 + assert client.get("/api/maintenance/status", cookies=cookies).status_code == 401 + + +def test_ui_assets_headers_and_docs_are_safe(client: TestClient): + page = client.get("/") + css = client.get("/maintenance.css") + javascript = client.get("/maintenance.js") + + assert page.status_code == 200 + assert "Systemcheck" in page.text + assert "text/html" in page.headers["content-type"] + assert "text/css" in css.headers["content-type"] + assert "javascript" in javascript.headers["content-type"] + assert client.get("/docs").status_code == 404 + for response in (page, css, javascript): + assert "frame-ancestors 'none'" in response.headers["content-security-policy"] + assert response.headers["strict-transport-security"].startswith("max-age=") + assert response.headers["x-content-type-options"] == "nosniff" + + +def test_host_allowlist_rejects_unexpected_host(client: TestClient): + response = client.get("/", headers={"Host": "attacker.invalid"}) + + assert response.status_code == 400 + + +def test_browser_ui_uses_confirmation_text_content_and_running_only_polling(client: TestClient): + page = client.get("/").text + script = client.get("/maintenance.js").text + + assert "Backup bestätigen" in page + assert "UPDATE" in page + assert "textContent" in script + assert "innerHTML" not in script + assert 'if (job.state === "running")' in script + assert "2000" in script + + +def test_suite_keeps_the_legacy_monitoring_api(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("SUITE_API_KEY", "correct horse battery staple") + sys.modules.pop("start_suite", None) + suite = importlib.import_module("start_suite") + suite_client = TestClient(suite.app, base_url="https://pi.hole:8443") + + assert {"/health", "/version", "/urls", "/pihole", "/unbound", "/netalertx", "/dns", "/devices", "/leases", "/stats"} <= { + route.path for route in suite.app.routes if hasattr(route, "path") + } + assert suite_client.get("/health", headers={"X-API-Key": "correct horse battery staple"}).status_code == 200 diff --git a/web/maintenance.css b/web/maintenance.css new file mode 100644 index 0000000..4b20359 --- /dev/null +++ b/web/maintenance.css @@ -0,0 +1,7 @@ +:root { color-scheme: light dark; font-family: system-ui, sans-serif; } +body { margin: 0; padding: 2rem; } +main { margin: auto; max-width: 68rem; } +.actions { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); } +article, section { border: 1px solid currentColor; border-radius: .5rem; margin: 1rem 0; padding: 1rem; } +button, input { font: inherit; margin-top: .5rem; } +pre { overflow-wrap: anywhere; white-space: pre-wrap; } diff --git a/web/maintenance.html b/web/maintenance.html new file mode 100644 index 0000000..4938ca6 --- /dev/null +++ b/web/maintenance.html @@ -0,0 +1,33 @@ + + + + + + Pi-hole Wartung + + + +
+

Pi-hole Wartung

+
+

Anmelden

+
+ + + +
+
+ +
+ + + diff --git a/web/maintenance.js b/web/maintenance.js new file mode 100644 index 0000000..03062ed --- /dev/null +++ b/web/maintenance.js @@ -0,0 +1,107 @@ +"use strict"; + +let csrfToken = null; +let pollTimer = null; + +const loginPanel = document.querySelector("#login-panel"); +const dashboard = document.querySelector("#dashboard"); +const statusText = document.querySelector("#status"); +const resultText = document.querySelector("#job-result"); + +function showMessage(message) { + statusText.textContent = message; +} + +async function request(path, options = {}) { + const headers = new Headers(options.headers || {}); + if (options.method && options.method !== "GET") { + headers.set("Origin", window.location.origin); + headers.set("X-CSRF-Token", csrfToken || ""); + } + return fetch(path, { ...options, headers, credentials: "same-origin" }); +} + +function stopPolling() { + if (pollTimer !== null) { + window.clearTimeout(pollTimer); + pollTimer = null; + } +} + +async function refreshStatus() { + const response = await request("/api/maintenance/status"); + if (response.status === 401) { + stopPolling(); + dashboard.hidden = true; + loginPanel.hidden = false; + csrfToken = null; + return; + } + if (!response.ok) { + showMessage("Status kann derzeit nicht gelesen werden."); + stopPolling(); + return; + } + const job = await response.json(); + showMessage(`Auftrag: ${job.action} – ${job.state}`); + resultText.textContent = JSON.stringify(job, null, 2); + if (job.state === "running") { + pollTimer = window.setTimeout(refreshStatus, 2000); + } else { + stopPolling(); + } +} + +async function startAction(action) { + const response = await request(`/api/maintenance/${action}`, { method: "POST" }); + if (!response.ok) { + showMessage("Wartungsauftrag konnte nicht gestartet werden."); + return; + } + showMessage("Wartungsauftrag wurde gestartet."); + stopPolling(); + await refreshStatus(); +} + +document.querySelector("#login-form").addEventListener("submit", async (event) => { + event.preventDefault(); + const password = document.querySelector("#password").value; + const response = await fetch("/api/session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "same-origin", + body: JSON.stringify({ password }), + }); + document.querySelector("#password").value = ""; + if (!response.ok) { + showMessage("Anmeldung fehlgeschlagen."); + return; + } + csrfToken = (await response.json()).csrf_token; + loginPanel.hidden = true; + dashboard.hidden = false; + await refreshStatus(); +}); + +document.querySelector("#check-button").addEventListener("click", () => startAction("check")); +document.querySelector("#backup-button").addEventListener("click", () => { + if (!document.querySelector("#backup-confirmed").checked) { + showMessage("Bitte das Backup sichtbar bestätigen."); + return; + } + startAction("backup"); +}); +document.querySelector("#update-button").addEventListener("click", () => { + if (document.querySelector("#update-confirmation").value !== "UPDATE") { + showMessage("Bitte UPDATE exakt eingeben."); + return; + } + startAction("update"); +}); +document.querySelector("#logout-button").addEventListener("click", async () => { + await request("/api/session/logout", { method: "POST" }); + stopPolling(); + csrfToken = null; + dashboard.hidden = true; + loginPanel.hidden = false; +}); From 91a1b9c64eb84651e38a0103fca18d075a32bd82 Mon Sep 17 00:00:00 2001 From: TimInTech Date: Mon, 7 Sep 2026 22:03:50 +0200 Subject: [PATCH 06/24] fix: serialize maintenance web operations --- maintenance_web.py | 98 ++++++++++++++++++++++-------- tests/test_maintenance_web.py | 108 +++++++++++++++++++++++++++++++++- web/maintenance.js | 39 +++++++++++- 3 files changed, 217 insertions(+), 28 deletions(-) diff --git a/maintenance_web.py b/maintenance_web.py index cef59d7..2a65652 100644 --- a/maintenance_web.py +++ b/maintenance_web.py @@ -8,6 +8,7 @@ import secrets import stat import subprocess +import threading import time from collections.abc import Callable from dataclasses import dataclass, field @@ -21,6 +22,7 @@ SESSION_MAX_SECONDS = 8 * 60 * 60 LOGIN_WINDOW_SECONDS = 5 * 60 LOGIN_MAX_FAILURES = 5 +START_RESERVATION_SECONDS = 60 MAX_STATE_BYTES = 64 * 1024 COOKIE_NAME = "pihole_maintenance_session" ALLOWED_HOSTS = frozenset({"pi.hole", "192.168.178.2", "127.0.0.1", "localhost"}) @@ -41,6 +43,13 @@ class Session: last_seen_at: float +@dataclass(frozen=True) +class StartReservation: + token: str + baseline_job_id: str | None + expires_at: float + + @dataclass class MaintenanceSettings: """Configuration owned by the local service, never supplied by a request.""" @@ -52,6 +61,8 @@ class MaintenanceSettings: allowed_origins: frozenset[str] = ALLOWED_ORIGINS sessions: dict[str, Session] = field(default_factory=dict) login_failures: dict[str, list[float]] = field(default_factory=dict) + state_lock: threading.RLock = field(default_factory=threading.RLock, repr=False) + start_reservation: StartReservation | None = None now: Callable[[], float] = time.monotonic def __post_init__(self) -> None: @@ -111,17 +122,39 @@ def _purge_expired_failures(settings: MaintenanceSettings, client_key: str, now: return recent +def _purge_expired_sessions(settings: MaintenanceSettings, now: float) -> None: + expired = [ + session_id + for session_id, session in settings.sessions.items() + if now - session.last_seen_at >= SESSION_IDLE_SECONDS or now - session.created_at >= SESSION_MAX_SECONDS + ] + for session_id in expired: + settings.sessions.pop(session_id, None) + + +def _observe_runner_state(settings: MaintenanceSettings, state: dict[str, Any] | None, now: float) -> None: + reservation = settings.start_reservation + if reservation is None: + return + if now >= reservation.expires_at: + settings.start_reservation = None + return + if state is not None and state["job_id"] != reservation.baseline_job_id: + settings.start_reservation = None + + def _session_from_request(request: Request, settings: MaintenanceSettings) -> tuple[str, Session]: session_id = request.cookies.get(COOKIE_NAME) if not session_id: raise HTTPException(status_code=401, detail="Authentication required") - session = settings.sessions.get(session_id) now = settings.now() - if session is None or now - session.last_seen_at >= SESSION_IDLE_SECONDS or now - session.created_at >= SESSION_MAX_SECONDS: - settings.sessions.pop(session_id, None) - raise HTTPException(status_code=401, detail="Authentication required") - session.last_seen_at = now - return session_id, session + with settings.state_lock: + _purge_expired_sessions(settings, now) + session = settings.sessions.get(session_id) + if session is None: + raise HTTPException(status_code=401, detail="Authentication required") + session.last_seen_at = now + return session_id, session def _require_mutation(request: Request, settings: MaintenanceSettings) -> tuple[str, Session]: @@ -242,6 +275,8 @@ def _state_or_error(settings: MaintenanceSettings) -> dict[str, Any]: state = _read_current_state(settings) except StateUnavailable: raise HTTPException(status_code=503, detail="Job status unavailable") + with settings.state_lock: + _observe_runner_state(settings, state, settings.now()) return state if state is not None else {"state": "idle"} @@ -273,23 +308,25 @@ def script() -> FileResponse: @router.post("/api/session", include_in_schema=False) async def create_session(request: Request, response: Response) -> dict[str, str]: - now = settings.now() - client_key = _client_key(request) - if len(_purge_expired_failures(settings, client_key, now)) >= LOGIN_MAX_FAILURES: - raise HTTPException(status_code=429, detail="Too many login attempts") try: body = await request.body() parsed = json.loads(body) if len(body) <= 8192 else {} password = parsed.get("password") if isinstance(parsed, dict) and set(parsed) == {"password"} else None except (UnicodeDecodeError, json.JSONDecodeError): password = None - if not isinstance(password, str) or not password or len(password) > 4096 or not hmac.compare_digest(password, settings.api_key): - settings.login_failures.setdefault(client_key, []).append(now) - raise HTTPException(status_code=401, detail="Invalid credentials") - settings.login_failures.pop(client_key, None) - session_id = secrets.token_urlsafe(32) - csrf_token = secrets.token_urlsafe(32) - settings.sessions[session_id] = Session(csrf_token=csrf_token, created_at=now, last_seen_at=now) + now = settings.now() + client_key = _client_key(request) + with settings.state_lock: + _purge_expired_sessions(settings, now) + if len(_purge_expired_failures(settings, client_key, now)) >= LOGIN_MAX_FAILURES: + raise HTTPException(status_code=429, detail="Too many login attempts") + if not isinstance(password, str) or not password or len(password) > 4096 or not hmac.compare_digest(password, settings.api_key): + settings.login_failures.setdefault(client_key, []).append(now) + raise HTTPException(status_code=401, detail="Invalid credentials") + settings.login_failures.pop(client_key, None) + session_id = secrets.token_urlsafe(32) + csrf_token = secrets.token_urlsafe(32) + settings.sessions[session_id] = Session(csrf_token=csrf_token, created_at=now, last_seen_at=now) response.set_cookie( COOKIE_NAME, session_id, @@ -302,7 +339,8 @@ async def create_session(request: Request, response: Response) -> dict[str, str] @router.post("/api/session/logout", status_code=204, include_in_schema=False) def logout(session: tuple[str, Session] = Depends(require_mutation)) -> Response: - settings.sessions.pop(session[0], None) + with settings.state_lock: + settings.sessions.pop(session[0], None) response = Response(status_code=204) response.delete_cookie(COOKIE_NAME, path="/", secure=True, httponly=True, samesite="strict") return response @@ -312,12 +350,21 @@ def status(_: tuple[str, Session] = Depends(require_session)) -> dict[str, Any]: return _state_or_error(settings) def start_action(action: str) -> JSONResponse: - try: - state = _read_current_state(settings) - except StateUnavailable: - raise HTTPException(status_code=503, detail="Job status unavailable") from None - if state is not None and state["state"] == "running": - raise HTTPException(status_code=409, detail="A maintenance job is already running") + with settings.state_lock: + try: + state = _read_current_state(settings) + except StateUnavailable: + raise HTTPException(status_code=503, detail="Job status unavailable") from None + now = settings.now() + _observe_runner_state(settings, state, now) + if settings.start_reservation is not None or (state is not None and state["state"] == "running"): + raise HTTPException(status_code=409, detail="A maintenance job is already running") + reservation = StartReservation( + token=secrets.token_urlsafe(16), + baseline_job_id=state["job_id"] if state is not None else None, + expires_at=now + START_RESERVATION_SECONDS, + ) + settings.start_reservation = reservation argv = [ "/usr/bin/sudo", "-n", "/usr/bin/systemctl", "start", "--no-block", f"pihole-maintenance-{action}.service", @@ -326,6 +373,9 @@ def start_action(action: str) -> JSONResponse: assert settings.dispatcher is not None settings.dispatcher(argv) except (OSError, subprocess.SubprocessError): + with settings.state_lock: + if settings.start_reservation == reservation: + settings.start_reservation = None raise HTTPException(status_code=503, detail="Maintenance job could not be started") from None return JSONResponse(status_code=202, content={"action": action, "state": "accepted"}) diff --git a/tests/test_maintenance_web.py b/tests/test_maintenance_web.py index a5dbf1a..1971221 100644 --- a/tests/test_maintenance_web.py +++ b/tests/test_maintenance_web.py @@ -1,5 +1,8 @@ +import asyncio import importlib import json +import shutil +import subprocess import sys from pathlib import Path @@ -11,6 +14,7 @@ from maintenance_web import ( MaintenanceSettings, + Session, configure_maintenance_security, create_maintenance_router, ) @@ -124,6 +128,24 @@ def test_login_rate_limits_five_failures(client: TestClient): assert "correct horse battery staple" not in response.text +def test_parallel_login_failures_cannot_bypass_rate_limit(client: TestClient): + async def failed_logins() -> list[int]: + import httpx + + transport = httpx.ASGITransport(app=client.app) + async with httpx.AsyncClient(transport=transport, base_url="https://pi.hole:8443") as asgi_client: + responses = await asyncio.gather(*[ + asgi_client.post("/api/session", json={"password": "wrong"}) + for _ in range(7) + ]) + locked = await asgi_client.post("/api/session", json={"password": "correct horse battery staple"}) + return [response.status_code for response in responses] + [locked.status_code] + + statuses = asyncio.run(failed_logins()) + assert statuses.count(401) == 5 + assert statuses.count(429) == 3 + + def test_invalid_login_body_never_reflects_password(client: TestClient): secret = "must-not-be-reflected" @@ -164,6 +186,54 @@ def test_first_job_can_start_without_a_preexisting_state_file(client: TestClient assert dispatcher.calls +def test_immediate_parallel_starts_are_reserved(client: TestClient): + cookies, csrf_token = login(client) + + async def starts() -> list[int]: + import httpx + + transport = httpx.ASGITransport(app=client.app) + async with httpx.AsyncClient(transport=transport, base_url="https://pi.hole:8443") as asgi_client: + responses = await asyncio.gather(*[ + asgi_client.post( + "/api/maintenance/check", + cookies=cookies, + headers={"Origin": "https://pi.hole:8443", "X-CSRF-Token": csrf_token}, + ) + for _ in range(2) + ]) + return [response.status_code for response in responses] + + assert sorted(asyncio.run(starts())) == [202, 409] + + +def test_failed_dispatch_releases_start_reservation(client: TestClient, settings: MaintenanceSettings): + cookies, csrf_token = login(client) + + def fail_dispatch(_: list[str]) -> None: + raise OSError("systemctl unavailable") + + settings.dispatcher = fail_dispatch + assert post_action(client, cookies, csrf_token, "check").status_code == 503 + settings.dispatcher = RecordingDispatcher() + assert post_action(client, cookies, csrf_token, "check").status_code == 202 + + +def test_new_runner_state_or_timeout_releases_start_reservation(client: TestClient, settings: MaintenanceSettings, state_file: Path): + clock = [0.0] + settings.now = lambda: clock[0] + cookies, csrf_token = login(client) + assert post_action(client, cookies, csrf_token, "check").status_code == 202 + + new_state = state_payload() + new_state["job_id"] = "20260907T120000000001Z-fedcba9876543210" + state_file.write_text(json.dumps(new_state), encoding="utf-8") + assert post_action(client, cookies, csrf_token, "backup").status_code == 202 + + clock[0] = 61.0 + assert post_action(client, cookies, csrf_token, "update").status_code == 202 + + def test_unknown_action_is_not_routable(client: TestClient): cookies, csrf_token = login(client) @@ -211,6 +281,16 @@ def test_session_expires_after_idle_or_absolute_lifetime(client: TestClient, set assert client.get("/api/maintenance/status", cookies=cookies).status_code == 401 +def test_expired_sessions_are_pruned_during_normal_login(client: TestClient, settings: MaintenanceSettings): + clock = [10_000.0] + settings.now = lambda: clock[0] + settings.sessions["expired"] = Session("csrf", 0.0, 0.0) + + login(client) + + assert "expired" not in settings.sessions + + def test_ui_assets_headers_and_docs_are_safe(client: TestClient): page = client.get("/") css = client.get("/maintenance.css") @@ -242,10 +322,36 @@ def test_browser_ui_uses_confirmation_text_content_and_running_only_polling(clie assert "UPDATE" in page assert "textContent" in script assert "innerHTML" not in script - assert 'if (job.state === "running")' in script + assert 'poll: job.state === "running"' in script assert "2000" in script +@pytest.mark.skipif(shutil.which("node") is None, reason="Node.js is only needed for the browser-state regression") +def test_browser_transition_polling_reaches_running_then_terminal_state(): + script_path = Path(__file__).resolve().parents[1] / "web" / "maintenance.js" + node_program = f""" +const fs = require("fs"); +const vm = require("vm"); +const element = {{ addEventListener() {{}}, value: "", checked: false, hidden: false, textContent: "" }}; +const context = {{ + document: {{ querySelector: () => element }}, + window: {{ clearTimeout() {{}}, setTimeout() {{ return 1; }}, location: {{ origin: "https://pi.hole:8443" }} }}, + Headers, + fetch: async () => {{ throw new Error("fetch is not expected"); }}, +}}; +vm.runInNewContext(fs.readFileSync({json.dumps(str(script_path))}, "utf8") + ";globalThis.decision = pollingDecision;", context); +let pending = {{ baselineJobId: "old-job", attempts: 0 }}; +let decision = context.decision({{ job_id: "old-job", state: "succeeded" }}, pending); +if (!decision.poll || decision.pending.attempts !== 1) throw new Error("old terminal state did not continue transition polling"); +decision = context.decision({{ job_id: "new-job", state: "running" }}, decision.pending); +if (!decision.poll || decision.pending !== null) throw new Error("running new job was not observed"); +decision = context.decision({{ job_id: "new-job", state: "succeeded" }}, decision.pending); +if (decision.poll) throw new Error("terminal new job still polls"); +""" + + subprocess.run(["node", "-e", node_program], check=True, capture_output=True, text=True) + + def test_suite_keeps_the_legacy_monitoring_api(monkeypatch: pytest.MonkeyPatch): monkeypatch.setenv("SUITE_API_KEY", "correct horse battery staple") sys.modules.pop("start_suite", None) diff --git a/web/maintenance.js b/web/maintenance.js index 03062ed..cbe581b 100644 --- a/web/maintenance.js +++ b/web/maintenance.js @@ -2,6 +2,11 @@ let csrfToken = null; let pollTimer = null; +let lastObservedJobId = null; +let pendingStart = null; + +const POLL_INTERVAL_MS = 2000; +const PENDING_START_MAX_POLLS = 15; const loginPanel = document.querySelector("#login-panel"); const dashboard = document.querySelector("#dashboard"); @@ -28,10 +33,26 @@ function stopPolling() { } } +function pollingDecision(job, pending) { + const jobId = typeof job.job_id === "string" ? job.job_id : null; + const newRunnerJob = pending !== null && ( + job.state === "running" || (jobId !== null && jobId !== pending.baselineJobId) + ); + if (pending !== null && !newRunnerJob) { + const nextPending = { ...pending, attempts: pending.attempts + 1 }; + if (nextPending.attempts <= PENDING_START_MAX_POLLS) { + return { poll: true, pending: nextPending, timedOut: false }; + } + return { poll: false, pending: null, timedOut: true }; + } + return { poll: job.state === "running", pending: null, timedOut: false }; +} + async function refreshStatus() { const response = await request("/api/maintenance/status"); if (response.status === 401) { stopPolling(); + pendingStart = null; dashboard.hidden = true; loginPanel.hidden = false; csrfToken = null; @@ -40,15 +61,25 @@ async function refreshStatus() { if (!response.ok) { showMessage("Status kann derzeit nicht gelesen werden."); stopPolling(); + pendingStart = null; return; } const job = await response.json(); + if (typeof job.job_id === "string") { + lastObservedJobId = job.job_id; + } showMessage(`Auftrag: ${job.action} – ${job.state}`); resultText.textContent = JSON.stringify(job, null, 2); - if (job.state === "running") { - pollTimer = window.setTimeout(refreshStatus, 2000); + const decision = pollingDecision(job, pendingStart); + pendingStart = decision.pending; + if (decision.timedOut) { + showMessage("Der gestartete Wartungsauftrag wurde nicht rechtzeitig im Status sichtbar."); + } + stopPolling(); + if (decision.poll) { + pollTimer = window.setTimeout(refreshStatus, POLL_INTERVAL_MS); } else { - stopPolling(); + pendingStart = null; } } @@ -60,6 +91,7 @@ async function startAction(action) { } showMessage("Wartungsauftrag wurde gestartet."); stopPolling(); + pendingStart = { baselineJobId: lastObservedJobId, attempts: 0 }; await refreshStatus(); } @@ -102,6 +134,7 @@ document.querySelector("#logout-button").addEventListener("click", async () => { await request("/api/session/logout", { method: "POST" }); stopPolling(); csrfToken = null; + pendingStart = null; dashboard.hidden = true; loginPanel.hidden = false; }); From 3367983bed2c2b58f0b6d3d540dae602f10202d6 Mon Sep 17 00:00:00 2001 From: TimInTech Date: Tue, 8 Sep 2026 13:06:30 +0200 Subject: [PATCH 07/24] feat: install hardened maintenance web services --- deploy/Caddyfile.maintenance | 8 + deploy/pihole-maintenance-backup.service | 17 ++ deploy/pihole-maintenance-check.service | 16 ++ deploy/pihole-maintenance-update.service | 17 ++ deploy/pihole-maintenance-web.sudoers | 3 + install.sh | 75 +++++-- scripts/install_maintenance_web.sh | 260 +++++++++++++++++++++++ scripts/rollback_maintenance_web.sh | 143 +++++++++++++ tests/test_install_maintenance_web.sh | 111 ++++++++++ 9 files changed, 635 insertions(+), 15 deletions(-) create mode 100644 deploy/Caddyfile.maintenance create mode 100644 deploy/pihole-maintenance-backup.service create mode 100644 deploy/pihole-maintenance-check.service create mode 100644 deploy/pihole-maintenance-update.service create mode 100644 deploy/pihole-maintenance-web.sudoers create mode 100755 scripts/install_maintenance_web.sh create mode 100755 scripts/rollback_maintenance_web.sh create mode 100755 tests/test_install_maintenance_web.sh diff --git a/deploy/Caddyfile.maintenance b/deploy/Caddyfile.maintenance new file mode 100644 index 0000000..77b7ed3 --- /dev/null +++ b/deploy/Caddyfile.maintenance @@ -0,0 +1,8 @@ +pi.hole:8443, 192.168.178.2:8443 { + tls internal + @lan remote_ip 192.168.178.0/24 127.0.0.1 ::1 + handle @lan { + reverse_proxy 127.0.0.1:8090 + } + respond "Forbidden" 403 +} diff --git a/deploy/pihole-maintenance-backup.service b/deploy/pihole-maintenance-backup.service new file mode 100644 index 0000000..8ce69a4 --- /dev/null +++ b/deploy/pihole-maintenance-backup.service @@ -0,0 +1,17 @@ +[Unit] +Description=Pi-hole maintenance verified backup +After=network-online.target pihole-FTL.service unbound.service +Wants=network-online.target + +[Service] +Type=oneshot +User=root +Group=root +UMask=0077 +Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +ExecStart=/usr/local/libexec/pihole-maintenance-runner backup +TimeoutStartSec=15min +NoNewPrivileges=true +PrivateTmp=true +ProtectHome=true +ReadWritePaths=/var/backups/pihole-suite /var/lib/pihole-suite/jobs /run/lock diff --git a/deploy/pihole-maintenance-check.service b/deploy/pihole-maintenance-check.service new file mode 100644 index 0000000..f0197e1 --- /dev/null +++ b/deploy/pihole-maintenance-check.service @@ -0,0 +1,16 @@ +[Unit] +Description=Pi-hole maintenance system check +After=network-online.target pihole-FTL.service unbound.service +Wants=network-online.target + +[Service] +Type=oneshot +User=root +Group=root +UMask=0077 +Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +ExecStart=/usr/local/libexec/pihole-maintenance-runner check +TimeoutStartSec=15min +NoNewPrivileges=true +PrivateTmp=true +ProtectHome=true diff --git a/deploy/pihole-maintenance-update.service b/deploy/pihole-maintenance-update.service new file mode 100644 index 0000000..eb9d6fe --- /dev/null +++ b/deploy/pihole-maintenance-update.service @@ -0,0 +1,17 @@ +[Unit] +Description=Pi-hole maintenance controlled update +After=network-online.target pihole-FTL.service unbound.service +Wants=network-online.target + +[Service] +Type=oneshot +User=root +Group=root +UMask=0077 +Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +ExecStart=/usr/local/libexec/pihole-maintenance-runner update +TimeoutStartSec=120min +NoNewPrivileges=true +PrivateTmp=true +ProtectHome=true +ReadWritePaths=/var/backups/pihole-suite /var/lib/pihole-suite/jobs /run/lock diff --git a/deploy/pihole-maintenance-web.sudoers b/deploy/pihole-maintenance-web.sudoers new file mode 100644 index 0000000..e9b4ed5 --- /dev/null +++ b/deploy/pihole-maintenance-web.sudoers @@ -0,0 +1,3 @@ +pihole-suite ALL=(root) NOPASSWD: /usr/bin/systemctl start --no-block pihole-maintenance-check.service +pihole-suite ALL=(root) NOPASSWD: /usr/bin/systemctl start --no-block pihole-maintenance-backup.service +pihole-suite ALL=(root) NOPASSWD: /usr/bin/systemctl start --no-block pihole-maintenance-update.service diff --git a/install.sh b/install.sh index ac50344..c6456c5 100755 --- a/install.sh +++ b/install.sh @@ -64,6 +64,7 @@ FORCE=false AUTO_REMOVE_CONFLICTS=false INSTALL_NETALERTX=false INSTALL_PYTHON_SUITE=true +INSTALL_MAINTENANCE_WEB=false # Ports UNBOUND_PORT=5335 @@ -300,7 +301,9 @@ parse_args() { --install-netalertx|--with-netalertx) INSTALL_NETALERTX=true ;; --skip-netalertx) INSTALL_NETALERTX=false ;; --skip-python-api) INSTALL_PYTHON_SUITE=false ;; - --minimal) INSTALL_NETALERTX=false; INSTALL_PYTHON_SUITE=false ;; + --with-maintenance-web) INSTALL_MAINTENANCE_WEB=true ;; + --skip-maintenance-web) INSTALL_MAINTENANCE_WEB=false ;; + --minimal) INSTALL_NETALERTX=false; INSTALL_PYTHON_SUITE=false; INSTALL_MAINTENANCE_WEB=false ;; *) log_error "Unknown option: $1"; exit 1 ;; esac shift @@ -1089,7 +1092,35 @@ setup_netalertx() { # ============================================= # PYTHON SUITE SETUP # ============================================= +install_python_suite_app_files() { + local suite_app_dir="$STATE_DIR/app" + local source_file relative_file + local -a app_files=( + "start_suite.py" + "maintenance_web.py" + "web/maintenance.html" + "web/maintenance.css" + "web/maintenance.js" + ) + + sudo install -d -o root -g root -m 0755 "$STATE_DIR" "$suite_app_dir" "$suite_app_dir/web" + for relative_file in "${app_files[@]}"; do + source_file="$SCRIPT_DIR/$relative_file" + [[ -f "$source_file" && ! -L "$source_file" ]] || { + log_error "Required Python Suite app file missing or unsafe: $source_file" + exit 1 + } + sudo install -o root -g root -m 0644 "$source_file" "$suite_app_dir/$relative_file" + done +} + setup_python_suite() { + if ! $DRY_RUN; then + # start_suite.py imports maintenance_web unconditionally. Keep the complete + # app tree installed even when privileged maintenance exposure stays off. + install_python_suite_app_files + fi + if [[ "$PY_SUITE_OK" == true && "$FORCE" != true ]]; then if [[ "$CONTAINER_MODE" == false ]] && command -v systemctl >/dev/null 2>&1; then local py_state @@ -1115,27 +1146,25 @@ setup_python_suite() { ensure_suite_env_file "$suite_data_dir" - # Keep a stable, system-accessible copy of the suite code under $STATE_DIR. - sudo mkdir -p "$suite_app_dir" 2>/dev/null || true - if [[ -f "$SCRIPT_DIR/start_suite.py" ]]; then - sudo install -m 0644 "$SCRIPT_DIR/start_suite.py" "$suite_entrypoint" - else - log_warning "Python Suite entrypoint missing at $SCRIPT_DIR/start_suite.py (service may fail to start)" - fi - if ! getent group "$suite_group" >/dev/null 2>&1; then sudo groupadd --system "$suite_group" fi if ! id -u "$suite_user" >/dev/null 2>&1; then sudo useradd --system --no-create-home --shell /usr/sbin/nologin --gid "$suite_group" "$suite_user" fi - sudo mkdir -p "$suite_state_dir" + sudo install -d -o root -g root -m 0755 "$suite_state_dir" + sudo install -d -o root -g "$suite_group" -m 0750 "$suite_data_dir" "$suite_state_dir/jobs" + sudo install -d -o root -g "$suite_group" -m 0770 "$suite_state_dir/sessions" ensure_python_venv "$suite_venv_dir" "$suite_venv_dir/bin/pip" install -r "$SCRIPT_DIR/requirements.txt" || { log_error "Python requirements failed"; exit 1; } - sudo chown -R "$suite_user":"$suite_group" "$suite_data_dir" "$suite_app_dir" "$suite_venv_dir" "$suite_state_dir" 2>/dev/null || true + sudo chown -R root:root "$suite_app_dir" "$suite_venv_dir" + sudo chmod 0755 "$suite_state_dir" "$suite_app_dir" "$suite_app_dir/web" "$suite_venv_dir" + sudo chown root:"$suite_group" "$suite_data_dir" "$suite_state_dir/jobs" "$suite_state_dir/sessions" + sudo chmod 0750 "$suite_data_dir" "$suite_state_dir/jobs" + sudo chmod 0770 "$suite_state_dir/sessions" sudo chown root:"$suite_group" "$ENV_FILE" sudo chmod 640 "$ENV_FILE" [[ -f "$suite_entrypoint" ]] || log_warning "Python Suite entrypoint missing at $suite_entrypoint (service may need code before start)" @@ -1143,8 +1172,6 @@ setup_python_suite() { if [[ "$CONTAINER_MODE" == false ]]; then local protect_home_value="true" - local read_write_paths="$suite_state_dir" - local tmp_unit tmp_unit="$(mktemp)" cat > "$tmp_unit" <&2 + exit 1 +} + +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + --destdir) + [[ $# -ge 2 ]] || die "--destdir requires a path" + DESTDIR="$2" + shift 2 + ;; + --help|-h) + usage + exit 0 + ;; + *) die "unknown option: $1" ;; + esac + done + if [[ -n "$DESTDIR" ]]; then + [[ "$DESTDIR" = /* && "$DESTDIR" != "/" ]] || die "DESTDIR must be a non-root absolute path" + [[ ! -L "$DESTDIR" ]] || die "DESTDIR must not be a symlink" + mkdir -p -- "$DESTDIR" + fi +} + +destination() { + local target="$1" + if [[ -n "$DESTDIR" ]]; then + printf '%s%s\n' "$DESTDIR" "$target" + else + printf '%s\n' "$target" + fi +} + +assert_safe_parent_chain() { + local path + path="$(dirname -- "$1")" + while [[ "$path" != "/" && "$path" != "." ]]; do + [[ ! -L "$path" ]] || die "refusing symlinked parent directory: $path" + path="$(dirname -- "$path")" + done +} + +source_for_target() { + case "$1" in + /etc/systemd/system/pihole-maintenance-check.service) printf '%s\n' "$SOURCE_DIR/deploy/pihole-maintenance-check.service" ;; + /etc/systemd/system/pihole-maintenance-backup.service) printf '%s\n' "$SOURCE_DIR/deploy/pihole-maintenance-backup.service" ;; + /etc/systemd/system/pihole-maintenance-update.service) printf '%s\n' "$SOURCE_DIR/deploy/pihole-maintenance-update.service" ;; + "$SUDOERS_TARGET") printf '%s\n' "$SOURCE_DIR/deploy/pihole-maintenance-web.sudoers" ;; + "$CADDY_MAIN_TARGET") die "Caddy main configuration is generated, not copied" ;; + "$CADDY_TARGET") printf '%s\n' "$SOURCE_DIR/deploy/Caddyfile.maintenance" ;; + "$RUNNER_TARGET") printf '%s\n' "$SOURCE_DIR/maintenance_runner.py" ;; + "$ROLLBACK_TARGET") printf '%s\n' "$SOURCE_DIR/scripts/rollback_maintenance_web.sh" ;; + "$APP_DIR/start_suite.py") printf '%s\n' "$SOURCE_DIR/start_suite.py" ;; + "$APP_DIR/maintenance_web.py") printf '%s\n' "$SOURCE_DIR/maintenance_web.py" ;; + "$APP_DIR/web/maintenance.html") printf '%s\n' "$SOURCE_DIR/web/maintenance.html" ;; + "$APP_DIR/web/maintenance.css") printf '%s\n' "$SOURCE_DIR/web/maintenance.css" ;; + "$APP_DIR/web/maintenance.js") printf '%s\n' "$SOURCE_DIR/web/maintenance.js" ;; + *) die "internal unexpected target: $1" ;; + esac +} + +mode_for_target() { + case "$1" in + "$RUNNER_TARGET"|"$ROLLBACK_TARGET") printf '%s\n' 0755 ;; + "$SUDOERS_TARGET") printf '%s\n' 0440 ;; + *) printf '%s\n' 0644 ;; + esac +} + +install_target() { + local target="$1" source mode installed_target + source="$(source_for_target "$target")" + mode="$(mode_for_target "$target")" + installed_target="$(destination "$target")" + [[ -f "$source" && ! -L "$source" ]] || die "unsafe or missing source: $source" + assert_safe_parent_chain "$installed_target" + [[ ! -L "$installed_target" ]] || die "refusing to replace symlink: $installed_target" + mkdir -p -- "$(dirname -- "$installed_target")" + if [[ -n "$DESTDIR" ]]; then + install -m "$mode" -- "$source" "$installed_target" + else + install -o root -g root -m "$mode" -- "$source" "$installed_target" + fi +} + +prepare_runtime_directories() { + if [[ -n "$DESTDIR" ]]; then + assert_safe_parent_chain "$(destination /usr/local/libexec)/.guard" + assert_safe_parent_chain "$(destination "$APP_DIR")/.guard" + assert_safe_parent_chain "$(destination "$APP_DIR/web")/.guard" + [[ ! -L "$(destination "$APP_DIR/web")" ]] || die "refusing symlinked app web directory" + install -d -m 0755 "$(destination /usr/local/libexec)" "$(destination "$APP_DIR")" "$(destination "$APP_DIR/web")" + install -d -m 0750 "$(destination /var/lib/pihole-suite/jobs)" + install -d -m 0770 "$(destination /var/lib/pihole-suite/sessions)" + install -d -m 0700 "$(destination /var/backups/pihole-suite)" "$(destination "$DEPLOY_BACKUP_ROOT")" + return + fi + getent group pihole-suite >/dev/null || die "required group pihole-suite does not exist" + id -u pihole-suite >/dev/null || die "required user pihole-suite does not exist" + assert_safe_parent_chain "$APP_DIR/.guard" + assert_safe_parent_chain "$APP_DIR/web/.guard" + [[ ! -L "$APP_DIR/web" ]] || die "refusing symlinked app web directory" + install -d -o root -g root -m 0755 /usr/local/libexec "$APP_DIR" "$APP_DIR/web" + install -d -o root -g pihole-suite -m 0750 /var/lib/pihole-suite/jobs + install -d -o root -g pihole-suite -m 0770 /var/lib/pihole-suite/sessions + install -d -o root -g root -m 0700 /var/backups/pihole-suite "$DEPLOY_BACKUP_ROOT" +} + +backup_target() { + local target="$1" backup_dir="$2" index="$3" backup_file mode uid gid digest + assert_safe_parent_chain "$target" + [[ ! -L "$target" ]] || die "refusing to backup symlink: $target" + backup_file="files/$index" + if [[ -e "$target" ]]; then + [[ -f "$target" ]] || die "refusing to backup non-regular artifact: $target" + install -d -o root -g root -m 0700 "$backup_dir/files" + cp --preserve=mode,ownership,timestamps -- "$target" "$backup_dir/$backup_file" + mode="$(stat -c '%a' -- "$target")" + uid="$(stat -c '%u' -- "$target")" + gid="$(stat -c '%g' -- "$target")" + digest="$(sha256sum -- "$target" | awk '{print $1}')" + printf '%s|present|%s|%s|%s|%s|%s\n' "$target" "$backup_file" "$mode" "$uid" "$gid" "$digest" >> "$backup_dir/manifest.tsv" + else + printf '%s|absent|-|-|-|-\n' "$target" >> "$backup_dir/manifest.tsv" + fi +} + +ensure_caddy_import() { + local target + target="$(destination "$CADDY_MAIN_TARGET")" + assert_safe_parent_chain "$target" + [[ ! -L "$target" ]] || die "refusing to modify symlinked Caddyfile" + if [[ -n "$DESTDIR" ]]; then + mkdir -p -- "$(dirname -- "$target")" + [[ -e "$target" ]] || : > "$target" + fi + [[ -f "$target" ]] || die "missing Caddyfile: $target" + if ! grep -Fqx 'import Caddyfile.d/*' "$target"; then + printf '\nimport Caddyfile.d/*\n' >> "$target" + fi + if [[ -n "$DESTDIR" ]]; then + chmod 0644 -- "$target" + else + chown root:root -- "$target" + chmod 0644 -- "$target" + fi +} + +create_deployment_backup() { + local backup_dir index=0 target timestamp + timestamp="$(date -u +%Y%m%dT%H%M%SZ)-$$" + backup_dir="$DEPLOY_BACKUP_ROOT/$timestamp" + (umask 077 && mkdir -- "$backup_dir") + chown root:root -- "$backup_dir" + chmod 0700 -- "$backup_dir" + printf 'version|1\n' > "$backup_dir/manifest.tsv" + chmod 0600 -- "$backup_dir/manifest.tsv" + for target in "${TARGETS[@]}"; do + index=$((index + 1)) + backup_target "$target" "$backup_dir" "$index" + done + printf '%s\n' "$backup_dir" +} + +validate_live_installation() { + /usr/bin/python3 -m py_compile "$RUNNER_TARGET" "$APP_DIR/start_suite.py" "$APP_DIR/maintenance_web.py" + /usr/sbin/visudo -cf "$SUDOERS_TARGET" + /usr/bin/systemd-analyze verify \ + /etc/systemd/system/pihole-maintenance-check.service \ + /etc/systemd/system/pihole-maintenance-backup.service \ + /etc/systemd/system/pihole-maintenance-update.service + /usr/bin/caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile +} + +activate_live_installation() { + /usr/bin/systemctl daemon-reload + /usr/bin/systemctl enable --now caddy + /usr/bin/systemctl reload caddy + /usr/bin/systemctl restart pihole-suite.service + /usr/bin/systemctl is-active --quiet pihole-suite.service + /usr/bin/curl --fail --silent --show-error --max-time 10 http://127.0.0.1:8090/ >/dev/null +} + +install_live() { + [[ ${EUID:-$(id -u)} -eq 0 ]] || die "live installation must run as root" + [[ -x /usr/bin/caddy ]] || die "Caddy must already be installed at /usr/bin/caddy" + [[ -f /etc/caddy/Caddyfile ]] || die "missing /etc/caddy/Caddyfile" + for command in /usr/bin/python3 /usr/sbin/visudo /usr/bin/systemd-analyze /usr/bin/systemctl /usr/bin/curl; do + [[ -x "$command" ]] || die "required command unavailable: $command" + done + prepare_runtime_directories + create_deployment_backup >/dev/null + ensure_caddy_import + local target + for target in "${TARGETS[@]}"; do + [[ "$target" == "$CADDY_MAIN_TARGET" ]] && continue + install_target "$target" + done + validate_live_installation + activate_live_installation +} + +install_staged() { + local target + prepare_runtime_directories + ensure_caddy_import + for target in "${TARGETS[@]}"; do + [[ "$target" == "$CADDY_MAIN_TARGET" ]] && continue + install_target "$target" + done +} + +main() { + parse_args "$@" + if [[ -n "$DESTDIR" ]]; then + install_staged + return + fi + install_live +} + +main "$@" diff --git a/scripts/rollback_maintenance_web.sh b/scripts/rollback_maintenance_web.sh new file mode 100755 index 0000000..a9cf384 --- /dev/null +++ b/scripts/rollback_maintenance_web.sh @@ -0,0 +1,143 @@ +#!/usr/bin/env bash +set -euo pipefail + +readonly BACKUP_ROOT="/var/backups/pihole-suite-deploy" +readonly MANIFEST_NAME="manifest.tsv" +readonly -a TARGETS=( + "/etc/systemd/system/pihole-maintenance-check.service" + "/etc/systemd/system/pihole-maintenance-backup.service" + "/etc/systemd/system/pihole-maintenance-update.service" + "/etc/sudoers.d/pihole-maintenance-web" + "/etc/caddy/Caddyfile" + "/etc/caddy/Caddyfile.d/pihole-maintenance.caddy" + "/usr/local/libexec/pihole-maintenance-runner" + "/usr/local/sbin/pihole-maintenance-web-rollback" + "/var/lib/pihole-suite/app/start_suite.py" + "/var/lib/pihole-suite/app/maintenance_web.py" + "/var/lib/pihole-suite/app/web/maintenance.html" + "/var/lib/pihole-suite/app/web/maintenance.css" + "/var/lib/pihole-suite/app/web/maintenance.js" +) + +die() { + printf 'maintenance web rollback: %s\n' "$*" >&2 + exit 1 +} + +is_allowed_target() { + local target + for target in "${TARGETS[@]}"; do + [[ "$1" == "$target" ]] && return 0 + done + return 1 +} + +assert_safe_parent_chain() { + local path + path="$(dirname -- "$1")" + while [[ "$path" != "/" && "$path" != "." ]]; do + [[ ! -L "$path" ]] || die "refusing symlinked parent directory: $path" + path="$(dirname -- "$path")" + done +} + +validate_backup_dir() { + local requested="$1" root_real backup_real + [[ ${EUID:-$(id -u)} -eq 0 ]] || die "rollback must run as root" + [[ -d "$requested" && ! -L "$requested" ]] || die "backup must be a real directory" + root_real="$(realpath -e -- "$BACKUP_ROOT")" + backup_real="$(realpath -e -- "$requested")" + [[ "$(dirname -- "$backup_real")" == "$root_real" ]] || die "backup must be directly below $BACKUP_ROOT" + [[ -f "$backup_real/$MANIFEST_NAME" && ! -L "$backup_real/$MANIFEST_NAME" ]] || die "missing safe manifest" + printf '%s\n' "$backup_real" +} + +validate_manifest() { + local backup_dir="$1" line target status saved mode uid gid digest actual_digest count=0 + declare -A seen=() + IFS= read -r line < "$backup_dir/$MANIFEST_NAME" + [[ "$line" == 'version|1' ]] || die "unsupported manifest" + [[ -d "$backup_dir/files" && ! -L "$backup_dir/files" ]] || die "missing safe artifact directory" + while IFS='|' read -r target status saved mode uid gid digest; do + [[ -n "$target" ]] || die "empty manifest entry" + is_allowed_target "$target" || die "unexpected manifest target" + [[ -z "${seen[$target]:-}" ]] || die "duplicate manifest target" + seen[$target]=1 + count=$((count + 1)) + case "$status" in + present) + [[ "$saved" =~ ^files/[0-9]+$ ]] || die "unsafe saved artifact path" + [[ -f "$backup_dir/$saved" && ! -L "$backup_dir/$saved" ]] || die "missing saved artifact" + [[ "$mode" =~ ^[0-7]{3,4}$ && "$uid" =~ ^[0-9]+$ && "$gid" =~ ^[0-9]+$ && "$digest" =~ ^[0-9a-f]{64}$ ]] || die "invalid saved artifact metadata" + actual_digest="$(sha256sum -- "$backup_dir/$saved" | awk '{print $1}')" + [[ "$actual_digest" == "$digest" ]] || die "saved artifact checksum mismatch" + assert_safe_parent_chain "$target" + [[ ! -L "$target" ]] || die "refusing to replace symlink target" + ;; + absent) + [[ "$saved" == '-' && "$mode" == '-' && "$uid" == '-' && "$gid" == '-' && "$digest" == '-' ]] || die "invalid absent entry" + assert_safe_parent_chain "$target" + [[ ! -L "$target" ]] || die "refusing to remove symlink target" + ;; + *) die "invalid manifest status" ;; + esac + done < <(tail -n +2 "$backup_dir/$MANIFEST_NAME") + [[ "$count" -eq "${#TARGETS[@]}" ]] || die "manifest does not cover all deployment artifacts" + for target in "${TARGETS[@]}"; do + [[ -n "${seen[$target]:-}" ]] || die "manifest misses expected target" + done +} + +restore_manifest() { + local backup_dir="$1" line target status saved mode uid gid digest + while IFS='|' read -r target status saved mode uid gid digest; do + case "$status" in + present) + assert_safe_parent_chain "$target" + [[ ! -L "$target" ]] || die "refusing to replace symlink target" + install -d -o root -g root -m 0755 "$(dirname -- "$target")" + install -o "$uid" -g "$gid" -m "$mode" -- "$backup_dir/$saved" "$target" + ;; + absent) + assert_safe_parent_chain "$target" + [[ ! -L "$target" ]] || die "refusing to remove symlink target" + rm -f -- "$target" + ;; + esac + done < <(tail -n +2 "$backup_dir/$MANIFEST_NAME") +} + +validate_and_activate() { + local unit artifact + local -a python_artifacts=() + for artifact in /usr/local/libexec/pihole-maintenance-runner /var/lib/pihole-suite/app/start_suite.py /var/lib/pihole-suite/app/maintenance_web.py; do + [[ -f "$artifact" ]] && python_artifacts+=("$artifact") + done + [[ ${#python_artifacts[@]} -gt 0 ]] && /usr/bin/python3 -m py_compile "${python_artifacts[@]}" + if [[ -f /etc/sudoers.d/pihole-maintenance-web ]]; then + /usr/sbin/visudo -cf /etc/sudoers.d/pihole-maintenance-web + fi + for unit in /etc/systemd/system/pihole-maintenance-*.service; do + [[ -e "$unit" ]] || continue + /usr/bin/systemd-analyze verify "$unit" + done + /usr/bin/caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile + /usr/bin/systemctl daemon-reload + /usr/bin/systemctl reload caddy + /usr/bin/systemctl restart pihole-suite.service + /usr/bin/systemctl is-active --quiet pihole-FTL.service + /usr/bin/systemctl is-active --quiet unbound.service + /usr/bin/dig +time=3 +tries=1 +short @127.0.0.1 example.com A | /usr/bin/grep -Eq '^[0-9.]+' + /usr/bin/dig +time=3 +tries=1 +short @127.0.0.1 -p 5335 example.com A | /usr/bin/grep -Eq '^[0-9.]+' +} + +main() { + [[ $# -eq 2 && "$1" == '--backup-dir' ]] || die "usage: $0 --backup-dir /var/backups/pihole-suite-deploy/NAME" + local backup_dir + backup_dir="$(validate_backup_dir "$2")" + validate_manifest "$backup_dir" + restore_manifest "$backup_dir" + validate_and_activate +} + +main "$@" diff --git a/tests/test_install_maintenance_web.sh b/tests/test_install_maintenance_web.sh new file mode 100755 index 0000000..6b602c3 --- /dev/null +++ b/tests/test_install_maintenance_web.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +DESTDIR="$(mktemp -d)" +GUARD_DIR="$(mktemp -d)" +UNSAFE_DESTDIR="$(mktemp -d)" +GUARD_LOG="$DESTDIR/forbidden-actions.log" +trap 'rm -rf -- "$DESTDIR" "$GUARD_DIR" "$UNSAFE_DESTDIR"' EXIT + +# A staged install must never touch packages, networking, or live services. +for command in apt apt-get caddy curl service sudo systemctl; do + printf '#!/usr/bin/env bash\nprintf "%%s\\n" "$0 $*" >> "%s"\nexit 97\n' "$GUARD_LOG" > "$GUARD_DIR/$command" + chmod 0755 "$GUARD_DIR/$command" +done + +run_staged_install() { + PATH="$GUARD_DIR:$PATH" bash "$ROOT_DIR/scripts/install_maintenance_web.sh" --destdir "$DESTDIR" +} + +run_staged_install +first_tree="$({ cd "$DESTDIR" && find . -type f -print0 | sort -z | xargs -0 sha256sum; })" +run_staged_install +second_tree="$({ cd "$DESTDIR" && find . -type f -print0 | sort -z | xargs -0 sha256sum; })" +test "$first_tree" = "$second_tree" +test ! -s "$GUARD_LOG" + +# A compromised prior app tree must not redirect a root-owned installation. +mkdir -p "$UNSAFE_DESTDIR/var/lib/pihole-suite/app" +ln -s /tmp "$UNSAFE_DESTDIR/var/lib/pihole-suite/app/web" +if PATH="$GUARD_DIR:$PATH" bash "$ROOT_DIR/scripts/install_maintenance_web.sh" --destdir "$UNSAFE_DESTDIR" >/dev/null 2>&1; then + printf '%s\n' 'symlinked app parent was accepted' >&2 + exit 1 +fi + +assert_mode() { + test "$(stat -c %a "$1")" = "$2" +} + +assert_mode "$DESTDIR/usr/local/libexec/pihole-maintenance-runner" 755 +assert_mode "$DESTDIR/usr/local/sbin/pihole-maintenance-web-rollback" 755 +assert_mode "$DESTDIR/var/lib/pihole-suite/app" 755 +assert_mode "$DESTDIR/var/lib/pihole-suite/app/web" 755 +assert_mode "$DESTDIR/var/lib/pihole-suite/app/start_suite.py" 644 +assert_mode "$DESTDIR/var/lib/pihole-suite/app/maintenance_web.py" 644 +assert_mode "$DESTDIR/var/lib/pihole-suite/app/web/maintenance.html" 644 +assert_mode "$DESTDIR/var/lib/pihole-suite/app/web/maintenance.css" 644 +assert_mode "$DESTDIR/var/lib/pihole-suite/app/web/maintenance.js" 644 + +for action in check backup update; do + unit="$DESTDIR/etc/systemd/system/pihole-maintenance-${action}.service" + assert_mode "$unit" 644 + grep -Fqx 'User=root' "$unit" + grep -Fqx 'Group=root' "$unit" + grep -Fqx 'UMask=0077' "$unit" + grep -Fqx 'Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin' "$unit" + grep -Fqx "ExecStart=/usr/local/libexec/pihole-maintenance-runner ${action}" "$unit" + ! grep -Eq '^Exec(Start|StartPre|StartPost)=.*(sh -c|bash -c)' "$unit" +done +grep -Fqx 'TimeoutStartSec=15min' "$DESTDIR/etc/systemd/system/pihole-maintenance-check.service" +grep -Fqx 'TimeoutStartSec=15min' "$DESTDIR/etc/systemd/system/pihole-maintenance-backup.service" +grep -Fqx 'TimeoutStartSec=120min' "$DESTDIR/etc/systemd/system/pihole-maintenance-update.service" + +SUDOERS="$DESTDIR/etc/sudoers.d/pihole-maintenance-web" +assert_mode "$SUDOERS" 440 +for action in check backup update; do + grep -Fqx "pihole-suite ALL=(root) NOPASSWD: /usr/bin/systemctl start --no-block pihole-maintenance-${action}.service" "$SUDOERS" +done +test "$(grep -Ec '^pihole-suite ALL=' "$SUDOERS")" = 3 +! grep -Eq '[*]|ALL[[:space:]]*=.*(sh|bash)' "$SUDOERS" + +CADDY_MAIN="$DESTDIR/etc/caddy/Caddyfile" +CADDY_SNIPPET="$DESTDIR/etc/caddy/Caddyfile.d/pihole-maintenance.caddy" +assert_mode "$CADDY_MAIN" 644 +assert_mode "$CADDY_SNIPPET" 644 +test "$(grep -Fxc 'import Caddyfile.d/*' "$CADDY_MAIN")" = 1 +grep -Fqx 'pi.hole:8443, 192.168.178.2:8443 {' "$CADDY_SNIPPET" +grep -Fqx ' tls internal' "$CADDY_SNIPPET" +grep -Fqx ' @lan remote_ip 192.168.178.0/24 127.0.0.1 ::1' "$CADDY_SNIPPET" +grep -Fqx ' handle @lan {' "$CADDY_SNIPPET" +grep -Fqx ' reverse_proxy 127.0.0.1:8090' "$CADDY_SNIPPET" +grep -Fqx ' respond "Forbidden" 403' "$CADDY_SNIPPET" +test "$(grep -Foc '{' "$CADDY_SNIPPET")" = "$(grep -Foc '}' "$CADDY_SNIPPET")" + +# Source intent: default Python API install is complete and root-owned, while +# privileged web exposure remains explicitly opt-in. +grep -Fqx 'INSTALL_MAINTENANCE_WEB=false' "$ROOT_DIR/install.sh" +grep -Fq -- '--with-maintenance-web) INSTALL_MAINTENANCE_WEB=true ;;' "$ROOT_DIR/install.sh" +grep -Fq -- '--skip-maintenance-web) INSTALL_MAINTENANCE_WEB=false ;;' "$ROOT_DIR/install.sh" +grep -Fq 'install_python_suite_app_files' "$ROOT_DIR/install.sh" +grep -Fq 'maintenance_web.py' "$ROOT_DIR/install.sh" +grep -Fq 'web/maintenance.html' "$ROOT_DIR/install.sh" +grep -Fq 'web/maintenance.css' "$ROOT_DIR/install.sh" +grep -Fq 'web/maintenance.js' "$ROOT_DIR/install.sh" +grep -Fq 'install -o root -g root -m 0644' "$ROOT_DIR/install.sh" +grep -Fq 'scripts/install_maintenance_web.sh' "$ROOT_DIR/install.sh" +grep -Fq 'DRY RUN: Would install privileged maintenance web exposure' "$ROOT_DIR/install.sh" +test -x "$ROOT_DIR/scripts/install_maintenance_web.sh" +test -x "$ROOT_DIR/scripts/rollback_maintenance_web.sh" + +# Deployment backup/rollback must be manifest-bound and preserve metadata. +grep -Fq 'manifest.tsv' "$ROOT_DIR/scripts/install_maintenance_web.sh" +grep -Fq 'sha256sum' "$ROOT_DIR/scripts/install_maintenance_web.sh" +grep -Fq 'install -o "$uid" -g "$gid" -m "$mode"' "$ROOT_DIR/scripts/rollback_maintenance_web.sh" +grep -Fq 'validate_manifest "$backup_dir"' "$ROOT_DIR/scripts/rollback_maintenance_web.sh" +grep -Fq 'restore_manifest "$backup_dir"' "$ROOT_DIR/scripts/rollback_maintenance_web.sh" +grep -Fq 'missing safe artifact directory' "$ROOT_DIR/scripts/rollback_maintenance_web.sh" +grep -Fq 'manifest does not cover all deployment artifacts' "$ROOT_DIR/scripts/rollback_maintenance_web.sh" +grep -Fq 'refusing to replace symlink target' "$ROOT_DIR/scripts/rollback_maintenance_web.sh" + +echo 'maintenance web staged installer: PASS' From e2cccf200c0f49a07fef75db96320f3bd179cfa5 Mon Sep 17 00:00:00 2001 From: TimInTech Date: Tue, 8 Sep 2026 13:20:10 +0200 Subject: [PATCH 08/24] docs: document maintenance web operations --- README.de.md | 15 ++++- README.md | 15 ++++- docs/CONSOLE_MENU.md | 9 +++ docs/MAINTENANCE_WEB.md | 120 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 157 insertions(+), 2 deletions(-) create mode 100644 docs/MAINTENANCE_WEB.md diff --git a/README.de.md b/README.de.md index 7498ae3..c48ccee 100644 --- a/README.de.md +++ b/README.de.md @@ -247,11 +247,24 @@ pip install -r requirements.txt # Starten python3 start_suite.py -# API-Doku: http://127.0.0.1:8090/docs +# Die API bleibt auf 127.0.0.1:8090; die Produktivdokumentation ist deaktiviert. ``` **Nicht erforderlich** für den Kern-Stack (Pi-hole + Unbound). +### Sichere Wartungsoberfläche + +Die optionale Wartungsoberfläche bietet Systemcheck, verifiziertes Backup und +kontrolliertes Update unter `https://pi.hole:8443/`. Sie bleibt standardmäßig +deaktiviert und wird nur zusammen mit Caddy bewusst freigeschaltet: + +```bash +sudo ./install.sh --with-maintenance-web +``` + +Siehe [docs/MAINTENANCE_WEB.md](docs/MAINTENANCE_WEB.md) für Voraussetzungen, +Zertifikatvertrauen, Sicherheitsgrenze und Rollback. + --- ## 🌐 Optional: NetAlertX diff --git a/README.md b/README.md index 8f55f65..beea7d9 100644 --- a/README.md +++ b/README.md @@ -249,11 +249,24 @@ pip install -r requirements.txt # Start python3 start_suite.py -# API docs: http://127.0.0.1:8090/docs +# The API stays on 127.0.0.1:8090; production API documentation is disabled. ``` **Not required** for the core Pi-hole + Unbound stack. +### Secure maintenance web panel + +The optional maintenance panel provides system checks, verified backups and +controlled updates at `https://pi.hole:8443/`. It remains disabled by default +and is enabled explicitly with Caddy: + +```bash +sudo ./install.sh --with-maintenance-web +``` + +See [docs/MAINTENANCE_WEB.md](docs/MAINTENANCE_WEB.md) for prerequisites, +certificate trust, security boundaries and rollback. + --- ## 🌐 Optional: NetAlertX diff --git a/docs/CONSOLE_MENU.md b/docs/CONSOLE_MENU.md index 179deea..3a8def7 100644 --- a/docs/CONSOLE_MENU.md +++ b/docs/CONSOLE_MENU.md @@ -15,6 +15,15 @@ The suite ships two interactive menus: Both menus use `scripts/lib/ui.sh` for consistent output formatting. +## Web Maintenance Panel + +The optional HTTPS panel is separate from both menus. After explicit +installation with `sudo ./install.sh --with-maintenance-web`, it provides only +system check, verified backup and controlled update at +`https://pi.hole:8443/`. It does not offer restore, backup download or deletion. +Use the existing Rescue menu for recovery operations. Operational details and +rollback are documented in [MAINTENANCE_WEB.md](MAINTENANCE_WEB.md). + --- ## Console Menu diff --git a/docs/MAINTENANCE_WEB.md b/docs/MAINTENANCE_WEB.md new file mode 100644 index 0000000..ef4ba58 --- /dev/null +++ b/docs/MAINTENANCE_WEB.md @@ -0,0 +1,120 @@ +# Pi-hole-Wartungsoberfläche + +Die Wartungsoberfläche stellt genau drei kontrollierte Aktionen bereit: +Systemcheck, verifiziertes Backup und Update. Sie ist nach erfolgreicher +Produktionsabnahme unter `https://pi.hole:8443/` sowie +`https://192.168.178.2:8443/` erreichbar. + +## Voraussetzungen und Installation + +Der Webdienst bleibt auf `127.0.0.1:8090`; Caddy veröffentlicht ausschließlich +HTTPS auf Port 8443 für `192.168.178.0/24` und Loopback. Für Caddy auf Debian +oder Raspberry Pi OS ist das offizielle Stable-Repository zu verwenden: + +```bash +sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl +curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg +curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list +sudo chmod o+r /usr/share/keyrings/caddy-stable-archive-keyring.gpg +sudo chmod o+r /etc/apt/sources.list.d/caddy-stable.list +sudo apt update +sudo apt install caddy +``` + +Danach installiert der vorhandene Projekt-Installer den Wartungsdienst gezielt: + +```bash +sudo ./install.sh --with-maintenance-web +``` + +Ohne diese Option bleibt die öffentliche Wartungsfreigabe deaktiviert. Ein +Dry-Run installiert oder aktiviert die Wartungsoberfläche nicht: + +```bash +sudo ./install.sh --dry-run --with-maintenance-web +``` + +Die Caddy-Konfiguration verwendet `tls internal`. Der verwendete interne +Aussteller muss auf den administrierenden Clients über den vorgesehenen, +vertrauenswürdigen CA-Verteilweg installiert werden. Keine Browserwarnung +wegklicken und kein Zertifikatsschutz deaktivieren. + +## Anmeldung und Bedienung + +Das Anmeldekennwort ist der bestehende `SUITE_API_KEY`. Es wird nicht in der +Shell-Historie gespeichert, wenn es lokal nur ausgegeben und direkt in das +Passwortfeld eingegeben wird: + +```bash +sudo awk -F= '$1 == "SUITE_API_KEY" { print $2 }' /etc/pihole-suite/pihole-suite.env +``` + +Nach erfolgreicher Anmeldung verwendet der Browser eine kurzlebige, +serverseitige Sitzung. Das Kennwort wird nicht als Cookie, API-Antwort oder +Jobzustand gespeichert. Backup verlangt eine sichtbare Bestätigung; Update +verlangt zusätzlich die exakte Eingabe `UPDATE`. + +## Aktionen und Jobzustand + +`Systemcheck` prüft Pi-hole FTL, Unbound, lokale DNS-Pfade auf 53 und 5335, +Konfiguration, Listener und eine lesende APT-Simulation. Ein Pflichtfehler +macht den Job fehlerhaft. + +`Backup` erstellt ein SQLite-Online-Backup von vorhandenen Pi-hole-Datenbanken, +kopiert Pi-hole-/Unbound-Konfiguration ohne Symlink-Traversierung und +veröffentlicht das Ergebnis erst nach `quick_check` und Manifest-Prüfsummen. +Backups liegen unter `/var/backups/pihole-suite/`, sind mit `0700` geschützt und +werden auf die zehn neuesten verifizierten Sicherungen begrenzt. + +`Update` erstellt zuerst dasselbe verifizierte Backup und führt danach in fester +Reihenfolge `apt-get update`, nichtinteraktives `apt-get -y upgrade`, +`pihole -up`, `pihole -g` und den vollständigen Systemcheck aus. Es gibt weder +`autoremove` noch einen automatischen Neustart. + +Aktuelle und historische Zustände liegen atomar in +`/var/lib/pihole-suite/jobs/`. Sie enthalten nur strukturierte Statusfelder, +keine Befehlsausgaben, Domains, Clients, Zugangsdaten oder Browserdaten. + +## Sicherheitsgrenze + +Der FastAPI-Prozess läuft als `pihole-suite`, nicht als root. Er darf nur diese +drei festen Befehle per passwortlosem sudo auslösen: + +```text +/usr/bin/systemctl start --no-block pihole-maintenance-check.service +/usr/bin/systemctl start --no-block pihole-maintenance-backup.service +/usr/bin/systemctl start --no-block pihole-maintenance-update.service +``` + +Alle Root-Jobs teilen eine Sperre. Ein gleichzeitiger zweiter Start wird über +die Web-API mit `409` abgewiesen. Web-Restore, Download und Löschen von Backups +sind absichtlich nicht vorhanden. + +## Prüfung und Rollback + +Nach der Installation müssen mindestens diese Nachweise erfolgreich sein: + +```bash +sudo systemctl is-active --quiet pihole-FTL unbound pihole-suite caddy +sudo ss -ltnp | grep -F '127.0.0.1:8090' +sudo ss -ltnp | grep -F ':8443' +dig +short @127.0.0.1 example.com +dig +short @127.0.0.1 -p 5335 example.com +``` + +Der Installer erzeugt vor dem Austausch ein root-eigenes Deployment-Backup +unter `/var/backups/pihole-suite-deploy/`. Für einen Rollback zuerst den +konkreten Sicherungsordner ermitteln und dann genau diesen wiederherstellen: + +```bash +sudo find /var/backups/pihole-suite-deploy -mindepth 1 -maxdepth 1 -type d -printf '%f\n' +sudo /usr/local/sbin/pihole-maintenance-web-rollback --backup-dir /var/backups/pihole-suite-deploy/PLATZHALTER_BACKUP_NAME +``` + +`PLATZHALTER_BACKUP_NAME` ist durch den tatsächlich angezeigten Sicherungsordner zu ersetzen. Der +Rollback akzeptiert nur direkte, nicht verlinkte Unterordner dieses Wurzelpfads, +prüft Manifest, Prüfsummen, Eigentümer und Modi vor der Wiederherstellung und +berührt keine regulären Wartungsbackups unter `/var/backups/pihole-suite/`. + +Die Caddy-Installationsschritte folgen der offiziellen +[Caddy-Dokumentation](https://caddyserver.com/docs/install). From 7dc43449a43ac565bf87fffecc51e8d93b356dc2 Mon Sep 17 00:00:00 2001 From: TimInTech Date: Tue, 8 Sep 2026 13:30:55 +0200 Subject: [PATCH 09/24] fix: enforce maintenance runtime bounds --- maintenance_runner.py | 9 +++++++-- scripts/install_maintenance_web.sh | 4 +++- start_suite.py | 2 ++ tests/test_maintenance_runner.py | 18 ++++++++++++++---- tests/test_maintenance_web.py | 10 ++++++++++ 5 files changed, 36 insertions(+), 7 deletions(-) diff --git a/maintenance_runner.py b/maintenance_runner.py index d784323..39d82b8 100644 --- a/maintenance_runner.py +++ b/maintenance_runner.py @@ -18,6 +18,7 @@ import subprocess import sys import tempfile +import time import uuid from dataclasses import dataclass from pathlib import Path, PurePosixPath @@ -27,7 +28,9 @@ STATE_FILE_MODE = 0o640 BACKUP_DIR_MODE = 0o700 CHECK_COMMAND_TIMEOUT_SECONDS = 30 -UPDATE_COMMAND_TIMEOUT_SECONDS = 7_200 +# The unit permits 7,200 seconds. Keep a ten-minute reserve for a clean failure +# state and the required final health check before systemd can terminate us. +UPDATE_JOB_TIMEOUT_SECONDS = 6_600 PROCESS_TERMINATION_GRACE_SECONDS = 5 @@ -437,6 +440,7 @@ def _run_backup(context: RunnerContext, job_id: str) -> tuple[list[dict[str, Any def _run_update(context: RunnerContext, job_id: str) -> tuple[list[dict[str, Any]], str | None, str | None]: + deadline = time.monotonic() + UPDATE_JOB_TIMEOUT_SECONDS steps, error, backup_path = _run_backup(context, job_id) if error: return steps, error, backup_path @@ -446,7 +450,8 @@ def _run_update(context: RunnerContext, job_id: str) -> tuple[list[dict[str, Any ("pihole_update", [context.pihole_bin, "-up"]), ("pihole_gravity", [context.pihole_bin, "-g"]), ): - step, _ = _command_step(name, argv, context, timeout_seconds=UPDATE_COMMAND_TIMEOUT_SECONDS) + remaining_seconds = max(1, int(deadline - time.monotonic())) + step, _ = _command_step(name, argv, context, timeout_seconds=remaining_seconds) steps.append(step) if not step["ok"]: return steps, "update_failed", backup_path diff --git a/scripts/install_maintenance_web.sh b/scripts/install_maintenance_web.sh index fa8da19..ea8f197 100755 --- a/scripts/install_maintenance_web.sh +++ b/scripts/install_maintenance_web.sh @@ -216,6 +216,8 @@ activate_live_installation() { /usr/bin/systemctl reload caddy /usr/bin/systemctl restart pihole-suite.service /usr/bin/systemctl is-active --quiet pihole-suite.service + /usr/bin/ss -ltnp '( sport = :8090 )' | /usr/bin/grep -Fq '127.0.0.1:8090' || die "pihole-suite is not listening only on 127.0.0.1:8090" + ! /usr/bin/ss -ltnp '( sport = :8090 )' | /usr/bin/grep -Eq '(0\.0\.0\.0|\[::\]):8090' || die "pihole-suite exposed port 8090 outside loopback" /usr/bin/curl --fail --silent --show-error --max-time 10 http://127.0.0.1:8090/ >/dev/null } @@ -223,7 +225,7 @@ install_live() { [[ ${EUID:-$(id -u)} -eq 0 ]] || die "live installation must run as root" [[ -x /usr/bin/caddy ]] || die "Caddy must already be installed at /usr/bin/caddy" [[ -f /etc/caddy/Caddyfile ]] || die "missing /etc/caddy/Caddyfile" - for command in /usr/bin/python3 /usr/sbin/visudo /usr/bin/systemd-analyze /usr/bin/systemctl /usr/bin/curl; do + for command in /usr/bin/python3 /usr/sbin/visudo /usr/bin/systemd-analyze /usr/bin/systemctl /usr/bin/curl /usr/bin/ss /usr/bin/grep; do [[ -x "$command" ]] || die "required command unavailable: $command" done prepare_runtime_directories diff --git a/start_suite.py b/start_suite.py index 8607df4..30dc66a 100755 --- a/start_suite.py +++ b/start_suite.py @@ -331,6 +331,8 @@ def stats() -> dict[str, Any]: def main() -> None: port = int(_env("SUITE_PORT", default="8090")) host = _env("SUITE_HOST", default="127.0.0.1") + if host != "127.0.0.1": + raise RuntimeError("SUITE_HOST must be 127.0.0.1 for the maintenance web service") uvicorn.run(app, host=host, port=port, log_level=os.getenv("SUITE_LOG_LEVEL", "info").lower()) diff --git a/tests/test_maintenance_runner.py b/tests/test_maintenance_runner.py index b0f3d5f..cef4261 100644 --- a/tests/test_maintenance_runner.py +++ b/tests/test_maintenance_runner.py @@ -347,18 +347,28 @@ def test_job_state_persists_only_allowlisted_structured_command_results(tmp_path assert set(step) <= {"name", "required", "ok", "detail"} -def test_update_uses_long_timeouts_while_final_check_uses_short_timeouts(tmp_path): +def test_update_uses_a_bounded_job_timeout_while_final_check_uses_short_timeouts(tmp_path): context, commands = make_context(tmp_path) assert run_job("update", context)["state"] == "succeeded" timeouts_by_command = dict(zip((tuple(call) for call in commands.calls), commands.timeouts, strict=True)) - assert timeouts_by_command[("/usr/bin/apt-get", "update")] == 7200 - assert timeouts_by_command[("/usr/bin/apt-get", "-y", "upgrade")] == 7200 - assert timeouts_by_command[(context.pihole_bin, "-up")] == 7200 + assert timeouts_by_command[("/usr/bin/apt-get", "update")] <= maintenance_runner.UPDATE_JOB_TIMEOUT_SECONDS + assert timeouts_by_command[("/usr/bin/apt-get", "-y", "upgrade")] <= maintenance_runner.UPDATE_JOB_TIMEOUT_SECONDS + assert timeouts_by_command[(context.pihole_bin, "-up")] <= maintenance_runner.UPDATE_JOB_TIMEOUT_SECONDS assert timeouts_by_command[("/usr/bin/dig", "+short", "@127.0.0.1", "example.com", "+time=3", "+tries=1")] == 30 +def test_update_reduces_later_command_timeout_to_fit_job_deadline(tmp_path, monkeypatch): + context, commands = make_context(tmp_path) + monotonic_values = iter((0.0, 0.0, 120.0, 240.0, 360.0)) + monkeypatch.setattr(maintenance_runner.time, "monotonic", lambda: next(monotonic_values)) + + assert run_job("update", context)["state"] == "succeeded" + + assert commands.timeouts[:4] == [6600, 6480, 6360, 6240] + + def test_timed_out_command_terminates_its_entire_process_group(tmp_path, monkeypatch): child_pid_path = tmp_path / "child.pid" child_code = "import signal, time; signal.signal(signal.SIGTERM, signal.SIG_IGN); time.sleep(60)" diff --git a/tests/test_maintenance_web.py b/tests/test_maintenance_web.py index 1971221..76e8a4e 100644 --- a/tests/test_maintenance_web.py +++ b/tests/test_maintenance_web.py @@ -362,3 +362,13 @@ def test_suite_keeps_the_legacy_monitoring_api(monkeypatch: pytest.MonkeyPatch): route.path for route in suite.app.routes if hasattr(route, "path") } assert suite_client.get("/health", headers={"X-API-Key": "correct horse battery staple"}).status_code == 200 + + +def test_suite_rejects_a_non_loopback_bind_host(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("SUITE_API_KEY", "correct horse battery staple") + monkeypatch.setenv("SUITE_HOST", "0.0.0.0") + sys.modules.pop("start_suite", None) + suite = importlib.import_module("start_suite") + + with pytest.raises(RuntimeError, match="SUITE_HOST must be 127.0.0.1"): + suite.main() From 1d02ec095a02783a5410b786ad39570c6dce7a3d Mon Sep 17 00:00:00 2001 From: TimInTech Date: Tue, 8 Sep 2026 13:36:04 +0200 Subject: [PATCH 10/24] fix: harden suite upgrade migration --- docs/MAINTENANCE_WEB.md | 10 +++-- install.sh | 65 ++++++++++++++++++++++----- scripts/install_maintenance_web.sh | 48 ++++++++++++++++++-- scripts/rollback_maintenance_web.sh | 1 + tests/test_install_maintenance_web.sh | 46 ++++++++++++++++++- 5 files changed, 151 insertions(+), 19 deletions(-) diff --git a/docs/MAINTENANCE_WEB.md b/docs/MAINTENANCE_WEB.md index ef4ba58..bd763d1 100644 --- a/docs/MAINTENANCE_WEB.md +++ b/docs/MAINTENANCE_WEB.md @@ -7,7 +7,8 @@ Produktionsabnahme unter `https://pi.hole:8443/` sowie ## Voraussetzungen und Installation -Der Webdienst bleibt auf `127.0.0.1:8090`; Caddy veröffentlicht ausschließlich +Der Webdienst bleibt verbindlich auf `127.0.0.1:8090`; ein abweichender +`SUITE_HOST` wird beim Start abgewiesen. Caddy veröffentlicht ausschließlich HTTPS auf Port 8443 für `192.168.178.0/24` und Loopback. Für Caddy auf Debian oder Raspberry Pi OS ist das offizielle Stable-Repository zu verwenden: @@ -102,8 +103,11 @@ dig +short @127.0.0.1 example.com dig +short @127.0.0.1 -p 5335 example.com ``` -Der Installer erzeugt vor dem Austausch ein root-eigenes Deployment-Backup -unter `/var/backups/pihole-suite-deploy/`. Für einen Rollback zuerst den +Der Installer erzeugt vor dem ersten Austausch der Suite-Dateien ein +root-eigenes Deployment-Backup unter `/var/backups/pihole-suite-deploy/`. +Ein bestehendes Python-Environment wird dabei nie als root ausgeführt, sondern +in einem neuen root-eigenen Environment aufgebaut und erst nach erfolgreicher +Abhängigkeitsinstallation übernommen. Für einen Rollback zuerst den konkreten Sicherungsordner ermitteln und dann genau diesen wiederherstellen: ```bash diff --git a/install.sh b/install.sh index c6456c5..4cf82a3 100755 --- a/install.sh +++ b/install.sh @@ -151,17 +151,50 @@ ensure_suite_env_file() { } -ensure_python_venv() { - # Ensure a working venv with an executable python at $venv_dir/bin/python - # Recreates the venv if python is missing (common after interrupted installs). +assert_no_symlink_in_path() { + local path="$1" + [[ "$path" = /* ]] || { + log_error "Expected an absolute path: $path" + return 1 + } + while [[ "$path" != "/" ]]; do + if sudo test -L "$path"; then + log_error "Refusing symlinked privileged path: $path" + return 1 + fi + path="$(dirname -- "$path")" + done +} + +rebuild_python_venv() { + # Never execute a pre-existing virtualenv: older releases made it writable + # by the service account, so it is untrusted during an upgrade. local venv_dir="$1" + local staging_dir previous_dir [[ -z "$venv_dir" ]] && return 1 + assert_no_symlink_in_path "$venv_dir" || return 1 + previous_dir="${venv_dir}.previous" + assert_no_symlink_in_path "$previous_dir" || return 1 - if [[ ! -x "$venv_dir/bin/python" ]]; then - log_warning "Python venv missing/broken at $venv_dir; recreating" - sudo rm -rf "$venv_dir" 2>/dev/null || true - python3 -m venv "$venv_dir" + staging_dir="$(sudo mktemp -d "$(dirname -- "$venv_dir")/.venv-bootstrap.XXXXXX")" || return 1 + if ! sudo /usr/bin/python3 -m venv "$staging_dir"; then + sudo rm -rf -- "$staging_dir" + return 1 fi + if ! sudo "$staging_dir/bin/pip" install -r "$SCRIPT_DIR/requirements.txt"; then + sudo rm -rf -- "$staging_dir" + return 1 + fi + + if [[ -e "$previous_dir" ]]; then + sudo rm -rf -- "$previous_dir" + fi + if [[ -e "$venv_dir" ]]; then + sudo mv -- "$venv_dir" "$previous_dir" + fi + sudo mv -- "$staging_dir" "$venv_dir" + sudo chown -R root:root "$venv_dir" + sudo chmod 0755 "$venv_dir" } # ============================================= @@ -1103,6 +1136,11 @@ install_python_suite_app_files() { "web/maintenance.js" ) + assert_no_symlink_in_path "$STATE_DIR" || exit 1 + assert_no_symlink_in_path "$suite_app_dir" || exit 1 + # The old installer made this code directory service-writable. Remove that + # legacy tree instead of following or reusing it during a privileged upgrade. + sudo rm -rf -- "$suite_app_dir" sudo install -d -o root -g root -m 0755 "$STATE_DIR" "$suite_app_dir" "$suite_app_dir/web" for relative_file in "${app_files[@]}"; do source_file="$SCRIPT_DIR/$relative_file" @@ -1156,8 +1194,7 @@ setup_python_suite() { sudo install -d -o root -g "$suite_group" -m 0750 "$suite_data_dir" "$suite_state_dir/jobs" sudo install -d -o root -g "$suite_group" -m 0770 "$suite_state_dir/sessions" - ensure_python_venv "$suite_venv_dir" - "$suite_venv_dir/bin/pip" install -r "$SCRIPT_DIR/requirements.txt" || { + rebuild_python_venv "$suite_venv_dir" || { log_error "Python requirements failed"; exit 1; } sudo chown -R root:root "$suite_app_dir" "$suite_venv_dir" @@ -1291,6 +1328,7 @@ run_healthchecks() { # MAIN # ============================================= main() { + local maintenance_backup_dir="" parse_args "$@" if [[ "$INSTALL_MAINTENANCE_WEB" == true && "$INSTALL_PYTHON_SUITE" != true ]]; then log_error "--with-maintenance-web requires the Python API Suite" @@ -1304,6 +1342,9 @@ main() { init_state validate_state_against_system check_dependencies + if [[ "$INSTALL_MAINTENANCE_WEB" == true && "$DRY_RUN" == false ]]; then + maintenance_backup_dir="$("$SCRIPT_DIR/scripts/install_maintenance_web.sh" --backup-only)" + fi handle_systemd_resolved check_ports install_packages @@ -1331,7 +1372,7 @@ main() { if [[ "$DRY_RUN" == true ]]; then log "DRY RUN: Would install privileged maintenance web exposure" else - "$SCRIPT_DIR/scripts/install_maintenance_web.sh" + "$SCRIPT_DIR/scripts/install_maintenance_web.sh" --backup-dir "$maintenance_backup_dir" fi else log "⏭️ Skipping privileged maintenance web exposure (default; use --with-maintenance-web)" @@ -1397,4 +1438,6 @@ main() { echo " 3. Monitor with: ./check.sh" } -main "$@" +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + main "$@" +fi diff --git a/scripts/install_maintenance_web.sh b/scripts/install_maintenance_web.sh index ea8f197..93f790f 100755 --- a/scripts/install_maintenance_web.sh +++ b/scripts/install_maintenance_web.sh @@ -4,6 +4,8 @@ set -euo pipefail SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" SOURCE_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)" DESTDIR="" +BACKUP_ONLY=false +BACKUP_DIR="" readonly DEPLOY_BACKUP_ROOT="/var/backups/pihole-suite-deploy" readonly APP_DIR="/var/lib/pihole-suite/app" @@ -12,6 +14,7 @@ readonly ROLLBACK_TARGET="/usr/local/sbin/pihole-maintenance-web-rollback" readonly CADDY_MAIN_TARGET="/etc/caddy/Caddyfile" readonly CADDY_TARGET="/etc/caddy/Caddyfile.d/pihole-maintenance.caddy" readonly SUDOERS_TARGET="/etc/sudoers.d/pihole-maintenance-web" +readonly SUITE_SERVICE_TARGET="/etc/systemd/system/pihole-suite.service" readonly -a TARGETS=( "/etc/systemd/system/pihole-maintenance-check.service" @@ -27,10 +30,11 @@ readonly -a TARGETS=( "$APP_DIR/web/maintenance.html" "$APP_DIR/web/maintenance.css" "$APP_DIR/web/maintenance.js" + "$SUITE_SERVICE_TARGET" ) usage() { - printf '%s\n' "Usage: $0 [--destdir ABSOLUTE_PATH]" + printf '%s\n' "Usage: $0 [--destdir ABSOLUTE_PATH] [--backup-only | --backup-dir ABSOLUTE_PATH]" } die() { @@ -46,6 +50,15 @@ parse_args() { DESTDIR="$2" shift 2 ;; + --backup-only) + BACKUP_ONLY=true + shift + ;; + --backup-dir) + [[ $# -ge 2 ]] || die "--backup-dir requires a path" + BACKUP_DIR="$2" + shift 2 + ;; --help|-h) usage exit 0 @@ -58,6 +71,9 @@ parse_args() { [[ ! -L "$DESTDIR" ]] || die "DESTDIR must not be a symlink" mkdir -p -- "$DESTDIR" fi + [[ "$BACKUP_ONLY" == false || -z "$DESTDIR" ]] || die "--backup-only cannot use --destdir" + [[ -z "$BACKUP_DIR" || ( "$BACKUP_DIR" = /* && "$BACKUP_DIR" != "/" ) ]] || die "--backup-dir must be a non-root absolute path" + [[ "$BACKUP_ONLY" == false || -z "$BACKUP_DIR" ]] || die "choose either --backup-only or --backup-dir" } destination() { @@ -93,10 +109,15 @@ source_for_target() { "$APP_DIR/web/maintenance.html") printf '%s\n' "$SOURCE_DIR/web/maintenance.html" ;; "$APP_DIR/web/maintenance.css") printf '%s\n' "$SOURCE_DIR/web/maintenance.css" ;; "$APP_DIR/web/maintenance.js") printf '%s\n' "$SOURCE_DIR/web/maintenance.js" ;; + "$SUITE_SERVICE_TARGET") die "Python Suite service is generated by install.sh, not copied" ;; *) die "internal unexpected target: $1" ;; esac } +is_generated_target() { + [[ "$1" == "$CADDY_MAIN_TARGET" || "$1" == "$SUITE_SERVICE_TARGET" ]] +} + mode_for_target() { case "$1" in "$RUNNER_TARGET"|"$ROLLBACK_TARGET") printf '%s\n' 0755 ;; @@ -200,6 +221,15 @@ create_deployment_backup() { printf '%s\n' "$backup_dir" } +validate_existing_backup_dir() { + local backup_real root_real + [[ -d "$BACKUP_DIR" && ! -L "$BACKUP_DIR" ]] || die "backup directory is not a real directory" + root_real="$(realpath -e -- "$DEPLOY_BACKUP_ROOT")" + backup_real="$(realpath -e -- "$BACKUP_DIR")" + [[ "$(dirname -- "$backup_real")" == "$root_real" ]] || die "backup directory must be directly below $DEPLOY_BACKUP_ROOT" + [[ -f "$backup_real/manifest.tsv" && ! -L "$backup_real/manifest.tsv" ]] || die "backup directory has no safe manifest" +} + validate_live_installation() { /usr/bin/python3 -m py_compile "$RUNNER_TARGET" "$APP_DIR/start_suite.py" "$APP_DIR/maintenance_web.py" /usr/sbin/visudo -cf "$SUDOERS_TARGET" @@ -229,11 +259,15 @@ install_live() { [[ -x "$command" ]] || die "required command unavailable: $command" done prepare_runtime_directories - create_deployment_backup >/dev/null + if [[ -n "$BACKUP_DIR" ]]; then + validate_existing_backup_dir + else + create_deployment_backup >/dev/null + fi ensure_caddy_import local target for target in "${TARGETS[@]}"; do - [[ "$target" == "$CADDY_MAIN_TARGET" ]] && continue + is_generated_target "$target" && continue install_target "$target" done validate_live_installation @@ -245,13 +279,19 @@ install_staged() { prepare_runtime_directories ensure_caddy_import for target in "${TARGETS[@]}"; do - [[ "$target" == "$CADDY_MAIN_TARGET" ]] && continue + is_generated_target "$target" && continue install_target "$target" done } main() { parse_args "$@" + if [[ "$BACKUP_ONLY" == true ]]; then + [[ ${EUID:-$(id -u)} -eq 0 ]] || die "backup creation must run as root" + install -d -o root -g root -m 0700 "$DEPLOY_BACKUP_ROOT" + create_deployment_backup + return + fi if [[ -n "$DESTDIR" ]]; then install_staged return diff --git a/scripts/rollback_maintenance_web.sh b/scripts/rollback_maintenance_web.sh index a9cf384..09bb98f 100755 --- a/scripts/rollback_maintenance_web.sh +++ b/scripts/rollback_maintenance_web.sh @@ -17,6 +17,7 @@ readonly -a TARGETS=( "/var/lib/pihole-suite/app/web/maintenance.html" "/var/lib/pihole-suite/app/web/maintenance.css" "/var/lib/pihole-suite/app/web/maintenance.js" + "/etc/systemd/system/pihole-suite.service" ) die() { diff --git a/tests/test_install_maintenance_web.sh b/tests/test_install_maintenance_web.sh index 6b602c3..ba924ad 100755 --- a/tests/test_install_maintenance_web.sh +++ b/tests/test_install_maintenance_web.sh @@ -5,8 +5,9 @@ ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" DESTDIR="$(mktemp -d)" GUARD_DIR="$(mktemp -d)" UNSAFE_DESTDIR="$(mktemp -d)" +VENV_TEST_ROOT="$(mktemp -d)" GUARD_LOG="$DESTDIR/forbidden-actions.log" -trap 'rm -rf -- "$DESTDIR" "$GUARD_DIR" "$UNSAFE_DESTDIR"' EXIT +trap 'rm -rf -- "$DESTDIR" "$GUARD_DIR" "$UNSAFE_DESTDIR" "$VENV_TEST_ROOT"' EXIT # A staged install must never touch packages, networking, or live services. for command in apt apt-get caddy curl service sudo systemctl; do @@ -33,6 +34,45 @@ if PATH="$GUARD_DIR:$PATH" bash "$ROOT_DIR/scripts/install_maintenance_web.sh" - exit 1 fi +# An upgrade must not execute a legacy service-writable venv binary as root. +VENV_TEST_ROOT="$VENV_TEST_ROOT" ROOT_DIR="$ROOT_DIR" bash -c ' + source "$ROOT_DIR/install.sh" + sudo() { + if [[ "$1" == "/usr/bin/python3" && "$2" == "-m" && "$3" == "venv" ]]; then + mkdir -p "$4/bin" + printf "#!/usr/bin/env bash\\nprintf safe > \\\"$VENV_TEST_ROOT/safe-pip-ran\\\"\\n" > "$4/bin/pip" + chmod 0755 "$4/bin/pip" + return 0 + fi + if [[ "$1" == "chown" ]]; then + return 0 + fi + "$@" + } + venv="$VENV_TEST_ROOT/venv" + mkdir -p "$venv/bin" + printf "#!/usr/bin/env bash\\nprintf compromised > \\\"$VENV_TEST_ROOT/old-pip-ran\\\"\\n" > "$venv/bin/pip" + chmod 0755 "$venv/bin/pip" + rebuild_python_venv "$venv" + test ! -e "$VENV_TEST_ROOT/old-pip-ran" + test -f "$VENV_TEST_ROOT/safe-pip-ran" + test -x "$venv/bin/pip" + test -x "$venv.previous/bin/pip" +' + +# A legacy app symlink must be rejected before root writes application files. +if VENV_TEST_ROOT="$VENV_TEST_ROOT" ROOT_DIR="$ROOT_DIR" bash -c ' + source "$ROOT_DIR/install.sh" + sudo() { "$@"; } + STATE_DIR="$VENV_TEST_ROOT/state" + mkdir -p "$STATE_DIR" + ln -s /tmp "$STATE_DIR/app" + install_python_suite_app_files +' >/dev/null 2>&1; then + printf '%s\n' 'legacy app symlink was accepted' >&2 + exit 1 +fi + assert_mode() { test "$(stat -c %a "$1")" = "$2" } @@ -101,11 +141,15 @@ test -x "$ROOT_DIR/scripts/rollback_maintenance_web.sh" # Deployment backup/rollback must be manifest-bound and preserve metadata. grep -Fq 'manifest.tsv' "$ROOT_DIR/scripts/install_maintenance_web.sh" grep -Fq 'sha256sum' "$ROOT_DIR/scripts/install_maintenance_web.sh" +grep -Fq -- '--backup-only' "$ROOT_DIR/scripts/install_maintenance_web.sh" +grep -Fq -- '--backup-dir' "$ROOT_DIR/scripts/install_maintenance_web.sh" +grep -Fq '/etc/systemd/system/pihole-suite.service' "$ROOT_DIR/scripts/install_maintenance_web.sh" grep -Fq 'install -o "$uid" -g "$gid" -m "$mode"' "$ROOT_DIR/scripts/rollback_maintenance_web.sh" grep -Fq 'validate_manifest "$backup_dir"' "$ROOT_DIR/scripts/rollback_maintenance_web.sh" grep -Fq 'restore_manifest "$backup_dir"' "$ROOT_DIR/scripts/rollback_maintenance_web.sh" grep -Fq 'missing safe artifact directory' "$ROOT_DIR/scripts/rollback_maintenance_web.sh" grep -Fq 'manifest does not cover all deployment artifacts' "$ROOT_DIR/scripts/rollback_maintenance_web.sh" grep -Fq 'refusing to replace symlink target' "$ROOT_DIR/scripts/rollback_maintenance_web.sh" +grep -Fq '/etc/systemd/system/pihole-suite.service' "$ROOT_DIR/scripts/rollback_maintenance_web.sh" echo 'maintenance web staged installer: PASS' From 2f8dd607a2013363bcc4f967a2ad60b36d773a18 Mon Sep 17 00:00:00 2001 From: TimInTech Date: Tue, 8 Sep 2026 13:37:20 +0200 Subject: [PATCH 11/24] docs: normalize maintenance web planning files --- docs/superpowers/plans/2026-09-07-maintenance-web.md | 1 - docs/superpowers/specs/2026-09-07-maintenance-web-design.md | 1 - 2 files changed, 2 deletions(-) diff --git a/docs/superpowers/plans/2026-09-07-maintenance-web.md b/docs/superpowers/plans/2026-09-07-maintenance-web.md index 7252df2..7a9f67f 100644 --- a/docs/superpowers/plans/2026-09-07-maintenance-web.md +++ b/docs/superpowers/plans/2026-09-07-maintenance-web.md @@ -222,4 +222,3 @@ grep -F "/usr/bin/systemctl start --no-block pihole-maintenance-update.service" - [ ] **Step 9: Append the final architecture, verified production state, exact rollback point, and genuine blockers to the existing Vault handover. Run diff checks, secret scan, and the canonical Vault sync helper.** - [ ] **Step 10: Commit documentation as docs: document maintenance web operations and record exact acceptance evidence. Mark complete only when every spec criterion has current direct evidence.** - diff --git a/docs/superpowers/specs/2026-09-07-maintenance-web-design.md b/docs/superpowers/specs/2026-09-07-maintenance-web-design.md index 5307f60..391baad 100644 --- a/docs/superpowers/specs/2026-09-07-maintenance-web-design.md +++ b/docs/superpowers/specs/2026-09-07-maintenance-web-design.md @@ -178,4 +178,3 @@ werden durch Rollback nicht gelöscht. 7. Gleichzeitige zweite Jobstarts werden mit HTTP 409 abgewiesen. 8. Nach allen Tests sind `pihole-FTL`, `unbound`, `pihole-suite` und `caddy` aktiv; DNS über Port 53 und 5335 funktioniert. - From ae4b7da0a517c0bf9d6693afb004c87bcd2e4958 Mon Sep 17 00:00:00 2001 From: TimInTech Date: Tue, 8 Sep 2026 13:38:40 +0200 Subject: [PATCH 12/24] fix: force hardened suite migration --- install.sh | 16 ++++++---------- tests/test_install_maintenance_web.sh | 3 +++ 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/install.sh b/install.sh index 4cf82a3..98b2fd1 100755 --- a/install.sh +++ b/install.sh @@ -1137,6 +1137,9 @@ install_python_suite_app_files() { ) assert_no_symlink_in_path "$STATE_DIR" || exit 1 + # Claim the legacy state parent before examining a service-owned child, so + # the service account cannot race this privileged migration. + sudo install -d -o root -g root -m 0755 "$STATE_DIR" assert_no_symlink_in_path "$suite_app_dir" || exit 1 # The old installer made this code directory service-writable. Remove that # legacy tree instead of following or reusing it during a privileged upgrade. @@ -1160,16 +1163,9 @@ setup_python_suite() { fi if [[ "$PY_SUITE_OK" == true && "$FORCE" != true ]]; then - if [[ "$CONTAINER_MODE" == false ]] && command -v systemctl >/dev/null 2>&1; then - local py_state - py_state="$(systemctl is-active pihole-suite.service 2>/dev/null || true)" - if [[ "$py_state" == "active" ]]; then - log "✅ Python Suite OK" - return - fi - fi - - # Stale state flag; force the step to run again. + # Always complete the one-time ownership and unit hardening migration. + # A running legacy service is not evidence that its venv is trustworthy. + log "Migrating Python Suite runtime to the hardened layout" update_state PY_SUITE_OK false fi diff --git a/tests/test_install_maintenance_web.sh b/tests/test_install_maintenance_web.sh index ba924ad..2d3e122 100755 --- a/tests/test_install_maintenance_web.sh +++ b/tests/test_install_maintenance_web.sh @@ -128,6 +128,8 @@ grep -Fqx 'INSTALL_MAINTENANCE_WEB=false' "$ROOT_DIR/install.sh" grep -Fq -- '--with-maintenance-web) INSTALL_MAINTENANCE_WEB=true ;;' "$ROOT_DIR/install.sh" grep -Fq -- '--skip-maintenance-web) INSTALL_MAINTENANCE_WEB=false ;;' "$ROOT_DIR/install.sh" grep -Fq 'install_python_suite_app_files' "$ROOT_DIR/install.sh" +grep -Fq 'Migrating Python Suite runtime to the hardened layout' "$ROOT_DIR/install.sh" +! grep -Fq 'log "✅ Python Suite OK"' "$ROOT_DIR/install.sh" grep -Fq 'maintenance_web.py' "$ROOT_DIR/install.sh" grep -Fq 'web/maintenance.html' "$ROOT_DIR/install.sh" grep -Fq 'web/maintenance.css' "$ROOT_DIR/install.sh" @@ -135,6 +137,7 @@ grep -Fq 'web/maintenance.js' "$ROOT_DIR/install.sh" grep -Fq 'install -o root -g root -m 0644' "$ROOT_DIR/install.sh" grep -Fq 'scripts/install_maintenance_web.sh' "$ROOT_DIR/install.sh" grep -Fq 'DRY RUN: Would install privileged maintenance web exposure' "$ROOT_DIR/install.sh" +grep -Fq 'sudo install -d -o root -g root -m 0755 "$STATE_DIR"' "$ROOT_DIR/install.sh" test -x "$ROOT_DIR/scripts/install_maintenance_web.sh" test -x "$ROOT_DIR/scripts/rollback_maintenance_web.sh" From 7f4cb1edc580965bbbd3d620c29581db262d6c56 Mon Sep 17 00:00:00 2001 From: TimInTech Date: Tue, 8 Sep 2026 14:16:02 +0200 Subject: [PATCH 13/24] fix: replace packaged Caddy sample --- scripts/install_maintenance_web.sh | 9 ++++++++- tests/test_install_maintenance_web.sh | 15 ++++++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/scripts/install_maintenance_web.sh b/scripts/install_maintenance_web.sh index 93f790f..dad9b2d 100755 --- a/scripts/install_maintenance_web.sh +++ b/scripts/install_maintenance_web.sh @@ -194,7 +194,14 @@ ensure_caddy_import() { [[ -e "$target" ]] || : > "$target" fi [[ -f "$target" ]] || die "missing Caddyfile: $target" - if ! grep -Fqx 'import Caddyfile.d/*' "$target"; then + if grep -Fqx '# The Caddyfile is an easy way to configure your Caddy web server.' "$target" \ + && grep -Fqx ':80 {' "$target" \ + && grep -Fqx $'\troot * /usr/share/caddy' "$target" \ + && grep -Fqx $'\tfile_server' "$target"; then + # The packaged example binds :80, which conflicts with Pi-hole. It has no + # user configuration, so replace it with the dedicated snippets import. + printf 'import Caddyfile.d/*\n' > "$target" + elif ! grep -Fqx 'import Caddyfile.d/*' "$target"; then printf '\nimport Caddyfile.d/*\n' >> "$target" fi if [[ -n "$DESTDIR" ]]; then diff --git a/tests/test_install_maintenance_web.sh b/tests/test_install_maintenance_web.sh index 2d3e122..dce6706 100755 --- a/tests/test_install_maintenance_web.sh +++ b/tests/test_install_maintenance_web.sh @@ -6,8 +6,9 @@ DESTDIR="$(mktemp -d)" GUARD_DIR="$(mktemp -d)" UNSAFE_DESTDIR="$(mktemp -d)" VENV_TEST_ROOT="$(mktemp -d)" +PACKAGED_DESTDIR="$(mktemp -d)" GUARD_LOG="$DESTDIR/forbidden-actions.log" -trap 'rm -rf -- "$DESTDIR" "$GUARD_DIR" "$UNSAFE_DESTDIR" "$VENV_TEST_ROOT"' EXIT +trap 'rm -rf -- "$DESTDIR" "$GUARD_DIR" "$UNSAFE_DESTDIR" "$VENV_TEST_ROOT" "$PACKAGED_DESTDIR"' EXIT # A staged install must never touch packages, networking, or live services. for command in apt apt-get caddy curl service sudo systemctl; do @@ -26,6 +27,18 @@ second_tree="$({ cd "$DESTDIR" && find . -type f -print0 | sort -z | xargs -0 sh test "$first_tree" = "$second_tree" test ! -s "$GUARD_LOG" +# The packaged Caddy sample binds :80 and must be replaced, not imported. +mkdir -p "$PACKAGED_DESTDIR/etc/caddy" +printf '%s\n' \ + '# The Caddyfile is an easy way to configure your Caddy web server.' \ + ':80 {' \ + $'\troot * /usr/share/caddy' \ + $'\tfile_server' \ + '}' > "$PACKAGED_DESTDIR/etc/caddy/Caddyfile" +PATH="$GUARD_DIR:$PATH" bash "$ROOT_DIR/scripts/install_maintenance_web.sh" --destdir "$PACKAGED_DESTDIR" +grep -Fqx 'import Caddyfile.d/*' "$PACKAGED_DESTDIR/etc/caddy/Caddyfile" +! grep -Fqx ':80 {' "$PACKAGED_DESTDIR/etc/caddy/Caddyfile" + # A compromised prior app tree must not redirect a root-owned installation. mkdir -p "$UNSAFE_DESTDIR/var/lib/pihole-suite/app" ln -s /tmp "$UNSAFE_DESTDIR/var/lib/pihole-suite/app/web" From 29d54049e38a0ab7915c83af0e6db6542009440a Mon Sep 17 00:00:00 2001 From: TimInTech Date: Tue, 8 Sep 2026 14:19:12 +0200 Subject: [PATCH 14/24] fix: prevent Caddy HTTP redirect listener --- scripts/install_maintenance_web.sh | 8 +++++--- tests/test_install_maintenance_web.sh | 1 + 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/scripts/install_maintenance_web.sh b/scripts/install_maintenance_web.sh index dad9b2d..7803c6d 100755 --- a/scripts/install_maintenance_web.sh +++ b/scripts/install_maintenance_web.sh @@ -185,7 +185,7 @@ backup_target() { } ensure_caddy_import() { - local target + local target nonempty_config target="$(destination "$CADDY_MAIN_TARGET")" assert_safe_parent_chain "$target" [[ ! -L "$target" ]] || die "refusing to modify symlinked Caddyfile" @@ -194,13 +194,15 @@ ensure_caddy_import() { [[ -e "$target" ]] || : > "$target" fi [[ -f "$target" ]] || die "missing Caddyfile: $target" + nonempty_config="$(sed '/^[[:space:]]*$/d' "$target")" if grep -Fqx '# The Caddyfile is an easy way to configure your Caddy web server.' "$target" \ && grep -Fqx ':80 {' "$target" \ && grep -Fqx $'\troot * /usr/share/caddy' "$target" \ - && grep -Fqx $'\tfile_server' "$target"; then + && grep -Fqx $'\tfile_server' "$target" \ + || [[ -z "$nonempty_config" || "$nonempty_config" == 'import Caddyfile.d/*' ]]; then # The packaged example binds :80, which conflicts with Pi-hole. It has no # user configuration, so replace it with the dedicated snippets import. - printf 'import Caddyfile.d/*\n' > "$target" + printf '{\n auto_https disable_redirects\n}\n\nimport Caddyfile.d/*\n' > "$target" elif ! grep -Fqx 'import Caddyfile.d/*' "$target"; then printf '\nimport Caddyfile.d/*\n' >> "$target" fi diff --git a/tests/test_install_maintenance_web.sh b/tests/test_install_maintenance_web.sh index dce6706..938eab4 100755 --- a/tests/test_install_maintenance_web.sh +++ b/tests/test_install_maintenance_web.sh @@ -37,6 +37,7 @@ printf '%s\n' \ '}' > "$PACKAGED_DESTDIR/etc/caddy/Caddyfile" PATH="$GUARD_DIR:$PATH" bash "$ROOT_DIR/scripts/install_maintenance_web.sh" --destdir "$PACKAGED_DESTDIR" grep -Fqx 'import Caddyfile.d/*' "$PACKAGED_DESTDIR/etc/caddy/Caddyfile" +grep -Fqx ' auto_https disable_redirects' "$PACKAGED_DESTDIR/etc/caddy/Caddyfile" ! grep -Fqx ':80 {' "$PACKAGED_DESTDIR/etc/caddy/Caddyfile" # A compromised prior app tree must not redirect a root-owned installation. From 50fd49bbb62bab0fb8cced1b191fa81d35914dc3 Mon Sep 17 00:00:00 2001 From: TimInTech Date: Tue, 8 Sep 2026 14:20:11 +0200 Subject: [PATCH 15/24] fix: wait for suite listener during activation --- scripts/install_maintenance_web.sh | 13 +++++++++++-- tests/test_install_maintenance_web.sh | 1 + 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/scripts/install_maintenance_web.sh b/scripts/install_maintenance_web.sh index 7803c6d..723c00f 100755 --- a/scripts/install_maintenance_web.sh +++ b/scripts/install_maintenance_web.sh @@ -250,13 +250,22 @@ validate_live_installation() { } activate_live_installation() { + local attempt listeners /usr/bin/systemctl daemon-reload /usr/bin/systemctl enable --now caddy /usr/bin/systemctl reload caddy /usr/bin/systemctl restart pihole-suite.service /usr/bin/systemctl is-active --quiet pihole-suite.service - /usr/bin/ss -ltnp '( sport = :8090 )' | /usr/bin/grep -Fq '127.0.0.1:8090' || die "pihole-suite is not listening only on 127.0.0.1:8090" - ! /usr/bin/ss -ltnp '( sport = :8090 )' | /usr/bin/grep -Eq '(0\.0\.0\.0|\[::\]):8090' || die "pihole-suite exposed port 8090 outside loopback" + for attempt in {1..15}; do + listeners="$(/usr/bin/ss -H -ltnp '( sport = :8090 )')" + if printf '%s\n' "$listeners" | /usr/bin/grep -Fq '127.0.0.1:8090'; then + break + fi + sleep 1 + done + [[ -n "$listeners" ]] || die "pihole-suite is not listening only on 127.0.0.1:8090" + printf '%s\n' "$listeners" | /usr/bin/grep -Fq '127.0.0.1:8090' || die "pihole-suite is not listening only on 127.0.0.1:8090" + ! printf '%s\n' "$listeners" | /usr/bin/grep -Eq '(0\.0\.0\.0|\[::\]):8090' || die "pihole-suite exposed port 8090 outside loopback" /usr/bin/curl --fail --silent --show-error --max-time 10 http://127.0.0.1:8090/ >/dev/null } diff --git a/tests/test_install_maintenance_web.sh b/tests/test_install_maintenance_web.sh index 938eab4..c7037f1 100755 --- a/tests/test_install_maintenance_web.sh +++ b/tests/test_install_maintenance_web.sh @@ -160,6 +160,7 @@ grep -Fq 'manifest.tsv' "$ROOT_DIR/scripts/install_maintenance_web.sh" grep -Fq 'sha256sum' "$ROOT_DIR/scripts/install_maintenance_web.sh" grep -Fq -- '--backup-only' "$ROOT_DIR/scripts/install_maintenance_web.sh" grep -Fq -- '--backup-dir' "$ROOT_DIR/scripts/install_maintenance_web.sh" +grep -Fq 'for attempt in {1..15}' "$ROOT_DIR/scripts/install_maintenance_web.sh" grep -Fq '/etc/systemd/system/pihole-suite.service' "$ROOT_DIR/scripts/install_maintenance_web.sh" grep -Fq 'install -o "$uid" -g "$gid" -m "$mode"' "$ROOT_DIR/scripts/rollback_maintenance_web.sh" grep -Fq 'validate_manifest "$backup_dir"' "$ROOT_DIR/scripts/rollback_maintenance_web.sh" From 3c4884ff26e3a7e2c076a1cf04fe5bb60f2e4764 Mon Sep 17 00:00:00 2001 From: TimInTech Date: Tue, 8 Sep 2026 14:29:45 +0200 Subject: [PATCH 16/24] fix: allow maintenance sudo transition --- install.sh | 4 +++- tests/test_install_maintenance_web.sh | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/install.sh b/install.sh index 98b2fd1..b6133d0 100755 --- a/install.sh +++ b/install.sh @@ -1227,7 +1227,9 @@ ReadOnlyPaths=$suite_app_dir $suite_venv_dir $suite_state_dir/jobs ReadWritePaths=$suite_data_dir $suite_state_dir/sessions PrivateTmp=true PrivateDevices=true -NoNewPrivileges=true +# The fixed, exact sudoers allow-list starts the three root-owned maintenance +# units. NoNewPrivileges would disable sudo's required privilege transition. +NoNewPrivileges=false ProtectSystem=strict ProtectHome=$protect_home_value ProtectKernelTunables=true diff --git a/tests/test_install_maintenance_web.sh b/tests/test_install_maintenance_web.sh index c7037f1..869929f 100755 --- a/tests/test_install_maintenance_web.sh +++ b/tests/test_install_maintenance_web.sh @@ -152,6 +152,7 @@ grep -Fq 'install -o root -g root -m 0644' "$ROOT_DIR/install.sh" grep -Fq 'scripts/install_maintenance_web.sh' "$ROOT_DIR/install.sh" grep -Fq 'DRY RUN: Would install privileged maintenance web exposure' "$ROOT_DIR/install.sh" grep -Fq 'sudo install -d -o root -g root -m 0755 "$STATE_DIR"' "$ROOT_DIR/install.sh" +grep -Fq 'NoNewPrivileges=false' "$ROOT_DIR/install.sh" test -x "$ROOT_DIR/scripts/install_maintenance_web.sh" test -x "$ROOT_DIR/scripts/rollback_maintenance_web.sh" From f8ae314011eade0909d25148da416cda1bc6471f Mon Sep 17 00:00:00 2001 From: TimInTech Date: Tue, 8 Sep 2026 14:33:44 +0200 Subject: [PATCH 17/24] fix: grant minimal sudo transition capabilities --- install.sh | 4 +++- tests/test_install_maintenance_web.sh | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/install.sh b/install.sh index b6133d0..5691411 100755 --- a/install.sh +++ b/install.sh @@ -1241,7 +1241,9 @@ RestrictNamespaces=true RestrictSUIDSGID=true MemoryDenyWriteExecute=true SystemCallFilter=@system-service -CapabilityBoundingSet= +# The exact sudoers allow-list needs only this privilege transition; all +# maintenance work remains in separate root-owned systemd units. +CapabilityBoundingSet=CAP_SETUID CAP_SETGID [Install] WantedBy=multi-user.target EOF diff --git a/tests/test_install_maintenance_web.sh b/tests/test_install_maintenance_web.sh index 869929f..975acfb 100755 --- a/tests/test_install_maintenance_web.sh +++ b/tests/test_install_maintenance_web.sh @@ -153,6 +153,7 @@ grep -Fq 'scripts/install_maintenance_web.sh' "$ROOT_DIR/install.sh" grep -Fq 'DRY RUN: Would install privileged maintenance web exposure' "$ROOT_DIR/install.sh" grep -Fq 'sudo install -d -o root -g root -m 0755 "$STATE_DIR"' "$ROOT_DIR/install.sh" grep -Fq 'NoNewPrivileges=false' "$ROOT_DIR/install.sh" +grep -Fq 'CapabilityBoundingSet=CAP_SETUID CAP_SETGID' "$ROOT_DIR/install.sh" test -x "$ROOT_DIR/scripts/install_maintenance_web.sh" test -x "$ROOT_DIR/scripts/rollback_maintenance_web.sh" From bc9f8954c927daf4c4f2ec502823ed4f0b9a92f0 Mon Sep 17 00:00:00 2001 From: TimInTech Date: Thu, 10 Sep 2026 15:54:10 +0200 Subject: [PATCH 18/24] feat: add maintenance web dashboard --- DESIGN.md | 25 ++ PRODUCT.md | 7 + README.de.md | 3 +- README.md | 3 +- deploy/Caddyfile.maintenance | 6 +- deploy/maintenance-web.conf | 2 + deploy/pihole-maintenance-web.sudoers | 1 + install.sh | 15 +- maintenance_backups.py | 233 +++++++++++++++ maintenance_config.py | 35 +++ maintenance_install.py | 84 ++++++ maintenance_runner.py | 24 +- maintenance_web.py | 113 ++++++- scripts/install_maintenance_web.sh | 69 ++++- scripts/rollback_maintenance_web.sh | 9 +- tests/test_install_maintenance_web.sh | 15 +- tests/test_maintenance_backups.py | 120 ++++++++ tests/test_maintenance_browser.py | 120 ++++++++ tests/test_maintenance_config.py | 18 ++ tests/test_maintenance_runner.py | 25 +- tests/test_maintenance_web.py | 114 ++++++- web/maintenance.css | 97 +++++- web/maintenance.html | 63 +++- web/maintenance.js | 411 +++++++++++++++++++------- 24 files changed, 1459 insertions(+), 153 deletions(-) create mode 100644 DESIGN.md create mode 100644 PRODUCT.md create mode 100644 deploy/maintenance-web.conf create mode 100755 maintenance_backups.py create mode 100644 maintenance_config.py create mode 100644 maintenance_install.py mode change 100644 => 100755 maintenance_runner.py create mode 100644 tests/test_maintenance_backups.py create mode 100644 tests/test_maintenance_browser.py create mode 100644 tests/test_maintenance_config.py diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..84b4e15 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,25 @@ +# Wartungs-Flugplan + +## Oberfläche + +Lokaler Betriebsarbeitsplatz, kein Marketing-Dashboard. Eine breite linke Zone zeigt den letzten Auftrag, reale Systemwerte und die Prüfliste. Rechts stehen drei getrennte Wartungsaktionen. Unter 720 px folgt eine einzelne Spalte. Backups und geschlossene JSON-Details schließen die Ansicht ab. + +## Gestaltungswerte + +- Systemschrift, Zeilenhöhe 1,5; Haupttitel 1,65–2,35 rem; maximal 1380 px Arbeitsbreite. +- Hintergrund `#0d1419`, Statusfläche `#151f26`, Trennlinien `#354650`. +- Primärtext `#edf3f5`, Sekundärtext `#a9bbc4`. +- Erfolg `#77e0c6`, Aufmerksamkeit `#f3c578`, Fehler `#ffaaa7` – stets zusätzlich ausgeschrieben. +- Eingabebegrenzungen `#607681`, 3,89:1 gegenüber dem Hintergrund. +- Statusfläche mit 12-px-Radius, Bedienelemente mit 6 px. Mindestens 44 px Zielhöhe, einschließlich Markenlink und Checkboxlabel. +- Sichtbarer 3-px-Fokus mit Abstand. Nur kurze Farbwechsel bei erlaubter Bewegung; keine Animation bei reduzierter Bewegung. + +## Verhalten + +Deutsch/Englisch aus statischen Wörterbüchern; nur `de` oder `en` werden gespeichert. Serverwerte landen ausschließlich in Textknoten. Fehlende Messwerte heißen „Nicht verfügbar“. Ein laufender Auftrag zeigt keine geschätzten Prozente; der bestehende Runner liefert die einzelnen Ergebnisse erst nach Abschluss. + +Anmeldung mit dem bestehenden `SUITE_API_KEY` und Sitzungserneuerung führen ins Dashboard, Abmeldung und Ablauf zurück zum API-Key-Feld. Startende und laufende Aufträge sperren konkurrierende Aktionen. Verbindungsfehler verlangen eine manuelle Statusabfrage; kein dauerhaftes Hintergrundpolling. + +## Prüfung + +Browsermatrix: 1440, 1063, 390 und 320 px, DE/EN, 200-%-CSS-Zoom, echte Tab-Fokusnavigation und reduzierte Bewegung. Die Prüfbilder verwenden ausdrücklich Testdaten einschließlich fehlender optionaler Werte und eines als Text dargestellten XSS-Testhosts. Unabhängiger Review: zwei Befunde zu Feldkontrast und Markenlink-Zielhöhe behoben und bestätigt. Der zusätzliche Design-Detector lief nur im eingeschränkten Regex-Modus; keine vollständige automatische Barrierefreiheitszertifizierung. Keine Rasterbilder oder externen Assets im Produkt. diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 0000000..77b98fe --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,7 @@ +# Pi-hole Wartungs-Flugplan + +Lokaler Betriebsarbeitsplatz für Tim und andere Self-Hoster: Pi-hole und Unbound prüfen, sichern und kontrolliert aktualisieren. Ergänzt Pi-hole ohne Änderungen an dessen Dateien oder Navigation. + +Deutsch und Englisch, Desktop und Smartphone, ausschließlich reale Runnerdaten. Drei feste Wartungsaktionen, bestätigte Änderungen und verifizierte Backups. Kein Restore, kein Internetzugang, keine generischen Root-Aktionen. Die Anmeldung verwendet unverändert den bestehenden `SUITE_API_KEY`. + +Dunkler Preflight-Arbeitsplatz mit breiter Statuszone und schmalerer Aktionszone. Türkis, Amber und Rot ergänzen ausgeschriebene Zustände. Systemfonts, keine externen Assets oder Bibliotheken. Technisches JSON bleibt geschlossen abrufbar. diff --git a/README.de.md b/README.de.md index c48ccee..0a65d7c 100644 --- a/README.de.md +++ b/README.de.md @@ -256,7 +256,8 @@ python3 start_suite.py Die optionale Wartungsoberfläche bietet Systemcheck, verifiziertes Backup und kontrolliertes Update unter `https://pi.hole:8443/`. Sie bleibt standardmäßig -deaktiviert und wird nur zusammen mit Caddy bewusst freigeschaltet: +deaktiviert und wird nur zusammen mit Caddy bewusst freigeschaltet. Die +Browser-Anmeldung verwendet den bestehenden `SUITE_API_KEY`: ```bash sudo ./install.sh --with-maintenance-web diff --git a/README.md b/README.md index beea7d9..ea0fa9a 100644 --- a/README.md +++ b/README.md @@ -258,7 +258,8 @@ python3 start_suite.py The optional maintenance panel provides system checks, verified backups and controlled updates at `https://pi.hole:8443/`. It remains disabled by default -and is enabled explicitly with Caddy: +and is enabled explicitly with Caddy. Browser sign-in uses the existing +`SUITE_API_KEY`: ```bash sudo ./install.sh --with-maintenance-web diff --git a/deploy/Caddyfile.maintenance b/deploy/Caddyfile.maintenance index 77b7ed3..dc61d10 100644 --- a/deploy/Caddyfile.maintenance +++ b/deploy/Caddyfile.maintenance @@ -2,7 +2,11 @@ pi.hole:8443, 192.168.178.2:8443 { tls internal @lan remote_ip 192.168.178.0/24 127.0.0.1 ::1 handle @lan { - reverse_proxy 127.0.0.1:8090 + @maintenance path / /maintenance.css /maintenance.js /api/session /api/session/logout /api/maintenance/* + handle @maintenance { + reverse_proxy 127.0.0.1:8090 + } + respond "Not found" 404 } respond "Forbidden" 403 } diff --git a/deploy/maintenance-web.conf b/deploy/maintenance-web.conf new file mode 100644 index 0000000..c8d4142 --- /dev/null +++ b/deploy/maintenance-web.conf @@ -0,0 +1,2 @@ +[Service] +ReadWritePaths=/var/backups/pihole-suite /var/lib/pihole-suite/exports /run/lock diff --git a/deploy/pihole-maintenance-web.sudoers b/deploy/pihole-maintenance-web.sudoers index e9b4ed5..c053027 100644 --- a/deploy/pihole-maintenance-web.sudoers +++ b/deploy/pihole-maintenance-web.sudoers @@ -1,3 +1,4 @@ pihole-suite ALL=(root) NOPASSWD: /usr/bin/systemctl start --no-block pihole-maintenance-check.service pihole-suite ALL=(root) NOPASSWD: /usr/bin/systemctl start --no-block pihole-maintenance-backup.service pihole-suite ALL=(root) NOPASSWD: /usr/bin/systemctl start --no-block pihole-maintenance-update.service +pihole-suite ALL=(root) NOPASSWD: /usr/local/libexec/pihole-maintenance-backups ^delete [0-9]{8}T[0-9]{6}Z-[0-9]{8}T[0-9]{12}Z-[a-f0-9]{32}$ diff --git a/install.sh b/install.sh index 5691411..83b62d0 100755 --- a/install.sh +++ b/install.sh @@ -65,6 +65,8 @@ AUTO_REMOVE_CONFLICTS=false INSTALL_NETALERTX=false INSTALL_PYTHON_SUITE=true INSTALL_MAINTENANCE_WEB=false +MAINTENANCE_HOSTS="" +MAINTENANCE_LAN_CIDR="" # Ports UNBOUND_PORT=5335 @@ -336,6 +338,14 @@ parse_args() { --skip-python-api) INSTALL_PYTHON_SUITE=false ;; --with-maintenance-web) INSTALL_MAINTENANCE_WEB=true ;; --skip-maintenance-web) INSTALL_MAINTENANCE_WEB=false ;; + --maintenance-hosts|--maintenance-lan-cidr) + [[ $# -ge 2 ]] || { log_error "$1 requires a value"; exit 1; } + case "$1" in + --maintenance-hosts) MAINTENANCE_HOSTS="$2" ;; + --maintenance-lan-cidr) MAINTENANCE_LAN_CIDR="$2" ;; + esac + shift + ;; --minimal) INSTALL_NETALERTX=false; INSTALL_PYTHON_SUITE=false; INSTALL_MAINTENANCE_WEB=false ;; *) log_error "Unknown option: $1"; exit 1 ;; esac @@ -1131,6 +1141,8 @@ install_python_suite_app_files() { local -a app_files=( "start_suite.py" "maintenance_web.py" + "maintenance_backups.py" + "maintenance_config.py" "web/maintenance.html" "web/maintenance.css" "web/maintenance.js" @@ -1372,7 +1384,8 @@ main() { if [[ "$DRY_RUN" == true ]]; then log "DRY RUN: Would install privileged maintenance web exposure" else - "$SCRIPT_DIR/scripts/install_maintenance_web.sh" --backup-dir "$maintenance_backup_dir" + local -a maintenance_args=(--backup-dir "$maintenance_backup_dir" --hosts "$MAINTENANCE_HOSTS" --lan-cidr "$MAINTENANCE_LAN_CIDR") + "$SCRIPT_DIR/scripts/install_maintenance_web.sh" "${maintenance_args[@]}" fi else log "⏭️ Skipping privileged maintenance web exposure (default; use --with-maintenance-web)" diff --git a/maintenance_backups.py b/maintenance_backups.py new file mode 100755 index 0000000..85dcdfb --- /dev/null +++ b/maintenance_backups.py @@ -0,0 +1,233 @@ +#!/usr/bin/env python3 +"""Verified read-only exports and a narrowly scoped privileged deletion command.""" + +import datetime as dt +import fcntl +import hashlib +import json +import os +import re +import shutil +import stat +import sys +import tarfile +import tempfile +from pathlib import Path + +EXPORT_ROOT = Path('/var/lib/pihole-suite/exports') +BACKUP_ID = re.compile(r'\d{8}T\d{6}Z-\d{8}T\d{12}Z-[a-f0-9]{32}') + + +def valid_id(value: str) -> bool: + return isinstance(value, str) and BACKUP_ID.fullmatch(value) is not None + + +def safe_file(path: Path): + descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) + metadata = os.fstat(descriptor) + if not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1: + os.close(descriptor) + raise ValueError('Unsafe file') + return os.fdopen(descriptor, 'rb') + + +def read_index(root: Path) -> list[dict]: + try: + with safe_file(root / 'index.json') as handle: + raw = handle.read(32769) + if len(raw) > 32768: + raise ValueError('Invalid index') + entries = json.loads(raw) + except FileNotFoundError: + return [] + if not isinstance(entries, list) or len(entries) > 10: + raise ValueError('Invalid index') + seen = set() + for entry in entries: + if not isinstance(entry, dict) or set(entry) != {'backup_id', 'created_at', 'size', 'sha256', 'verified'}: + raise ValueError('Invalid index') + if not valid_id(entry['backup_id']) or entry['backup_id'] in seen or entry['verified'] is not True: + raise ValueError('Invalid index') + seen.add(entry['backup_id']) + if not isinstance(entry['size'], int) or isinstance(entry['size'], bool) or entry['size'] < 1: + raise ValueError('Invalid index') + if not isinstance(entry['sha256'], str) or not re.fullmatch('[a-f0-9]{64}', entry['sha256']): + raise ValueError('Invalid index') + if not isinstance(entry['created_at'], str) or len(entry['created_at']) > 40: + raise ValueError('Invalid index') + dt.datetime.fromisoformat(entry['created_at']) + return entries + + +def _verified_backup(context, backup_id: str) -> Path: + from maintenance_runner import _verify_manifest + if not valid_id(backup_id): + raise ValueError('Invalid backup ID') + candidate = context.backup_root / backup_id + if any(parent.is_symlink() for parent in candidate.parents) or candidate.is_symlink() or not candidate.is_dir(): + raise FileNotFoundError('Backup not found') + if any(path.is_symlink() or (path.is_file() and path.stat().st_nlink != 1) for path in candidate.rglob('*')): + raise ValueError('Unsafe backup') + try: + _verify_manifest(candidate) + manifest = json.loads((candidate / 'manifest.json').read_text()) + if backup_id.split('-', 1)[1] != manifest['job_id']: + raise ValueError('Backup ID mismatch') + dt.datetime.fromisoformat(manifest['created_at']) + except RuntimeError as error: + raise ValueError('Invalid backup') from error + return candidate + + +def _publish_index(context, entries): + from maintenance_runner import _atomic_json_write + _atomic_json_write(context.export_root / 'index.json', entries, context) + + +def refresh_exports(context) -> None: + import grp + + from maintenance_runner import _prepare_directory, _sha256 + recover_deletions(context) + _prepare_directory(context.export_root, 0o750, 'export directory') + gid = context.state_gid if context.state_gid is not None else grp.getgrnam('pihole-suite').gr_gid + os.chown(context.export_root, -1, gid) + entries = [] + for candidate in sorted(context.backup_root.iterdir(), reverse=True): + if not valid_id(candidate.name) or len(entries) >= 10: + continue + try: + backup = _verified_backup(context, candidate.name) + except (OSError, ValueError): + continue + archive = context.export_root / (candidate.name + '.tar.gz') + if archive.is_symlink(): + raise ValueError('Unsafe export') + if not archive.exists(): + descriptor, temporary = tempfile.mkstemp(prefix='.export-', dir=context.export_root) + try: + with os.fdopen(descriptor, 'wb') as output: + with tarfile.open(fileobj=output, mode='w:gz') as bundle: + for path in sorted(backup.rglob('*')): + bundle.add(path, arcname=path.relative_to(backup).as_posix(), recursive=False) + output.flush() + os.fsync(output.fileno()) + os.fchown(output.fileno(), -1, gid) + os.fchmod(output.fileno(), 0o640) + os.replace(temporary, archive) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + manifest = json.loads((backup / 'manifest.json').read_text()) + with safe_file(archive) as handle, tarfile.open(fileobj=handle, mode='r:gz') as bundle: + expected = {item['path']: item for item in manifest['files']} + directories = {path.relative_to(backup).as_posix() for path in backup.rglob('*') if path.is_dir()} + covered = set() + seen_members = set() + for member in bundle: + if member.name in seen_members: + raise ValueError('Duplicate export member') + seen_members.add(member.name) + if member.isdir(): + if member.name not in directories: + raise ValueError('Unsafe export directory') + continue + if not member.isfile() or member.name in covered: + raise ValueError('Unsafe export content') + covered.add(member.name) + source = bundle.extractfile(member) + if member.name == 'manifest.json': + if source.read() != (backup / 'manifest.json').read_bytes(): + raise ValueError('Export manifest mismatch') + elif member.name not in expected or member.size != expected[member.name]['size'] or hashlib.file_digest(source, 'sha256').hexdigest() != expected[member.name]['sha256']: + raise ValueError('Export verification failed') + if covered != set(expected) | {'manifest.json'}: + raise ValueError('Incomplete export') + entries.append({'backup_id': candidate.name, 'created_at': manifest['created_at'], 'size': archive.stat().st_size, 'sha256': _sha256(archive), 'verified': True}) + _publish_index(context, entries) + retained = {entry['backup_id'] + '.tar.gz' for entry in entries} + for archive in context.export_root.glob('*.tar.gz'): + if valid_id(archive.name.removesuffix('.tar.gz')) and archive.name not in retained: + archive.unlink() + + +def delete_backup(context, backup_id: str) -> None: + if not valid_id(backup_id): + raise ValueError('Invalid backup ID') + with os.fdopen(os.open(context.lock_file, os.O_WRONLY | os.O_CREAT | os.O_NOFOLLOW, 0o600), 'w') as lock: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + recover_deletions(context) + backup = _verified_backup(context, backup_id) + archive = context.export_root / (backup_id + '.tar.gz') + if archive.is_symlink(): + raise ValueError('Unsafe export') + entries = read_index(context.export_root) + tombstone = backup.with_name('.deleting-' + backup_id) + if tombstone.exists() or tombstone.is_symlink(): + raise ValueError('Unfinished deletion') + from maintenance_runner import _atomic_json_write, _sync_directory + marker = context.backup_root / ('.delete-' + backup_id + '.json') + intent = {'backup_id': backup_id, 'job_id': backup_id.split('-', 1)[1], 'committed': False} + _atomic_json_write(marker, intent, context) + os.replace(backup, tombstone) + _sync_directory(context.backup_root) + try: + _publish_index(context, [entry for entry in entries if entry['backup_id'] != backup_id]) + except Exception: + os.replace(tombstone, backup) + marker.unlink() + _sync_directory(context.backup_root) + raise + _atomic_json_write(marker, {**intent, 'committed': True}, context) + archive.unlink(missing_ok=True) + shutil.rmtree(tombstone) + marker.unlink() + _sync_directory(context.backup_root) + + +def recover_deletions(context) -> None: + from maintenance_runner import _sync_directory + + for marker in context.backup_root.glob('.delete-*.json'): + backup_id = marker.name.removeprefix('.delete-').removesuffix('.json') + if not valid_id(backup_id): + raise ValueError('Invalid deletion marker') + with safe_file(marker) as handle: + intent = json.loads(handle.read(1025)) + if not isinstance(intent, dict) or set(intent) != {'backup_id', 'job_id', 'committed'} or intent['backup_id'] != backup_id or intent['job_id'] != backup_id.split('-', 1)[1] or not isinstance(intent['committed'], bool): + raise ValueError('Invalid deletion intent') + tombstone = context.backup_root / ('.deleting-' + backup_id) + backup = context.backup_root / backup_id + if tombstone.is_symlink() or backup.is_symlink() or any(path.is_symlink() for path in tombstone.rglob('*')): + raise ValueError('Unsafe interrupted deletion') + if intent['committed']: + (context.export_root / (backup_id + '.tar.gz')).unlink(missing_ok=True) + if tombstone.exists(): + shutil.rmtree(tombstone) + elif tombstone.exists(): + if backup.exists(): + raise ValueError('Conflicting interrupted deletion') + os.replace(tombstone, backup) + marker.unlink() + _sync_directory(context.backup_root) + + +def main() -> int: + if os.geteuid() != 0 or len(sys.argv) != 3 or sys.argv[1] != 'delete' or not valid_id(sys.argv[2]): + return 2 + from maintenance_runner import RunnerContext + try: + import grp + os.setegid(grp.getgrnam('pihole-suite').gr_gid) + delete_backup(RunnerContext(), sys.argv[2]) + except BlockingIOError: + return 3 + except FileNotFoundError: + return 4 + except (OSError, ValueError, KeyError): + return 5 + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/maintenance_config.py b/maintenance_config.py new file mode 100644 index 0000000..6f5f47a --- /dev/null +++ b/maintenance_config.py @@ -0,0 +1,35 @@ +"""Validated LAN configuration shared by Caddy and the browser API.""" + +import ipaddress +import re + + +def configuration(hosts: str, cidr: str) -> dict: + selected = hosts.split(',') + if not selected or len(selected) > 8 or len(set(selected)) != len(selected): + raise ValueError('Invalid hosts') + for host in selected: + if len(host) > 253 or not all(re.fullmatch(r'[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?', part) for part in host.split('.')): + raise ValueError('Invalid host') + network = ipaddress.ip_network(cidr, strict=True) + private = [ipaddress.ip_network(value) for value in ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16']] + if network.version != 4 or not any(network.subnet_of(allowed) for allowed in private): + raise ValueError('An RFC1918 LAN CIDR is required') + return {'hosts': selected, 'lan_cidr': str(network), 'allowed_origins': [f'https://{host}:8443' for host in selected], 'pihole_url': f'http://{selected[0]}/admin/'} + + +def caddy_config(config: dict) -> str: + hosts = ', '.join(host + ':8443' for host in config['hosts']) + return f'''{hosts} {{ + tls internal + @lan remote_ip {config['lan_cidr']} 127.0.0.1 ::1 + handle @lan {{ + @maintenance path / /maintenance.css /maintenance.js /api/session /api/session/logout /api/maintenance/* + handle @maintenance {{ + reverse_proxy 127.0.0.1:8090 + }} + respond "Not found" 404 + }} + respond "Forbidden" 403 +}} +''' diff --git a/maintenance_install.py b/maintenance_install.py new file mode 100644 index 0000000..a84b1ad --- /dev/null +++ b/maintenance_install.py @@ -0,0 +1,84 @@ +"""Installation-only provisioning; never exposed by sudoers or the HTTP API.""" + +import argparse +import fcntl +import ipaddress +import json +import os +import subprocess +import tarfile +import tempfile +from pathlib import Path + +from maintenance_config import caddy_config, configuration + + +def atomic_file(path: Path, value: str) -> None: + if path.is_symlink() or any(parent.is_symlink() for parent in path.parents): + raise ValueError('Unsafe installation path') + path.parent.mkdir(parents=True, exist_ok=True) + descriptor, temporary = tempfile.mkstemp(dir=path.parent) + try: + with os.fdopen(descriptor, 'w') as handle: + os.fchmod(handle.fileno(), 0o644) + handle.write(value) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, path) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + + +def detect_network() -> tuple[str, str]: + routes = json.loads(subprocess.check_output(['/usr/sbin/ip', '-j', '-4', 'route', 'show', 'default'], text=True)) + if not routes: + raise ValueError('No default interface; specify --hosts and --lan-cidr') + addresses = json.loads(subprocess.check_output(['/usr/sbin/ip', '-j', '-4', 'addr', 'show', 'dev', routes[0]['dev']], text=True)) + for interface in addresses: + for address in interface.get('addr_info', []): + if address.get('scope') == 'global': + network = ipaddress.ip_interface(f"{address['local']}/{address['prefixlen']}").network + return f"pi.hole,{address['local']}", str(network) + raise ValueError('LAN detection failed; specify --hosts and --lan-cidr') + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--destdir', default='') + parser.add_argument('--hosts', default='') + parser.add_argument('--lan-cidr', default='') + parser.add_argument('--lock-fd', type=int) + args = parser.parse_args() + if not args.destdir and os.geteuid() != 0: + raise ValueError('Root required') + root = Path(args.destdir or '/') + config_path = root / 'etc/pihole-suite/maintenance-web.json' + if not args.hosts and not args.lan_cidr and config_path.exists(): + raw = json.loads(config_path.read_text()) + hosts, cidr = ','.join(raw['hosts']), raw['lan_cidr'] + else: + detected_hosts, detected_cidr = detect_network() if not (args.hosts and args.lan_cidr) else ('', '') + hosts, cidr = args.hosts or detected_hosts, args.lan_cidr or detected_cidr + config = configuration(hosts, cidr) + atomic_file(config_path, json.dumps(config) + '\n') + atomic_file(root / 'etc/caddy/Caddyfile.d/pihole-maintenance.caddy', caddy_config(config)) + if not args.destdir: + from maintenance_backups import refresh_exports + from maintenance_runner import RunnerContext + context = RunnerContext() + descriptor = os.dup(args.lock_fd) if args.lock_fd is not None else os.open(context.lock_file, os.O_WRONLY | os.O_CREAT | os.O_NOFOLLOW, 0o600) + if os.fstat(descriptor).st_ino != context.lock_file.stat().st_ino: + os.close(descriptor) + raise ValueError('Invalid inherited maintenance lock') + with os.fdopen(descriptor, 'w') as lock: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + refresh_exports(context) + print('Wartungsadresse / Maintenance URL: ' + config['allowed_origins'][0] + '/') + + +if __name__ == '__main__': + try: + main() + except (OSError, ValueError, KeyError, subprocess.SubprocessError, tarfile.TarError, EOFError): + raise SystemExit('Provisioning failed: check LAN configuration and maintenance lock') from None diff --git a/maintenance_runner.py b/maintenance_runner.py old mode 100644 new mode 100755 index 39d82b8..1e134f6 --- a/maintenance_runner.py +++ b/maintenance_runner.py @@ -17,13 +17,14 @@ import sqlite3 import subprocess import sys +import tarfile import tempfile import time import uuid +from collections.abc import Callable, Sequence from dataclasses import dataclass from pathlib import Path, PurePosixPath -from typing import Any, Callable, Sequence - +from typing import Any STATE_FILE_MODE = 0o640 BACKUP_DIR_MODE = 0o700 @@ -55,6 +56,7 @@ class RunnerContext: etc_root: Path = Path("/etc") state_dir: Path = Path("/var/lib/pihole-suite/jobs") backup_root: Path = Path("/var/backups/pihole-suite") + export_root: Path = Path('/var/lib/pihole-suite/exports') lock_file: Path = Path("/run/lock/pihole-maintenance-web.lock") command_executor: CommandExecutor | None = None pihole_bin: str = "/usr/local/bin/pihole" @@ -62,7 +64,7 @@ class RunnerContext: reboot_required_path: Path = Path("/var/run/reboot-required") def __post_init__(self) -> None: - for field_name in ("etc_root", "state_dir", "backup_root", "lock_file", "reboot_required_path"): + for field_name in ("etc_root", "state_dir", "backup_root", "export_root", "lock_file", "reboot_required_path"): object.__setattr__(self, field_name, Path(getattr(self, field_name))) if self.command_executor is None: object.__setattr__(self, "command_executor", _run_command) @@ -157,10 +159,19 @@ def _atomic_json_write(path: Path, payload: dict[str, Any], context: RunnerConte state_gid = grp.getgrnam("pihole-suite").gr_gid os.chown(temporary_path, -1, state_gid) os.replace(temporary_path, path) + _sync_directory(path.parent) finally: temporary_path.unlink(missing_ok=True) +def _sync_directory(path: Path) -> None: + descriptor = os.open(path, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + + def _write_state(payload: dict[str, Any], context: RunnerContext, *, history: bool = False) -> None: _atomic_json_write(context.state_dir / "current.json", payload, context) if history: @@ -275,6 +286,9 @@ def _manifest(staging: Path, job_id: str) -> Path: def _verify_manifest(staging: Path) -> None: + manifest_path = staging / 'manifest.json' + if staging.is_symlink() or manifest_path.is_symlink() or not manifest_path.is_file() or manifest_path.stat().st_size > 1024 * 1024: + raise RuntimeError('unsafe manifest') try: manifest = json.loads((staging / "manifest.json").read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): @@ -433,8 +447,10 @@ def _run_backup(context: RunnerContext, job_id: str) -> tuple[list[dict[str, Any published = context.backup_root / f"{_utc_now().replace(':', '').replace('-', '')}-{job_id}" os.replace(staging, published) _retain_newest_backups(context.backup_root) + from maintenance_backups import refresh_exports + refresh_exports(context) return [{"name": "backup", "required": True, "ok": True, "detail": "completed"}], None, str(published) - except (OSError, sqlite3.Error, ValueError, RuntimeError): + except (OSError, sqlite3.Error, ValueError, RuntimeError, tarfile.TarError, EOFError): shutil.rmtree(staging, ignore_errors=True) return [{"name": "backup", "required": True, "ok": False, "detail": "backup_failed"}], "backup_failed", None diff --git a/maintenance_web.py b/maintenance_web.py index 2a65652..0e20537 100644 --- a/maintenance_web.py +++ b/maintenance_web.py @@ -2,6 +2,7 @@ from __future__ import annotations +import hashlib import hmac import json import os @@ -16,7 +17,15 @@ from typing import Any from fastapi import APIRouter, Depends, HTTPException, Request, Response -from fastapi.responses import FileResponse, JSONResponse, PlainTextResponse +from fastapi.responses import ( + FileResponse, + JSONResponse, + PlainTextResponse, + StreamingResponse, +) +from starlette.concurrency import run_in_threadpool + +from maintenance_backups import EXPORT_ROOT, read_index, safe_file, valid_id SESSION_IDLE_SECONDS = 30 * 60 SESSION_MAX_SECONDS = 8 * 60 * 60 @@ -55,6 +64,8 @@ class MaintenanceSettings: """Configuration owned by the local service, never supplied by a request.""" api_key: str + export_root: Path = EXPORT_ROOT + pihole_url: str = 'http://pi.hole/admin/' state_file: Path = Path("/var/lib/pihole-suite/jobs/current.json") dispatcher: Dispatcher | None = None allowed_hosts: frozenset[str] = ALLOWED_HOSTS @@ -63,6 +74,7 @@ class MaintenanceSettings: login_failures: dict[str, list[float]] = field(default_factory=dict) state_lock: threading.RLock = field(default_factory=threading.RLock, repr=False) start_reservation: StartReservation | None = None + deletion_running: bool = False now: Callable[[], float] = time.monotonic def __post_init__(self) -> None: @@ -75,12 +87,19 @@ def from_environment(cls) -> MaintenanceSettings: api_key = os.environ.get("SUITE_API_KEY", "") if not api_key: raise RuntimeError("Missing required environment variable: SUITE_API_KEY") + config_file = Path('/etc/pihole-suite/maintenance-web.json') + if config_file.exists(): + from maintenance_config import configuration + raw = json.loads(config_file.read_text()) + config = configuration(','.join(raw['hosts']), raw['lan_cidr']) + return cls(api_key=api_key, allowed_hosts=frozenset(config['hosts'] + ['localhost', '127.0.0.1']), allowed_origins=frozenset(config['allowed_origins']), pihole_url=config['pihole_url']) return cls(api_key=api_key) def _dispatch(argv: list[str]) -> None: """Start one predefined unit without a shell or caller-controlled arguments.""" - subprocess.run(argv, check=True, timeout=15, capture_output=True, text=True) + timeout = 300 if argv[:4] == ['/usr/bin/sudo', '-n', '/usr/local/libexec/pihole-maintenance-backups', 'delete'] else 15 + subprocess.run(argv, check=True, timeout=timeout, capture_output=True, text=True) def _security_headers(response: Response) -> None: @@ -157,6 +176,16 @@ def _session_from_request(request: Request, settings: MaintenanceSettings) -> tu return session_id, session +async def _confirmation(request: Request, expected: dict) -> None: + try: + body = await request.body() + parsed = json.loads(body) if len(body) <= 1024 else None + except (ValueError, UnicodeError): + parsed = None + if parsed != expected or (expected.get('confirmed') is True and parsed.get('confirmed') is not True): + raise HTTPException(status_code=400, detail='Invalid confirmation') + + def _require_mutation(request: Request, settings: MaintenanceSettings) -> tuple[str, Session]: session_id, session = _session_from_request(request, settings) if request.headers.get("origin") not in settings.allowed_origins: @@ -335,7 +364,15 @@ async def create_session(request: Request, response: Response) -> dict[str, str] samesite="strict", path="/", ) - return {"csrf_token": csrf_token} + return {"csrf_token": csrf_token, 'pihole_url': settings.pihole_url} + + @router.get('/api/session', include_in_schema=False) + def resume_session(request: Request, session: tuple[str, Session] = Depends(require_session)) -> dict: + if request.headers.get('origin') and request.headers['origin'] not in settings.allowed_origins: + raise HTTPException(status_code=403, detail='Request rejected') + with settings.state_lock: + session[1].csrf_token = secrets.token_urlsafe(32) + return {'csrf_token': session[1].csrf_token, 'pihole_url': settings.pihole_url} @router.post("/api/session/logout", status_code=204, include_in_schema=False) def logout(session: tuple[str, Session] = Depends(require_mutation)) -> Response: @@ -349,6 +386,68 @@ def logout(session: tuple[str, Session] = Depends(require_mutation)) -> Response def status(_: tuple[str, Session] = Depends(require_session)) -> dict[str, Any]: return _state_or_error(settings) + def backup_entries() -> list[dict]: + try: + return read_index(settings.export_root) + except (OSError, ValueError, TypeError): + raise HTTPException(status_code=503, detail='Backup index unavailable') from None + + def backup_entry(backup_id: str) -> dict: + if valid_id(backup_id): + for entry in backup_entries(): + if entry['backup_id'] == backup_id: + return entry + raise HTTPException(status_code=404, detail='Backup not found') + + @router.get('/api/maintenance/backups', include_in_schema=False) + def backups(_: tuple[str, Session] = Depends(require_session)) -> list[dict]: + return backup_entries() + + @router.get('/api/maintenance/backups/{backup_id}/download', include_in_schema=False) + def download_backup(backup_id: str, _: tuple[str, Session] = Depends(require_session)) -> StreamingResponse: + entry = backup_entry(backup_id) + try: + handle = safe_file(settings.export_root / (backup_id + '.tar.gz')) + except FileNotFoundError: + raise HTTPException(status_code=404, detail='Backup not found') from None + except (OSError, ValueError): + raise HTTPException(status_code=503, detail='Backup unavailable') from None + if os.fstat(handle.fileno()).st_size != entry['size'] or hashlib.file_digest(handle, 'sha256').hexdigest() != entry['sha256']: + handle.close() + raise HTTPException(status_code=503, detail='Backup verification failed') + handle.seek(0) + + def chunks(): + with handle: + while chunk := handle.read(65536): + yield chunk + + return StreamingResponse(chunks(), media_type='application/gzip', headers={ + 'Content-Disposition': f'attachment; filename="pihole-backup-{backup_id}.tar.gz"', + 'Content-Length': str(entry['size']), 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', + }) + + @router.delete('/api/maintenance/backups/{backup_id}', status_code=204, include_in_schema=False) + async def remove_backup(backup_id: str, request: Request, _: tuple[str, Session] = Depends(require_mutation)) -> Response: + backup_entry(backup_id) + await _confirmation(request, {'confirmation': backup_id}) + with settings.state_lock: + state = _state_or_error(settings) + if settings.deletion_running or settings.start_reservation is not None or state['state'] == 'running': + raise HTTPException(status_code=409, detail='Maintenance busy') + settings.deletion_running = True + try: + assert settings.dispatcher is not None + await run_in_threadpool(settings.dispatcher, ['/usr/bin/sudo', '-n', '/usr/local/libexec/pihole-maintenance-backups', 'delete', backup_id]) + except subprocess.CalledProcessError as error: + raise HTTPException(status_code={3: 409, 4: 404}.get(error.returncode, 503), detail='Backup deletion rejected') from None + except (OSError, subprocess.SubprocessError): + raise HTTPException(status_code=503, detail='Backup deletion unavailable') from None + finally: + with settings.state_lock: + settings.deletion_running = False + return Response(status_code=204) + def start_action(action: str) -> JSONResponse: with settings.state_lock: try: @@ -357,7 +456,7 @@ def start_action(action: str) -> JSONResponse: raise HTTPException(status_code=503, detail="Job status unavailable") from None now = settings.now() _observe_runner_state(settings, state, now) - if settings.start_reservation is not None or (state is not None and state["state"] == "running"): + if settings.deletion_running or settings.start_reservation is not None or (state is not None and state["state"] == "running"): raise HTTPException(status_code=409, detail="A maintenance job is already running") reservation = StartReservation( token=secrets.token_urlsafe(16), @@ -384,11 +483,13 @@ def start_check(_: tuple[str, Session] = Depends(require_mutation)) -> JSONRespo return start_action("check") @router.post("/api/maintenance/backup", status_code=202, include_in_schema=False) - def start_backup(_: tuple[str, Session] = Depends(require_mutation)) -> JSONResponse: + async def start_backup(request: Request, _: tuple[str, Session] = Depends(require_mutation)) -> JSONResponse: + await _confirmation(request, {'confirmed': True}) return start_action("backup") @router.post("/api/maintenance/update", status_code=202, include_in_schema=False) - def start_update(_: tuple[str, Session] = Depends(require_mutation)) -> JSONResponse: + async def start_update(request: Request, _: tuple[str, Session] = Depends(require_mutation)) -> JSONResponse: + await _confirmation(request, {'confirmation': 'UPDATE'}) return start_action("update") return router diff --git a/scripts/install_maintenance_web.sh b/scripts/install_maintenance_web.sh index 723c00f..3d9fcd0 100755 --- a/scripts/install_maintenance_web.sh +++ b/scripts/install_maintenance_web.sh @@ -6,6 +6,9 @@ SOURCE_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)" DESTDIR="" BACKUP_ONLY=false BACKUP_DIR="" +WEB_HOSTS="" +LAN_CIDR="" +LOCK_HELD=false readonly DEPLOY_BACKUP_ROOT="/var/backups/pihole-suite-deploy" readonly APP_DIR="/var/lib/pihole-suite/app" @@ -31,10 +34,17 @@ readonly -a TARGETS=( "$APP_DIR/web/maintenance.css" "$APP_DIR/web/maintenance.js" "$SUITE_SERVICE_TARGET" + "/etc/systemd/system/pihole-suite.service.d/maintenance-web.conf" + "/etc/pihole-suite/maintenance-web.json" + "/usr/local/libexec/pihole-maintenance-backups" + "/usr/local/libexec/maintenance_runner.py" + "/usr/local/libexec/maintenance_backups.py" + "$APP_DIR/maintenance_backups.py" + "$APP_DIR/maintenance_config.py" ) usage() { - printf '%s\n' "Usage: $0 [--destdir ABSOLUTE_PATH] [--backup-only | --backup-dir ABSOLUTE_PATH]" + printf '%s\n' "Usage: $0 [--destdir ABSOLUTE_PATH] [--backup-only | --backup-dir ABSOLUTE_PATH] [--hosts HOST,IP] [--lan-cidr CIDR]" } die() { @@ -54,6 +64,14 @@ parse_args() { BACKUP_ONLY=true shift ;; + --hosts|--lan-cidr) + [[ $# -ge 2 ]] || die "$1 requires a value" + case "$1" in + --hosts) WEB_HOSTS="$2" ;; + --lan-cidr) LAN_CIDR="$2" ;; + esac + shift 2 + ;; --backup-dir) [[ $# -ge 2 ]] || die "--backup-dir requires a path" BACKUP_DIR="$2" @@ -110,17 +128,22 @@ source_for_target() { "$APP_DIR/web/maintenance.css") printf '%s\n' "$SOURCE_DIR/web/maintenance.css" ;; "$APP_DIR/web/maintenance.js") printf '%s\n' "$SOURCE_DIR/web/maintenance.js" ;; "$SUITE_SERVICE_TARGET") die "Python Suite service is generated by install.sh, not copied" ;; + /etc/systemd/system/pihole-suite.service.d/maintenance-web.conf) printf '%s\n' "$SOURCE_DIR/deploy/maintenance-web.conf" ;; + /usr/local/libexec/pihole-maintenance-backups|/usr/local/libexec/maintenance_backups.py) printf '%s\n' "$SOURCE_DIR/maintenance_backups.py" ;; + /usr/local/libexec/maintenance_runner.py) printf '%s\n' "$SOURCE_DIR/maintenance_runner.py" ;; + "$APP_DIR/maintenance_backups.py") printf '%s\n' "$SOURCE_DIR/maintenance_backups.py" ;; + "$APP_DIR/maintenance_config.py") printf '%s\n' "$SOURCE_DIR/maintenance_config.py" ;; *) die "internal unexpected target: $1" ;; esac } is_generated_target() { - [[ "$1" == "$CADDY_MAIN_TARGET" || "$1" == "$SUITE_SERVICE_TARGET" ]] + [[ "$1" == "$CADDY_MAIN_TARGET" || "$1" == "$SUITE_SERVICE_TARGET" || "$1" == /etc/pihole-suite/maintenance-web.json ]] } mode_for_target() { case "$1" in - "$RUNNER_TARGET"|"$ROLLBACK_TARGET") printf '%s\n' 0755 ;; + "$RUNNER_TARGET"|"$ROLLBACK_TARGET"|/usr/local/libexec/pihole-maintenance-backups) printf '%s\n' 0755 ;; "$SUDOERS_TARGET") printf '%s\n' 0440 ;; *) printf '%s\n' 0644 ;; esac @@ -150,6 +173,7 @@ prepare_runtime_directories() { [[ ! -L "$(destination "$APP_DIR/web")" ]] || die "refusing symlinked app web directory" install -d -m 0755 "$(destination /usr/local/libexec)" "$(destination "$APP_DIR")" "$(destination "$APP_DIR/web")" install -d -m 0750 "$(destination /var/lib/pihole-suite/jobs)" + install -d -m 0750 "$(destination /var/lib/pihole-suite/exports)" install -d -m 0770 "$(destination /var/lib/pihole-suite/sessions)" install -d -m 0700 "$(destination /var/backups/pihole-suite)" "$(destination "$DEPLOY_BACKUP_ROOT")" return @@ -161,6 +185,7 @@ prepare_runtime_directories() { [[ ! -L "$APP_DIR/web" ]] || die "refusing symlinked app web directory" install -d -o root -g root -m 0755 /usr/local/libexec "$APP_DIR" "$APP_DIR/web" install -d -o root -g pihole-suite -m 0750 /var/lib/pihole-suite/jobs + install -d -o root -g pihole-suite -m 0750 /var/lib/pihole-suite/exports install -d -o root -g pihole-suite -m 0770 /var/lib/pihole-suite/sessions install -d -o root -g root -m 0700 /var/backups/pihole-suite "$DEPLOY_BACKUP_ROOT" } @@ -180,7 +205,7 @@ backup_target() { digest="$(sha256sum -- "$target" | awk '{print $1}')" printf '%s|present|%s|%s|%s|%s|%s\n' "$target" "$backup_file" "$mode" "$uid" "$gid" "$digest" >> "$backup_dir/manifest.tsv" else - printf '%s|absent|-|-|-|-\n' "$target" >> "$backup_dir/manifest.tsv" + printf '%s|absent|-|-|-|-|-\n' "$target" >> "$backup_dir/manifest.tsv" fi } @@ -219,9 +244,10 @@ create_deployment_backup() { timestamp="$(date -u +%Y%m%dT%H%M%SZ)-$$" backup_dir="$DEPLOY_BACKUP_ROOT/$timestamp" (umask 077 && mkdir -- "$backup_dir") + install -d -o root -g root -m 0700 "$backup_dir/files" chown root:root -- "$backup_dir" chmod 0700 -- "$backup_dir" - printf 'version|1\n' > "$backup_dir/manifest.tsv" + printf 'version|2\n' > "$backup_dir/manifest.tsv" chmod 0600 -- "$backup_dir/manifest.tsv" for target in "${TARGETS[@]}"; do index=$((index + 1)) @@ -280,16 +306,40 @@ install_live() { if [[ -n "$BACKUP_DIR" ]]; then validate_existing_backup_dir else - create_deployment_backup >/dev/null + BACKUP_DIR="$(create_deployment_backup)" fi + printf 'Deployment snapshot: %s\n' "$BACKUP_DIR" + /usr/bin/python3 -c 'import os, stat; fd = os.open("/run/lock/pihole-maintenance-web.lock", os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600); metadata = os.fstat(fd); assert metadata.st_uid == 0 and stat.S_ISREG(metadata.st_mode); os.close(fd)' + exec 9<>/run/lock/pihole-maintenance-web.lock + /usr/bin/flock --nonblock 9 || die "maintenance is active; retry after completion" + LOCK_HELD=true + trap rollback_failed_install EXIT + /usr/bin/systemctl stop pihole-suite.service + for action in check backup update; do + if /usr/bin/systemctl is-active --quiet "pihole-maintenance-$action.service"; then + die "maintenance is active; retry after completion" + fi + done ensure_caddy_import local target for target in "${TARGETS[@]}"; do is_generated_target "$target" && continue install_target "$target" done + provision validate_live_installation activate_live_installation + trap - EXIT +} + +rollback_failed_install() { + local result=$? + trap - EXIT + if [[ "$result" -ne 0 ]]; then + printf 'Deployment failed; restoring snapshot %s\n' "$BACKUP_DIR" >&2 + bash "$SOURCE_DIR/scripts/rollback_maintenance_web.sh" --backup-dir "$BACKUP_DIR" || printf 'Automatic rollback failed; manual recovery required\n' >&2 + fi + exit "$result" } install_staged() { @@ -300,6 +350,13 @@ install_staged() { is_generated_target "$target" && continue install_target "$target" done + provision +} + +provision() { + local -a args=(--destdir "$DESTDIR" --hosts "$WEB_HOSTS" --lan-cidr "$LAN_CIDR") + [[ "$LOCK_HELD" == false ]] || args+=(--lock-fd 9) + /usr/bin/python3 "$SOURCE_DIR/maintenance_install.py" "${args[@]}" "$@" } main() { diff --git a/scripts/rollback_maintenance_web.sh b/scripts/rollback_maintenance_web.sh index 09bb98f..d2c65fa 100755 --- a/scripts/rollback_maintenance_web.sh +++ b/scripts/rollback_maintenance_web.sh @@ -18,6 +18,13 @@ readonly -a TARGETS=( "/var/lib/pihole-suite/app/web/maintenance.css" "/var/lib/pihole-suite/app/web/maintenance.js" "/etc/systemd/system/pihole-suite.service" + "/etc/systemd/system/pihole-suite.service.d/maintenance-web.conf" + "/etc/pihole-suite/maintenance-web.json" + "/usr/local/libexec/pihole-maintenance-backups" + "/usr/local/libexec/maintenance_runner.py" + "/usr/local/libexec/maintenance_backups.py" + "/var/lib/pihole-suite/app/maintenance_backups.py" + "/var/lib/pihole-suite/app/maintenance_config.py" ) die() { @@ -57,7 +64,7 @@ validate_manifest() { local backup_dir="$1" line target status saved mode uid gid digest actual_digest count=0 declare -A seen=() IFS= read -r line < "$backup_dir/$MANIFEST_NAME" - [[ "$line" == 'version|1' ]] || die "unsupported manifest" + [[ "$line" == 'version|2' ]] || die "unsupported manifest: use the rollback helper saved with an older snapshot" [[ -d "$backup_dir/files" && ! -L "$backup_dir/files" ]] || die "missing safe artifact directory" while IFS='|' read -r target status saved mode uid gid digest; do [[ -n "$target" ]] || die "empty manifest entry" diff --git a/tests/test_install_maintenance_web.sh b/tests/test_install_maintenance_web.sh index 975acfb..f022823 100755 --- a/tests/test_install_maintenance_web.sh +++ b/tests/test_install_maintenance_web.sh @@ -17,7 +17,7 @@ for command in apt apt-get caddy curl service sudo systemctl; do done run_staged_install() { - PATH="$GUARD_DIR:$PATH" bash "$ROOT_DIR/scripts/install_maintenance_web.sh" --destdir "$DESTDIR" + PATH="$GUARD_DIR:$PATH" bash "$ROOT_DIR/scripts/install_maintenance_web.sh" --destdir "$DESTDIR" --hosts pi.hole,192.168.178.2 --lan-cidr 192.168.178.0/24 } run_staged_install @@ -35,7 +35,7 @@ printf '%s\n' \ $'\troot * /usr/share/caddy' \ $'\tfile_server' \ '}' > "$PACKAGED_DESTDIR/etc/caddy/Caddyfile" -PATH="$GUARD_DIR:$PATH" bash "$ROOT_DIR/scripts/install_maintenance_web.sh" --destdir "$PACKAGED_DESTDIR" +PATH="$GUARD_DIR:$PATH" bash "$ROOT_DIR/scripts/install_maintenance_web.sh" --destdir "$PACKAGED_DESTDIR" --hosts pi.hole,192.168.178.2 --lan-cidr 192.168.178.0/24 grep -Fqx 'import Caddyfile.d/*' "$PACKAGED_DESTDIR/etc/caddy/Caddyfile" grep -Fqx ' auto_https disable_redirects' "$PACKAGED_DESTDIR/etc/caddy/Caddyfile" ! grep -Fqx ':80 {' "$PACKAGED_DESTDIR/etc/caddy/Caddyfile" @@ -97,6 +97,9 @@ assert_mode "$DESTDIR/var/lib/pihole-suite/app" 755 assert_mode "$DESTDIR/var/lib/pihole-suite/app/web" 755 assert_mode "$DESTDIR/var/lib/pihole-suite/app/start_suite.py" 644 assert_mode "$DESTDIR/var/lib/pihole-suite/app/maintenance_web.py" 644 +test ! -e "$DESTDIR/var/lib/pihole-suite/app/maintenance_credentials.py" +test ! -e "$DESTDIR/usr/local/sbin/pihole-maintenance-password" +test ! -e "$DESTDIR/etc/pihole-suite/maintenance-password.json" assert_mode "$DESTDIR/var/lib/pihole-suite/app/web/maintenance.html" 644 assert_mode "$DESTDIR/var/lib/pihole-suite/app/web/maintenance.css" 644 assert_mode "$DESTDIR/var/lib/pihole-suite/app/web/maintenance.js" 644 @@ -120,7 +123,7 @@ assert_mode "$SUDOERS" 440 for action in check backup update; do grep -Fqx "pihole-suite ALL=(root) NOPASSWD: /usr/bin/systemctl start --no-block pihole-maintenance-${action}.service" "$SUDOERS" done -test "$(grep -Ec '^pihole-suite ALL=' "$SUDOERS")" = 3 +test "$(grep -Ec '^pihole-suite ALL=' "$SUDOERS")" = 4 ! grep -Eq '[*]|ALL[[:space:]]*=.*(sh|bash)' "$SUDOERS" CADDY_MAIN="$DESTDIR/etc/caddy/Caddyfile" @@ -132,7 +135,9 @@ grep -Fqx 'pi.hole:8443, 192.168.178.2:8443 {' "$CADDY_SNIPPET" grep -Fqx ' tls internal' "$CADDY_SNIPPET" grep -Fqx ' @lan remote_ip 192.168.178.0/24 127.0.0.1 ::1' "$CADDY_SNIPPET" grep -Fqx ' handle @lan {' "$CADDY_SNIPPET" -grep -Fqx ' reverse_proxy 127.0.0.1:8090' "$CADDY_SNIPPET" +grep -Fqx ' reverse_proxy 127.0.0.1:8090' "$CADDY_SNIPPET" +grep -Fqx ' respond "Not found" 404' "$CADDY_SNIPPET" +grep -Fq 'path / /maintenance.css /maintenance.js /api/session /api/session/logout /api/maintenance/*' "$CADDY_SNIPPET" grep -Fqx ' respond "Forbidden" 403' "$CADDY_SNIPPET" test "$(grep -Foc '{' "$CADDY_SNIPPET")" = "$(grep -Foc '}' "$CADDY_SNIPPET")" @@ -145,6 +150,8 @@ grep -Fq 'install_python_suite_app_files' "$ROOT_DIR/install.sh" grep -Fq 'Migrating Python Suite runtime to the hardened layout' "$ROOT_DIR/install.sh" ! grep -Fq 'log "✅ Python Suite OK"' "$ROOT_DIR/install.sh" grep -Fq 'maintenance_web.py' "$ROOT_DIR/install.sh" +! grep -Fq 'maintenance_credentials.py' "$ROOT_DIR/install.sh" +! grep -Fq -- '--maintenance-password-file' "$ROOT_DIR/install.sh" grep -Fq 'web/maintenance.html' "$ROOT_DIR/install.sh" grep -Fq 'web/maintenance.css' "$ROOT_DIR/install.sh" grep -Fq 'web/maintenance.js' "$ROOT_DIR/install.sh" diff --git a/tests/test_maintenance_backups.py b/tests/test_maintenance_backups.py new file mode 100644 index 0000000..f082780 --- /dev/null +++ b/tests/test_maintenance_backups.py @@ -0,0 +1,120 @@ +import hashlib +import json +import tarfile + +import pytest +from test_maintenance_runner import held_lock, make_context + +from maintenance_backups import delete_backup, read_index, refresh_exports +from maintenance_runner import run_job + + +def test_export_index_and_confirmed_deletion(tmp_path): + context, _ = make_context(tmp_path) + result = run_job('backup', context) + assert result['state'] == 'succeeded' + entries = read_index(context.export_root) + assert len(entries) == 1 + entry = entries[0] + archive = context.export_root / (entry['backup_id'] + '.tar.gz') + assert hashlib.sha256(archive.read_bytes()).hexdigest() == entry['sha256'] + with tarfile.open(archive) as handle: + assert 'manifest.json' in handle.getnames() + assert 'pihole/pihole.toml' in handle.getnames() + assert archive.stat().st_mode & 0o777 == 0o640 + refresh_exports(context) + assert read_index(context.export_root) == entries + with held_lock(context.lock_file), pytest.raises(BlockingIOError): + delete_backup(context, entry['backup_id']) + delete_backup(context, entry['backup_id']) + assert read_index(context.export_root) == [] + assert not archive.exists() + + +@pytest.mark.parametrize('bad_id', ['../outside', '.', 'anything', 'a/b']) +def test_delete_rejects_paths(tmp_path, bad_id): + context, _ = make_context(tmp_path) + with pytest.raises(ValueError): + delete_backup(context, bad_id) + + +def test_manipulated_manifest_is_not_deleted(tmp_path): + context, _ = make_context(tmp_path) + result = run_job('backup', context) + entry = read_index(context.export_root)[0] + from pathlib import Path + backup = Path(result['result']['backup_path']) + manifest = json.loads((backup / 'manifest.json').read_text()) + manifest['job_id'] = 'different' + (backup / 'manifest.json').write_text(json.dumps(manifest)) + with pytest.raises(ValueError): + delete_backup(context, entry['backup_id']) + assert backup.exists() + + +def test_symlink_manifest_and_failed_index_publish_preserve_backup(tmp_path, monkeypatch): + from pathlib import Path + + import maintenance_backups + context, _ = make_context(tmp_path) + result = run_job('backup', context) + entry = read_index(context.export_root)[0] + backup = Path(result['result']['backup_path']) + manifest = backup / 'manifest.json' + external = tmp_path / 'external-manifest.json' + manifest.rename(external) + manifest.symlink_to(external) + with pytest.raises(ValueError): + delete_backup(context, entry['backup_id']) + manifest.unlink() + external.rename(manifest) + + def fail_publish(*args): + raise OSError('simulated publication failure') + + monkeypatch.setattr(maintenance_backups, '_publish_index', fail_publish) + with pytest.raises(OSError): + delete_backup(context, entry['backup_id']) + assert backup.exists() + assert read_index(context.export_root) == [entry] + + +def test_corrupt_export_never_republished(tmp_path): + context, _ = make_context(tmp_path) + run_job('backup', context) + entry = read_index(context.export_root)[0] + archive = context.export_root / (entry['backup_id'] + '.tar.gz') + archive.write_bytes(b'corrupt archive') + with pytest.raises((ValueError, tarfile.TarError)): + refresh_exports(context) + + +def test_truncated_export_finishes_job_as_failed(tmp_path): + context, _ = make_context(tmp_path) + run_job('backup', context) + entry = read_index(context.export_root)[0] + archive = context.export_root / (entry['backup_id'] + '.tar.gz') + archive.write_bytes(archive.read_bytes()[:100]) + result = run_job('backup', context) + assert result['state'] == 'failed' + assert json.loads((context.state_dir / 'current.json').read_text())['state'] == 'failed' + + +def test_interrupted_deletion_recovers_on_export_refresh(tmp_path, monkeypatch): + import maintenance_backups + context, _ = make_context(tmp_path) + run_job('backup', context) + entry = read_index(context.export_root)[0] + original = maintenance_backups.shutil.rmtree + + def interrupt(path): + (path / 'manifest.json').unlink() + raise OSError('simulated interrupted cleanup') + + monkeypatch.setattr(maintenance_backups.shutil, 'rmtree', interrupt) + with pytest.raises(OSError): + delete_backup(context, entry['backup_id']) + monkeypatch.setattr(maintenance_backups.shutil, 'rmtree', original) + refresh_exports(context) + assert not list(context.backup_root.glob('.delet*')) + assert read_index(context.export_root) == [] diff --git a/tests/test_maintenance_browser.py b/tests/test_maintenance_browser.py new file mode 100644 index 0000000..61f71f6 --- /dev/null +++ b/tests/test_maintenance_browser.py @@ -0,0 +1,120 @@ +from pathlib import Path + +import pytest + +playwright = pytest.importorskip('playwright.sync_api') +ROOT = Path(__file__).resolve().parents[1] + + +def test_browser_states_languages_security_and_layout(tmp_path): + from test_maintenance_web import state_payload + job = state_payload() + job['result']['system']['host'] = '' + authenticated = False + requests = [] + errors = [] + with playwright.sync_playwright() as runtime: + browser = runtime.chromium.launch(headless=True) + page = browser.new_page(locale='de-DE') + page.on('pageerror', lambda error: errors.append(str(error))) + + def route_request(route): + nonlocal authenticated + path = route.request.url.removeprefix('https://pi.hole:8443') + method = route.request.method + requests.append((method, path)) + assets = {'/': ('maintenance.html', 'text/html'), '/maintenance.css': ('maintenance.css', 'text/css'), '/maintenance.js': ('maintenance.js', 'text/javascript')} + if path in assets: + name, mime = assets[path] + route.fulfill(body=(ROOT / 'web' / name).read_text(), content_type=mime) + elif path == '/api/session': + if method == 'POST': + authenticated = route.request.post_data_json.get('password') == 'browser-test-password' + route.fulfill(status=200 if authenticated else 401, json={'csrf_token': 'test-only', 'pihole_url': 'http://pi.hole/admin/'}) + elif path == '/api/session/logout': + authenticated = False + route.fulfill(status=204) + elif not authenticated: + route.fulfill(status=401, json={}) + elif path == '/api/maintenance/status': + route.fulfill(json=job) + elif path == '/api/maintenance/backups': + route.fulfill(json=[]) + elif method == 'POST': + route.fulfill(status=202, json={'state': 'accepted'}) + else: + route.fulfill(status=404) + + page.route('https://pi.hole:8443/**', route_request) + page.goto('https://pi.hole:8443/') + page.wait_for_load_state('networkidle') + page.fill('#password', 'incorrect-password') + page.click('#login-button') + page.wait_for_function("document.querySelector('#login-error').textContent.includes('fehlgeschlagen')") + assert page.locator('#login-error').is_visible() + page.fill('#password', 'browser-test-password') + page.click('#login-button') + page.wait_for_function("document.querySelector('#status').textContent === 'Auftrag erfolgreich'") + assert page.locator('#system-metrics').inner_text().find('') >= 0 + assert page.locator('#system-metrics img').count() == 0 + assert not page.locator('details').evaluate('(node) => node.open') + assert page.locator('#backup-button').is_disabled() + assert page.locator('#update-button').is_disabled() + page.check('#backup-confirmed') + assert page.locator('#backup-button').is_enabled() + page.fill('#update-confirmation', 'update') + assert page.locator('#update-button').is_disabled() + page.fill('#update-confirmation', 'UPDATE') + assert page.locator('#update-button').is_enabled() + page.select_option('#language', 'en') + assert page.locator('#check-button').inner_text() == 'Run system check' + assert page.evaluate('Object.keys(localStorage)') == ['maintenance-language'] + page.reload() + page.wait_for_function("document.querySelector('#status').textContent === 'Job succeeded'") + assert page.locator('#dashboard').is_visible() + assert page.locator('#language').input_value() == 'en' + page.select_option('#language', 'de') + assert page.locator('#check-button').inner_text() == 'Systemcheck starten' + for state in ['idle', 'running', 'failed', 'succeeded']: + page.evaluate('(state) => { lastJob = {...lastJob, state}; renderJob(lastJob); }', state) + assert page.locator('#status').get_attribute('data-state') == state + page.evaluate('lastJob = {state: "succeeded", result: {}, steps: [{name:"future_code", ok:false, required:false, detail:"unknown"}]}; renderJob(lastJob)') + assert 'Optionale Warnung' in page.locator('#step-list').inner_text() + assert 'future code' in page.locator('#step-list').inner_text() + page.click('#refresh-button') + page.wait_for_load_state('networkidle') + screenshots = Path('/tmp/pihole-maintenance-review') + screenshots.mkdir(exist_ok=True) + for width in [1440, 1063, 390, 320]: + page.set_viewport_size({'width': width, 'height': 1000}) + assert page.evaluate('document.documentElement.scrollWidth <= window.innerWidth'), width + page.screenshot(path=str(screenshots / f'dashboard-{width}.png'), full_page=True) + page.set_viewport_size({'width': 1063, 'height': 1000}) + page.evaluate('document.body.style.zoom = "2"') + assert page.evaluate('document.documentElement.scrollWidth <= window.innerWidth') + page.evaluate('document.body.style.zoom = "1"') + page.emulate_media(reduced_motion='reduce') + assert page.locator('#check-button').evaluate('(node) => getComputedStyle(node).transitionDuration') == '0s' + page.locator('#refresh-button').focus() + page.keyboard.press('Tab') + assert page.evaluate('document.activeElement.id') == 'check-button' + assert page.locator('#check-button').evaluate('(node) => getComputedStyle(node).outlineStyle') != 'none' + page.click('#check-button') + assert page.locator('#backup-button').is_disabled() + assert page.locator('#update-button').is_disabled() + assert page.locator('#status').inner_text() == 'Auftrag wird gestartet' + job['state'] = 'running' + job['job_id'] += 'new' + page.wait_for_function("document.querySelector('#status').textContent === 'Wartung läuft'") + job['state'] = 'succeeded' + page.wait_for_function("document.querySelector('#status').textContent === 'Auftrag erfolgreich'") + page.wait_for_load_state('networkidle') + status_count = requests.count(('GET', '/api/maintenance/status')) + page.wait_for_timeout(2200) + assert requests.count(('GET', '/api/maintenance/status')) == status_count + authenticated = False + page.click('#refresh-button') + page.wait_for_function("document.activeElement.id === 'password'") + assert 'Sitzung abgelaufen' in page.locator('#login-error').inner_text() + assert not errors + browser.close() diff --git a/tests/test_maintenance_config.py b/tests/test_maintenance_config.py new file mode 100644 index 0000000..1b74a20 --- /dev/null +++ b/tests/test_maintenance_config.py @@ -0,0 +1,18 @@ +import pytest + +from maintenance_config import caddy_config, configuration + + +def test_network_config_and_route_boundary(): + config = configuration('pi.hole,192.168.178.2', '192.168.178.0/24') + assert config['allowed_origins'] == ['https://pi.hole:8443', 'https://192.168.178.2:8443'] + caddy = caddy_config(config) + assert 'path / /maintenance.css /maintenance.js /api/session /api/session/logout /api/maintenance/*' in caddy + assert 'respond "Not found" 404' in caddy + assert '/dns' not in caddy + + +@pytest.mark.parametrize('host,network', [('host {', '192.168.1.0/24'), ('https://pi.hole', '192.168.1.0/24'), ('pi.hole', '0.0.0.0/0'), ('pi.hole', '8.8.8.0/24')]) +def test_invalid_or_public_network_rejected(host, network): + with pytest.raises(ValueError): + configuration(host, network) diff --git a/tests/test_maintenance_runner.py b/tests/test_maintenance_runner.py index cef4261..4371238 100644 --- a/tests/test_maintenance_runner.py +++ b/tests/test_maintenance_runner.py @@ -1,20 +1,20 @@ -import sqlite3 -import sys -from pathlib import Path import fcntl import hashlib import json import os import shutil +import sqlite3 +import sys import time from contextlib import contextmanager +from pathlib import Path import pytest sys.path.insert(0, str(Path(__file__).resolve().parents[1])) -from maintenance_runner import CommandResult, RunnerContext, run_job import maintenance_runner +from maintenance_runner import CommandResult, RunnerContext, run_job class RecordingExecutor: @@ -72,6 +72,7 @@ def make_context(tmp_path: Path, *, corrupt_database: bool = False, fail_dns: bo etc_root=etc_root, state_dir=tmp_path / "state" / "jobs", backup_root=tmp_path / "backups", + export_root=tmp_path / 'exports', lock_file=tmp_path / "lock" / "maintenance.lock", command_executor=commands, pihole_bin="/usr/local/bin/pihole", @@ -139,6 +140,7 @@ def observe_state(argv: list[str], timeout_seconds: int | None = None) -> Comman etc_root=context.etc_root, state_dir=context.state_dir, backup_root=context.backup_root, + export_root=context.export_root, lock_file=context.lock_file, command_executor=commands.__call__, pihole_bin=context.pihole_bin, @@ -301,6 +303,7 @@ def test_update_reports_reboot_requirement_without_rebooting(tmp_path): pihole_bin=context.pihole_bin, state_gid=context.state_gid, reboot_required_path=reboot_marker, + export_root=context.export_root, ) result = run_job("update", context) @@ -421,6 +424,20 @@ def test_manifest_rejects_empty_or_incomplete_file_coverage(tmp_path): maintenance_runner._verify_manifest(staging) +def test_atomic_json_write_syncs_file_and_parent_directory(tmp_path, monkeypatch): + context, _ = make_context(tmp_path) + calls = [] + real_fsync = maintenance_runner.os.fsync + + def record_fsync(descriptor): + calls.append(descriptor) + real_fsync(descriptor) + + monkeypatch.setattr(maintenance_runner.os, 'fsync', record_fsync) + maintenance_runner._atomic_json_write(context.state_dir / 'atomic.json', {'state': 'test'}, context) + assert len(calls) == 2 + + @pytest.mark.parametrize("unsafe_kind", ("absolute", "traversal")) def test_manifest_rejects_absolute_and_traversal_paths(tmp_path, unsafe_kind): staging = tmp_path / "staging" diff --git a/tests/test_maintenance_web.py b/tests/test_maintenance_web.py index 76e8a4e..1c87158 100644 --- a/tests/test_maintenance_web.py +++ b/tests/test_maintenance_web.py @@ -78,9 +78,110 @@ def post_action(client: TestClient, cookies: dict[str, str], csrf_token: str, ac f"/api/maintenance/{action}", cookies=cookies, headers={"Origin": "https://pi.hole:8443", "X-CSRF-Token": csrf_token}, + json={"confirmed": True} if action == "backup" else {"confirmation": "UPDATE"} if action == "update" else {}, ) +def test_existing_api_key_remains_the_browser_credential(client): + assert client.post('/api/session', json={'password': 'correct horse battery staple'}).status_code == 200 + + +def test_backup_routes_auth_and_not_found(client, settings, tmp_path): + settings.export_root = tmp_path / 'exports' + assert client.get('/api/maintenance/backups').status_code == 401 + _, csrf = login(client) + assert client.get('/api/maintenance/backups').json() == [] + assert client.get('/api/maintenance/backups/invalid/download').status_code == 404 + assert client.request('DELETE', '/api/maintenance/backups/invalid', json={'confirmation': 'invalid'}, headers={'Origin': 'https://pi.hole:8443', 'X-CSRF-Token': csrf}).status_code == 404 + + +def test_verified_download_and_delete_contract(client, settings, tmp_path): + import hashlib + + from test_maintenance_runner import make_context + + from maintenance_backups import delete_backup + from maintenance_runner import run_job + context, _ = make_context(tmp_path) + assert run_job('backup', context)['state'] == 'succeeded' + settings.export_root = context.export_root + _, csrf = login(client) + entry = client.get('/api/maintenance/backups').json()[0] + route = '/api/maintenance/backups/' + entry['backup_id'] + download = client.get(route + '/download') + assert download.status_code == 200 + assert hashlib.sha256(download.content).hexdigest() == entry['sha256'] + assert download.headers['content-disposition'].startswith('attachment; filename="pihole-backup-') + assert download.headers['x-content-type-options'] == 'nosniff' + assert download.headers['cache-control'] == 'no-store' + headers = {'Origin': 'https://pi.hole:8443', 'X-CSRF-Token': csrf} + assert client.request('DELETE', route, json={'confirmation': entry['backup_id']}).status_code == 403 + assert client.request('DELETE', route, headers=headers, json={'confirmation': 'wrong'}).status_code == 400 + assert not settings.dispatcher.calls + + def dispatch(argv): + assert argv == ['/usr/bin/sudo', '-n', '/usr/local/libexec/pihole-maintenance-backups', 'delete', entry['backup_id']] + delete_backup(context, argv[-1]) + + settings.dispatcher = dispatch + assert client.request('DELETE', route, headers=headers, json={'confirmation': entry['backup_id']}).status_code == 204 + assert client.get(route + '/download').status_code == 404 + assert client.get('/api/maintenance/backups').json() == [] + + +def test_slow_delete_keeps_status_responsive_and_blocks_jobs(client, settings, tmp_path): + import threading + + import httpx + from test_maintenance_runner import make_context + + from maintenance_runner import run_job + context, _ = make_context(tmp_path) + run_job('backup', context) + settings.export_root = context.export_root + cookies, csrf = login(client) + entry = client.get('/api/maintenance/backups').json()[0] + started, release = threading.Event(), threading.Event() + + def slow_dispatch(argv): + started.set() + assert release.wait(5) + + settings.dispatcher = slow_dispatch + + async def exercise(): + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=client.app), base_url='https://pi.hole:8443', cookies=cookies) as session: + headers = {'Origin': 'https://pi.hole:8443', 'X-CSRF-Token': csrf} + deletion = asyncio.create_task(session.request('DELETE', '/api/maintenance/backups/' + entry['backup_id'], headers=headers, json={'confirmation': entry['backup_id']})) + try: + assert await asyncio.to_thread(started.wait, 2) + response = await asyncio.wait_for(session.get('/api/maintenance/status'), 1) + assert response.status_code == 200 + response = await asyncio.wait_for(session.post('/api/maintenance/check', headers=headers), 1) + assert response.status_code == 409 + finally: + release.set() + assert (await deletion).status_code == 204 + asyncio.run(exercise()) + + +def test_resume_rotates_csrf(client): + cookies, old_token = login(client) + resumed = client.get('/api/session') + assert resumed.status_code == 200 + assert resumed.json()['csrf_token'] != old_token + assert post_action(client, cookies, old_token, 'check').status_code == 403 + assert client.get('/api/session').status_code == 200 + + +@pytest.mark.parametrize('action,body', [('backup', {}), ('backup', {'confirmed': 1}), ('update', {}), ('update', {'confirmation': 'update'})]) +def test_server_requires_exact_confirmation(client, dispatcher, action, body): + _, csrf = login(client) + response = client.post(f'/api/maintenance/{action}', json=body, headers={'Origin': 'https://pi.hole:8443', 'X-CSRF-Token': csrf}) + assert response.status_code == 400 + assert not dispatcher.calls + + def test_job_status_requires_session(client: TestClient): assert client.get("/api/maintenance/status").status_code == 401 @@ -326,6 +427,17 @@ def test_browser_ui_uses_confirmation_text_content_and_running_only_polling(clie assert "2000" in script +def test_structured_bilingual_dashboard(client): + page = client.get('/').text + script = client.get('/maintenance.js').text + for element in ['system-metrics', 'step-list', 'backup-list', 'language', 'login-error']: + assert f'id="{element}"' in page + assert ' element }}, + document: {{ querySelector: () => element, addEventListener() {{}} }}, window: {{ clearTimeout() {{}}, setTimeout() {{ return 1; }}, location: {{ origin: "https://pi.hole:8443" }} }}, Headers, fetch: async () => {{ throw new Error("fetch is not expected"); }}, diff --git a/web/maintenance.css b/web/maintenance.css index 4b20359..512ff26 100644 --- a/web/maintenance.css +++ b/web/maintenance.css @@ -1,7 +1,90 @@ -:root { color-scheme: light dark; font-family: system-ui, sans-serif; } -body { margin: 0; padding: 2rem; } -main { margin: auto; max-width: 68rem; } -.actions { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); } -article, section { border: 1px solid currentColor; border-radius: .5rem; margin: 1rem 0; padding: 1rem; } -button, input { font: inherit; margin-top: .5rem; } -pre { overflow-wrap: anywhere; white-space: pre-wrap; } +:root { + color-scheme: dark; + font-family: system-ui, -apple-system, sans-serif; + --bg: #0d1419; + --surface: #151f26; + --line: #354650; + --text: #edf3f5; + --muted: #a9bbc4; + --healthy: #77e0c6; + --attention: #f3c578; + --danger: #ffaaa7; + color: var(--text); + background: var(--bg); + font-synthesis: none; + line-height: 1.5; + scrollbar-color: var(--line) var(--bg); +} +* { box-sizing: border-box; } +[hidden] { display: none !important; } +body { margin: 0; } +::selection { background: var(--healthy); color: var(--bg); } +:focus-visible { outline: 3px solid var(--healthy); outline-offset: 4px; } +a { color: var(--text); text-underline-offset: .25em; } +button, input, select { font: inherit; color: inherit; min-height: 44px; border: 1px solid var(--line); border-radius: 6px; background: var(--bg); } +input, select { border-color: #607681; } +button, select { cursor: pointer; padding: .6rem .85rem; } +button:hover:not(:disabled), select:hover { background: #25353f; border-color: var(--muted); } +button:disabled, input:disabled { opacity: .5; cursor: not-allowed; } +input { padding: .65rem; width: 100%; caret-color: var(--healthy); margin: .4rem 0 .75rem; } +button.primary { color: #0c2521; background: var(--healthy); border-color: var(--healthy); font-weight: 650; } +button.primary:hover:not(:disabled) { background: #a0eddb; } +button.caution { color: var(--attention); border-color: #8e713f; } +button.danger { color: var(--danger); } +h1, h2, h3, p { margin: 0; } +h1 { font-size: clamp(1.65rem, 3vw, 2.35rem); line-height: 1.2; letter-spacing: -.025em; font-weight: 650; } +h2 { font-size: 1.1rem; font-weight: 650; } +h3 { font-size: 1.15rem; } +p { margin: .65rem 0; max-width: 72ch; } +.muted, dt, footer, #connection { color: var(--muted); } +.error, [data-state="failed"], #login-error { color: var(--danger); } +[data-state="succeeded"], .passed { color: var(--healthy); } +[data-state="running"], [data-state="starting"], .warning { color: var(--attention); } +.topbar { min-height: 80px; border-bottom: 1px solid var(--line); padding: 1rem clamp(1rem, 4vw, 3rem); display: flex; align-items: center; justify-content: space-between; gap: 1rem; } +.brand { text-decoration: none; font-size: 1.15rem; font-weight: 700; white-space: nowrap; min-height: 44px; display: inline-flex; align-items: center; } +.brand span { font-weight: 400; color: var(--muted); margin-left: .4rem; } +nav { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; font-size: .9rem; } +nav a { min-height: 44px; display: inline-flex; align-items: center; } +main { max-width: 1380px; margin: auto; padding: 2.5rem clamp(1rem, 4vw, 3rem) 1rem; } +.page-heading { display: flex; justify-content: space-between; align-items: center; gap: 1.5rem; margin-bottom: 2rem; } +.workspace { display: grid; grid-template-columns: minmax(0, 1fr) minmax(260px, 320px); gap: 2.5rem; } +.instruments, .workspace > *, dl, dd { min-width: 0; } +.overview { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 1.5rem; } +.section-head { display: flex; justify-content: space-between; align-items: center; gap: 1rem; flex-wrap: wrap; } +.section-head > span { font-size: .85rem; color: var(--muted); } +.status-title { font-size: clamp(1.5rem, 2.5vw, 2.1rem); font-weight: 600; line-height: 1.2; margin-top: 1.5rem; } +.job-facts { display: grid; grid-template-columns: 1fr 1.4fr 1fr; gap: 1rem; margin: 1.6rem 0 0; padding-top: 1.2rem; border-top: 1px solid var(--line); } +dt { font-size: .8rem; } +dd { margin: .4rem 0 0; overflow-wrap: anywhere; font-variant-numeric: tabular-nums; } +.system, .checks { margin-top: 2rem; } +.system > p { font-size: .85rem; } +.metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0 1.5rem; margin: 1rem 0 0; } +.metrics > div { padding: 1rem 0; border-bottom: 1px solid var(--line); } +.metrics dd { font-size: 1.1rem; } +#step-list { list-style: none; padding: 0; margin: 1rem 0; } +#step-list li { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; padding: .8rem 0; border-bottom: 1px solid var(--line); overflow-wrap: anywhere; } +.step-state { font-size: .85rem; text-align: right; flex-shrink: 0; } +.actions { border-left: 1px solid var(--line); padding-left: 1.7rem; } +.actions > p { font-size: .85rem; } +.actions article { padding: 1.4rem 0; border-bottom: 1px solid var(--line); } +.actions article p, .actions label { font-size: .9rem; } +.actions article button { width: 100%; margin-top: .5rem; } +.checkbox { display: flex; gap: .65rem; align-items: center; min-height: 44px; } +input[type="checkbox"] { width: 20px; height: 20px; min-height: 20px; flex-shrink: 0; margin: 0; accent-color: var(--healthy); } +.backups { margin-top: 2.5rem; padding-top: 1.5rem; border-top: 1px solid var(--line); } +.backup-row { padding: 1.2rem 0; border-bottom: 1px solid var(--line); display: grid; grid-template-columns: minmax(0, 1fr) minmax(200px, 300px); gap: 1.5rem; } +.backup-row code { font-size: .8rem; overflow-wrap: anywhere; display: block; } +.backup-row a { display: inline-flex; align-items: center; min-height: 44px; color: var(--healthy); } +.backup-row label { display: block; font-size: .85rem; } +.backup-row button { width: 100%; } +details { margin-top: 1.5rem; border: 1px solid var(--line); border-radius: 8px; } +summary { cursor: pointer; padding: 1rem; min-height: 44px; } +pre { padding: 1rem; margin: 0; overflow-wrap: anywhere; white-space: pre-wrap; font-size: .8rem; } +.login { max-width: 520px; margin: 4rem auto; } +.login form { margin-top: 2rem; } +.login button { width: 100%; } +footer { margin-top: 2.5rem; padding: 1rem 0; font-size: .8rem; } +.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } +@media (max-width: 1000px) { .topbar { align-items: flex-start; } nav { gap: .65rem; } .workspace { gap: 1.5rem; } .job-facts { grid-template-columns: 1fr; gap: .8rem; } .job-facts > div { display: flex; justify-content: space-between; gap: 1rem; } .job-facts dd { margin: 0; text-align: right; } } +@media (max-width: 719px) { .topbar { flex-direction: column; gap: .5rem; padding-block: .75rem; } main { padding-top: 1.5rem; } .page-heading { align-items: flex-start; flex-direction: column; gap: 1rem; } .workspace { grid-template-columns: 1fr; } .actions { border-left: 0; border-top: 1px solid var(--line); padding: 1.5rem 0 0; } .metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } .backup-row { grid-template-columns: 1fr; gap: .75rem; } .overview { padding: 1rem; } .login { margin: 1.5rem auto; } .step-state { flex-shrink: 1; } } +@media (prefers-reduced-motion: no-preference) { button { transition: background-color 120ms ease-out; } } diff --git a/web/maintenance.html b/web/maintenance.html index 4938ca6..0868dbc 100644 --- a/web/maintenance.html +++ b/web/maintenance.html @@ -3,30 +3,63 @@ - Pi-hole Wartung + Pi-hole · Wartungs-Flugplan + +
+ Pi-hole Wartung + +
-

Pi-hole Wartung

-
-

Anmelden

+ -
diff --git a/web/maintenance.js b/web/maintenance.js index cbe581b..48e0ab8 100644 --- a/web/maintenance.js +++ b/web/maintenance.js @@ -1,140 +1,349 @@ "use strict"; +const dictionaries = { + de: { + maintenance: "Wartung", language: "Sprache", flightplan: "Wartungs-Flugplan", openPihole: "Pi-hole öffnen", logout: "Abmelden", login: "Anmelden", apiKey: "Suite-API-Key", + loginIntro: "Der sichere Arbeitsplatz für Pi-hole und Unbound. Prüfen, sichern und kontrolliert aktualisieren.", apiKeyHint: "Verwendet den bestehenden SUITE_API_KEY, nicht das Pi-hole-Kennwort.", + disconnected: "Nicht verbunden", connected: "Verbunden", unreachable: "Nicht erreichbar", expired: "Sitzung abgelaufen. Bitte erneut anmelden.", loginFailed: "Anmeldung fehlgeschlagen. API-Key prüfen.", rateLimited: "Zu viele Versuche. Bitte fünf Minuten warten.", + copyAddress: "Wartungsadresse kopieren", copied: "Wartungsadresse kopiert.", copyFailed: "Kopieren nicht möglich. Adresse aus der Browserzeile übernehmen.", overview: "Betriebsübersicht", refresh: "Neu laden", lastJob: "Letzter Auftrag", time: "Zeitpunkt", requiredChecks: "Pflichtprüfungen bestanden", system: "Systemwerte", snapshot: "Momentaufnahme des letzten Auftrags, keine Live-Messung.", checklist: "Prüfablauf", actions: "Wartung ausführen", oneJob: "Ein Auftrag zur Zeit. Sicherheitsprüfungen bleiben aktiv.", + check: "Systemcheck", backup: "Backup", update: "Update", checkDescription: "Prüft Pi-hole, Unbound und beide DNS-Pfade. Ohne Änderungen.", backupDescription: "Sichert Konfiguration und Datenbanken. Prüft alle Dateien vor der Freigabe.", updateDescription: "Erstellt zuerst ein Backup, aktualisiert das System und prüft anschließend DNS. Kann länger dauern.", startCheck: "Systemcheck starten", startBackup: "Backup erstellen", startUpdate: "Update starten", confirmBackup: "Backup bestätigen", confirmUpdate: "Zum Bestätigen UPDATE eingeben", + backups: "Verifizierte Backups", retention: "Maximal 10 Sicherungen", backupPrivacy: "Downloads enthalten private Konfiguration. Sicher aufbewahren. Wiederherstellung nur lokal, nicht im Browser.", noBackups: "Noch keine verifizierten Backups verfügbar.", backupsFailed: "Backupliste nicht verfügbar. Bitte neu laden.", download: "Archiv herunterladen", deleteBackup: "Backup endgültig löschen", deleteLabel: "Zum Löschen die vollständige Backup-ID eingeben", deleted: "Backup gelöscht.", deleteFailed: "Backup nicht gelöscht. Status neu laden und Bestätigung prüfen.", verified: "Verifiziert", details: "Technische Details", footer: "Lokal betrieben. Bewusst gewartet.", + idle: "Bereit für den ersten Check", starting: "Auftrag wird gestartet", running: "Wartung läuft", succeeded: "Auftrag erfolgreich", failed: "Auftrag fehlgeschlagen", idleHint: "Starte einen Systemcheck, um den Zustand zu prüfen.", runningHint: "Der Runner arbeitet. Prüfergebnisse erscheinen nach Abschluss; keine geschätzten Fortschritte.", successHint: "Der letzte Auftrag ist abgeschlossen. Die Werte zeigen seinen Prüfzeitpunkt.", failedHint: "Mindestens ein erforderlicher Schritt ist fehlgeschlagen. Technische Details prüfen.", noSteps: "Noch keine Prüfergebnisse.", passed: "Bestanden", optionalWarning: "Optionale Warnung", stepFailed: "Fehlgeschlagen", notAvailable: "Nicht verfügbar", startFailed: "Auftrag nicht gestartet. Neu laden und erneut versuchen.", busy: "Ein Wartungsauftrag läuft bereits. Status neu laden.", forbidden: "Sicherheitsprüfung abgelehnt. Seite neu laden und erneut bestätigen.", networkError: "Verbindung unterbrochen. Neu laden, bevor ein weiterer Auftrag gestartet wird.", startTimeout: "Start noch nicht bestätigt. Status neu laden; keinen zweiten Auftrag starten.", reboot: "Neustart erforderlich. Bitte lokal einplanen.", + host: "Host", temperature: "Temperatur", uptime: "Laufzeit", load: "Last · 1 / 5 / 15 min", memory: "RAM · genutzt / gesamt", disk: "Root-Dateisystem · genutzt / gesamt", architecture: "Architektur", operatingSystem: "Betriebssystem", kernel: "Kernel", days: "Tage", + pihole_ftl: "Pi-hole FTL", unbound: "Unbound", dns_pihole: "DNS über Pi-hole", dns_unbound: "DNS über Unbound", apt_update: "Paketlisten aktualisieren", apt_upgrade: "Systempakete aktualisieren", pihole_update: "Pi-hole aktualisieren", pihole_gravity: "Gravity aktualisieren", completed: "Abgeschlossen", backup_failed: "Backup fehlgeschlagen", update_failed: "Update fehlgeschlagen", runner_failed: "Runner fehlgeschlagen", check_failed: "Systemcheck fehlgeschlagen", + pihole_version: "Pi-hole-Version", unbound_version: "Unbound-Version", unbound_config: "Unbound-Konfiguration", listeners: "DNS-Ports", apt_simulation: "Paketupdate simulieren", timed_out: "Zeitüberschreitung", command_failed: "Befehl fehlgeschlagen", no_answer: "Keine DNS-Antwort", required_listener_missing: "Erforderlicher DNS-Port fehlt", + }, + en: { + maintenance: "Maintenance", language: "Language", flightplan: "Maintenance flight plan", openPihole: "Open Pi-hole", logout: "Sign out", login: "Sign in", apiKey: "Suite API key", + loginIntro: "Your secure workspace for Pi-hole and Unbound. Check, back up and update with control.", apiKeyHint: "Uses the existing SUITE_API_KEY, not your Pi-hole password.", + disconnected: "Disconnected", connected: "Connected", unreachable: "Unreachable", expired: "Session expired. Please sign in again.", loginFailed: "Sign-in failed. Check your API key.", rateLimited: "Too many attempts. Please wait five minutes.", + copyAddress: "Copy maintenance address", copied: "Maintenance address copied.", copyFailed: "Unable to copy. Copy the address from your browser instead.", overview: "Operations overview", refresh: "Refresh", lastJob: "Last job", time: "Timestamp", requiredChecks: "Required checks passed", system: "System readings", snapshot: "Snapshot from the last job, not live telemetry.", checklist: "Check sequence", actions: "Run maintenance", oneJob: "One job at a time. Safety checks stay active.", + check: "System check", backup: "Backup", update: "Update", checkDescription: "Checks Pi-hole, Unbound and both DNS paths. Makes no changes.", backupDescription: "Backs up configuration and databases. Verifies every file before release.", updateDescription: "Creates a backup first, updates the system, then checks DNS. May take a while.", startCheck: "Run system check", startBackup: "Create backup", startUpdate: "Start update", confirmBackup: "Confirm backup", confirmUpdate: "Type UPDATE to confirm", + backups: "Verified backups", retention: "Up to 10 backups", backupPrivacy: "Downloads contain private configuration. Store securely. Restore locally, not in this browser.", noBackups: "No verified backups available yet.", backupsFailed: "Backup list unavailable. Please refresh.", download: "Download archive", deleteBackup: "Permanently delete backup", deleteLabel: "Enter the full backup ID to delete", deleted: "Backup deleted.", deleteFailed: "Backup not deleted. Refresh status and check the confirmation.", verified: "Verified", details: "Technical details", footer: "Locally hosted. Deliberately maintained.", + idle: "Ready for the first check", starting: "Starting job", running: "Maintenance running", succeeded: "Job succeeded", failed: "Job failed", idleHint: "Run a system check to inspect the current state.", runningHint: "The runner is working. Check results appear on completion; progress is not estimated.", successHint: "The last job is complete. Readings reflect its check time.", failedHint: "At least one required step failed. Inspect the technical details.", noSteps: "No check results yet.", passed: "Passed", optionalWarning: "Optional warning", stepFailed: "Failed", notAvailable: "Unavailable", startFailed: "Job not started. Refresh and try again.", busy: "A maintenance job is already running. Refresh status.", forbidden: "Security check rejected. Reload the page and confirm again.", networkError: "Connection interrupted. Refresh before starting another job.", startTimeout: "Start not yet confirmed. Refresh status; do not start a second job.", reboot: "Reboot required. Please schedule locally.", + host: "Host", temperature: "Temperature", uptime: "Uptime", load: "Load · 1 / 5 / 15 min", memory: "RAM · used / total", disk: "Root filesystem · used / total", architecture: "Architecture", operatingSystem: "Operating system", kernel: "Kernel", days: "days", + pihole_ftl: "Pi-hole FTL", unbound: "Unbound", dns_pihole: "DNS via Pi-hole", dns_unbound: "DNS via Unbound", apt_update: "Refresh package lists", apt_upgrade: "Upgrade system packages", pihole_update: "Update Pi-hole", pihole_gravity: "Update Gravity", completed: "Completed", backup_failed: "Backup failed", update_failed: "Update failed", runner_failed: "Runner failed", check_failed: "System check failed", + pihole_version: "Pi-hole version", unbound_version: "Unbound version", unbound_config: "Unbound configuration", listeners: "DNS ports", apt_simulation: "Simulate package upgrade", timed_out: "Timed out", command_failed: "Command failed", no_answer: "No DNS answer", required_listener_missing: "Required DNS port missing", + }, +}; + +let language = "de"; let csrfToken = null; let pollTimer = null; let lastObservedJobId = null; let pendingStart = null; - +let lastJob = { state: "idle" }; +let backupEntries = []; +let busy = false; +let reachable = false; +let messageKey = ""; +let loginErrorKey = ""; +let backupError = false; const POLL_INTERVAL_MS = 2000; const PENDING_START_MAX_POLLS = 15; +const query = (selector) => document.querySelector(selector); +const translate = (key) => Object.hasOwn(dictionaries[language], key) ? dictionaries[language][key] : String(key ?? "—").replaceAll("_", " "); +const text = (selector, value) => { query(selector).textContent = value; }; -const loginPanel = document.querySelector("#login-panel"); -const dashboard = document.querySelector("#dashboard"); -const statusText = document.querySelector("#status"); -const resultText = document.querySelector("#job-result"); +function element(tag, value, className) { + const node = document.createElement(tag); + if (value !== undefined) node.textContent = value; + if (className) node.className = className; + return node; +} -function showMessage(message) { - statusText.textContent = message; +function showMessage(key) { + messageKey = key; + text("#message", key ? translate(key) : ""); } -async function request(path, options = {}) { - const headers = new Headers(options.headers || {}); - if (options.method && options.method !== "GET") { - headers.set("Origin", window.location.origin); - headers.set("X-CSRF-Token", csrfToken || ""); +function controls() { + const locked = busy || !reachable || !csrfToken; + query("#check-button").disabled = locked; + query("#backup-button").disabled = locked || !query("#backup-confirmed").checked; + query("#update-button").disabled = locked || query("#update-confirmation").value !== "UPDATE"; + query("#backup-confirmed").disabled = locked; + query("#update-confirmation").disabled = locked; + query("#refresh-button").disabled = busy; + for (const button of document.querySelectorAll(".delete-button")) { + button.disabled = locked || button.previousElementSibling.value !== button.dataset.backupId; } - return fetch(path, { ...options, headers, credentials: "same-origin" }); + text("#connection", translate(reachable ? "connected" : csrfToken ? "unreachable" : "disconnected")); } function stopPolling() { - if (pollTimer !== null) { - window.clearTimeout(pollTimer); - pollTimer = null; + if (pollTimer !== null) window.clearTimeout(pollTimer); + pollTimer = null; +} + +function signedOut(errorKey = "") { + stopPolling(); + csrfToken = null; + pendingStart = null; + lastObservedJobId = null; + lastJob = { state: "idle" }; + backupEntries = []; + busy = false; + reachable = false; + query("#dashboard").hidden = true; + query("#login-panel").hidden = false; + query("#logout-button").hidden = true; + query("#password").value = ""; + query("#backup-confirmed").checked = false; + query("#update-confirmation").value = ""; + loginErrorKey = errorKey; + text("#login-error", errorKey ? translate(errorKey) : ""); + renderJob(lastJob); + renderBackups(); + query("#password").focus(); +} + +async function request(path, options = {}) { + const headers = new Headers(options.headers || {}); + if (options.method && options.method !== "GET") { + headers.set("X-CSRF-Token", csrfToken || ""); + headers.set("Content-Type", "application/json"); } + const response = await fetch(path, { ...options, headers, credentials: "same-origin", cache: "no-store" }); + if (response.status === 401) signedOut("expired"); + return response; } function pollingDecision(job, pending) { const jobId = typeof job.job_id === "string" ? job.job_id : null; - const newRunnerJob = pending !== null && ( - job.state === "running" || (jobId !== null && jobId !== pending.baselineJobId) - ); + const newRunnerJob = pending !== null && (job.state === "running" || (jobId !== null && jobId !== pending.baselineJobId)); if (pending !== null && !newRunnerJob) { const nextPending = { ...pending, attempts: pending.attempts + 1 }; - if (nextPending.attempts <= PENDING_START_MAX_POLLS) { - return { poll: true, pending: nextPending, timedOut: false }; - } + if (nextPending.attempts <= PENDING_START_MAX_POLLS) return { poll: true, pending: nextPending, timedOut: false }; return { poll: false, pending: null, timedOut: true }; } return { poll: job.state === "running", pending: null, timedOut: false }; } +function bytes(value) { + return Number.isFinite(value) ? `${(value / 1073741824).toLocaleString(language, { maximumFractionDigits: 1 })} GiB` : translate("notAvailable"); +} + +function timestamp(value) { + if (!value) return "—"; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString(language); +} + +function renderJob(job) { + const state = pendingStart ? "starting" : job.state || "idle"; + text("#status", translate(state)); + query("#status").dataset.state = state; + text("#job-summary", translate(state === "idle" ? "idleHint" : state === "failed" ? "failedHint" : state === "succeeded" ? "successHint" : "runningHint")); + text("#last-action", job.action ? translate(job.action) : "—"); + text("#last-time", timestamp(job.finished_at || job.started_at)); + const steps = Array.isArray(job.steps) ? job.steps : []; + const required = steps.filter((step) => step.required); + text("#required-count", required.length ? `${required.filter((step) => step.ok).length} / ${required.length}` : "—"); + text("#job-result", JSON.stringify(job, null, 2)); + const system = job.result?.system || {}; + const memory = system.memory; + const disk = system.root_filesystem; + const values = [ + ["host", system.host], ["temperature", Number.isFinite(system.temperature_celsius) ? `${system.temperature_celsius.toLocaleString(language)} °C` : null], + ["uptime", Number.isFinite(system.uptime_seconds) ? `${Math.floor(system.uptime_seconds / 86400)} ${translate("days")} ${Math.floor(system.uptime_seconds / 3600) % 24} h` : null], + ["load", system.load_average?.map((value) => Number(value).toLocaleString(language, { maximumFractionDigits: 2 })).join(" / ")], + ["memory", Number.isFinite(memory?.total_bytes) && Number.isFinite(memory?.available_bytes) ? `${bytes(memory.total_bytes - memory.available_bytes)} / ${bytes(memory.total_bytes)}` : null], + ["disk", disk ? `${bytes(disk.used_bytes)} / ${bytes(disk.total_bytes)}` : null], + ["architecture", system.architecture], ["operatingSystem", system.operating_system], ["kernel", system.kernel], + ]; + query("#system-metrics").replaceChildren(...values.map(([key, value]) => { + const row = element("div"); + row.append(element("dt", translate(key)), element("dd", value ?? translate("notAvailable"))); + return row; + })); + const rows = steps.map((step) => { + const row = element("li"); + row.append(element("span", translate(step.name)), element("span", translate(step.ok ? "passed" : step.required ? "stepFailed" : "optionalWarning"), `step-state ${step.ok ? "passed" : step.required ? "error" : "warning"}`)); + return row; + }); + if (!rows.length) rows.push(element("li", translate(state === "running" || state === "starting" ? "runningHint" : "noSteps"), "muted")); + query("#step-list").replaceChildren(...rows); + if (job.result?.reboot_required) showMessage("reboot"); + controls(); +} + +function renderBackups() { + const rows = backupEntries.map((entry) => { + const row = element("article", undefined, "backup-row"); + const info = element("div"); + info.append(element("strong", `${timestamp(entry.created_at)} · ${bytes(entry.size)} · ${translate("verified")}`), element("code", entry.backup_id), element("code", `SHA-256: ${entry.sha256}`)); + const link = element("a", translate("download")); + link.href = `/api/maintenance/backups/${encodeURIComponent(entry.backup_id)}/download`; + info.append(link); + const form = element("form"); + const input = element("input"); + input.type = "text"; + input.autocomplete = "off"; + input.id = `delete-${entry.backup_id}`; + const label = element("label", translate("deleteLabel")); + label.htmlFor = input.id; + const button = element("button", translate("deleteBackup"), "delete-button danger"); + button.type = "submit"; + button.dataset.backupId = entry.backup_id; + input.addEventListener("input", controls); + form.append(label, input, button); + form.addEventListener("submit", async (event) => { + event.preventDefault(); + if (busy || !reachable || input.value !== entry.backup_id) return; + busy = true; + controls(); + try { + const response = await request(`/api/maintenance/backups/${encodeURIComponent(entry.backup_id)}`, { method: "DELETE", body: JSON.stringify({ confirmation: input.value }) }); + if (response.status === 401) return; + showMessage(response.ok ? "deleted" : response.status === 409 ? "busy" : "deleteFailed"); + await loadBackups(); + query("#backups-heading").setAttribute("tabindex", "-1"); + query("#backups-heading").focus(); + } catch { connectionLost(); } + finally { busy = false; controls(); } + }); + row.append(info, form); + return row; + }); + if (!rows.length) rows.push(element("p", translate(backupError ? "backupsFailed" : "noBackups"), "muted")); + query("#backup-list").replaceChildren(...rows); + controls(); +} + +async function loadBackups() { + try { + const response = await request("/api/maintenance/backups"); + if (response.status === 401) return; + backupError = !response.ok; + backupEntries = response.ok ? await response.json() : []; + } catch { backupError = true; backupEntries = []; } + renderBackups(); +} + +function connectionLost() { + stopPolling(); + pendingStart = null; + busy = false; + reachable = false; + showMessage("networkError"); + text("#status", translate("unreachable")); + query("#status").dataset.state = "failed"; + controls(); +} + async function refreshStatus() { - const response = await request("/api/maintenance/status"); - if (response.status === 401) { - stopPolling(); - pendingStart = null; - dashboard.hidden = true; - loginPanel.hidden = false; - csrfToken = null; - return; - } - if (!response.ok) { - showMessage("Status kann derzeit nicht gelesen werden."); - stopPolling(); - pendingStart = null; - return; - } - const job = await response.json(); - if (typeof job.job_id === "string") { - lastObservedJobId = job.job_id; - } - showMessage(`Auftrag: ${job.action} – ${job.state}`); - resultText.textContent = JSON.stringify(job, null, 2); - const decision = pollingDecision(job, pendingStart); - pendingStart = decision.pending; - if (decision.timedOut) { - showMessage("Der gestartete Wartungsauftrag wurde nicht rechtzeitig im Status sichtbar."); - } stopPolling(); - if (decision.poll) { - pollTimer = window.setTimeout(refreshStatus, POLL_INTERVAL_MS); - } else { - pendingStart = null; - } + try { + const response = await request("/api/maintenance/status"); + if (response.status === 401) return; + if (!response.ok) { connectionLost(); return; } + lastJob = await response.json(); + if (!csrfToken) return; + reachable = true; + if (typeof lastJob.job_id === "string") lastObservedJobId = lastJob.job_id; + const decision = pollingDecision(lastJob, pendingStart); + pendingStart = decision.pending; + busy = decision.poll; + renderJob(lastJob); + if (decision.timedOut) { reachable = false; showMessage("startTimeout"); controls(); } + if (decision.poll) pollTimer = window.setTimeout(refreshStatus, POLL_INTERVAL_MS); + else await loadBackups(); + } catch { connectionLost(); } } async function startAction(action) { - const response = await request(`/api/maintenance/${action}`, { method: "POST" }); - if (!response.ok) { - showMessage("Wartungsauftrag konnte nicht gestartet werden."); - return; - } - showMessage("Wartungsauftrag wurde gestartet."); - stopPolling(); + if (busy || !reachable) return; + if (action === "backup" && !query("#backup-confirmed").checked) return; + if (action === "update" && query("#update-confirmation").value !== "UPDATE") return; + busy = true; + controls(); + showMessage(""); pendingStart = { baselineJobId: lastObservedJobId, attempts: 0 }; + renderJob(lastJob); + try { + const body = action === "backup" ? { confirmed: true } : action === "update" ? { confirmation: "UPDATE" } : {}; + const response = await request(`/api/maintenance/${action}`, { method: "POST", body: JSON.stringify(body) }); + if (response.status === 401) return; + if (!response.ok) { + pendingStart = null; + busy = false; + showMessage(response.status === 409 ? "busy" : response.status === 403 ? "forbidden" : "startFailed"); + await refreshStatus(); + return; + } + query("#backup-confirmed").checked = false; + query("#update-confirmation").value = ""; + await refreshStatus(); + } catch { connectionLost(); } +} + +function changeLanguage(value) { + language = value === "en" ? "en" : "de"; + document.documentElement.lang = language; + document.title = `Pi-hole · ${translate("flightplan")}`; + query("#language").value = language; + for (const node of document.querySelectorAll("[data-i18n]")) node.textContent = translate(node.dataset.i18n); + text("#login-error", loginErrorKey ? translate(loginErrorKey) : ""); + showMessage(messageKey); + renderJob(lastJob); + renderBackups(); +} + +async function signedIn(payload) { + csrfToken = payload.csrf_token; + if (typeof payload.pihole_url === "string" && /^https?:\/\//.test(payload.pihole_url)) query("#pihole-link").href = payload.pihole_url; + query("#login-panel").hidden = true; + query("#dashboard").hidden = false; + query("#logout-button").hidden = false; + loginErrorKey = ""; + showMessage(""); + query("#dashboard-heading").focus(); await refreshStatus(); } -document.querySelector("#login-form").addEventListener("submit", async (event) => { - event.preventDefault(); - const password = document.querySelector("#password").value; - const response = await fetch("/api/session", { - method: "POST", - headers: { "Content-Type": "application/json" }, - credentials: "same-origin", - body: JSON.stringify({ password }), +function initialize() { + let stored; + try { stored = localStorage.getItem("maintenance-language"); } catch { stored = null; } + changeLanguage(stored === "de" || stored === "en" ? stored : navigator.language.startsWith("de") ? "de" : "en"); + query("#pihole-link").href = `http://${window.location.hostname}/admin/`; + query("#language").addEventListener("change", (event) => { + changeLanguage(event.target.value); + try { localStorage.setItem("maintenance-language", language); } catch {} }); - document.querySelector("#password").value = ""; - if (!response.ok) { - showMessage("Anmeldung fehlgeschlagen."); - return; - } - csrfToken = (await response.json()).csrf_token; - loginPanel.hidden = true; - dashboard.hidden = false; - await refreshStatus(); -}); + query("#login-form").addEventListener("submit", async (event) => { + event.preventDefault(); + const password = query("#password").value; + query("#password").value = ""; + query("#login-button").disabled = true; + try { + const response = await fetch("/api/session", { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "same-origin", body: JSON.stringify({ password }) }); + if (!response.ok) { + loginErrorKey = response.status === 429 ? "rateLimited" : "loginFailed"; + text("#login-error", translate(loginErrorKey)); + query("#password").focus(); + } else await signedIn(await response.json()); + } catch { loginErrorKey = "networkError"; text("#login-error", translate(loginErrorKey)); } + finally { query("#login-button").disabled = false; } + }); + for (const action of ["check", "backup", "update"]) query(`#${action}-button`).addEventListener("click", () => startAction(action)); + query("#backup-confirmed").addEventListener("change", controls); + query("#update-confirmation").addEventListener("input", controls); + query("#refresh-button").addEventListener("click", () => { showMessage(""); refreshStatus(); }); + query("#copy-address").addEventListener("click", async () => { + try { await navigator.clipboard.writeText(window.location.origin + "/"); showMessage("copied"); } + catch { showMessage("copyFailed"); } + }); + query("#logout-button").addEventListener("click", async () => { + try { + const response = await request("/api/session/logout", { method: "POST" }); + if (response.ok || response.status === 401) signedOut(); + else showMessage("forbidden"); + } catch { connectionLost(); } + }); + fetch("/api/session", { credentials: "same-origin", cache: "no-store" }) + .then(async (response) => { if (response.ok) await signedIn(await response.json()); }) + .catch(() => { loginErrorKey = "networkError"; text("#login-error", translate(loginErrorKey)); }); +} -document.querySelector("#check-button").addEventListener("click", () => startAction("check")); -document.querySelector("#backup-button").addEventListener("click", () => { - if (!document.querySelector("#backup-confirmed").checked) { - showMessage("Bitte das Backup sichtbar bestätigen."); - return; - } - startAction("backup"); -}); -document.querySelector("#update-button").addEventListener("click", () => { - if (document.querySelector("#update-confirmation").value !== "UPDATE") { - showMessage("Bitte UPDATE exakt eingeben."); - return; - } - startAction("update"); -}); -document.querySelector("#logout-button").addEventListener("click", async () => { - await request("/api/session/logout", { method: "POST" }); - stopPolling(); - csrfToken = null; - pendingStart = null; - dashboard.hidden = true; - loginPanel.hidden = false; -}); +document.addEventListener("DOMContentLoaded", initialize); From 3ce409dc8a85e1ca8980002549aa2cfda2871325 Mon Sep 17 00:00:00 2001 From: Tim Baumann Date: Wed, 28 Oct 2026 00:10:37 +0100 Subject: [PATCH 19/24] Guard maintenance upgrades and support Python 3.10 --- docs/MAINTENANCE_WEB.md | 13 ++++++ maintenance_backups.py | 15 +++++-- maintenance_install.py | 3 +- maintenance_web.py | 4 +- scripts/install_maintenance_web.sh | 65 ++++++++++++++++++++++----- scripts/rollback_maintenance_web.sh | 33 ++++++++++++-- tests/test_install_maintenance_web.sh | 51 +++++++++++++++++++++ tests/test_maintenance_backups.py | 6 +++ 8 files changed, 171 insertions(+), 19 deletions(-) diff --git a/docs/MAINTENANCE_WEB.md b/docs/MAINTENANCE_WEB.md index 61a99d8..e5bc018 100644 --- a/docs/MAINTENANCE_WEB.md +++ b/docs/MAINTENANCE_WEB.md @@ -110,6 +110,19 @@ dig +short @127.0.0.1 -p 5335 example.com Der Gesamtinstaller verweigert einen Lauf bei erkannter bestehender Installation, weil sein Rollback DNS, Resolver, Dienste und Python-Environment nicht vollständig abdeckt. Ein Upgrade erfordert eine separat geprüfte Migration und Sicherung. +Für eine bereits laufende Suite steht der eingeschränkte Web-Upgradepfad bereit: + +```bash +sudo ./scripts/install_maintenance_web.sh --upgrade-only +``` + +Er setzt laufendes Pi-hole, Unbound, Caddy und die Suite sowie aktivierte Webdienste +voraus, prüft DNS, das vorhandene root-eigene Python-Environment und erstellt +unter Wartungssperre ein validiertes Deployment-Backup. Er ändert weder DNS- noch +Resolverkonfiguration oder das Python-Environment. Caddy wird neu geladen und die +Suite neu gestartet. Der Pfad ersetzt kein vollständiges Systembackup und muss vor +einer produktiven Nutzung auf dem Zielsystem überprüft werden. + Der Web-Installer erstellt ein root-eigenes Deployment-Backup unter `/var/backups/pihole-suite-deploy/`. Es deckt die im Manifest aufgeführten Web-Dateien ab, ersetzt jedoch kein vollständiges Systembackup. Für einen Rollback zuerst den diff --git a/maintenance_backups.py b/maintenance_backups.py index 85dcdfb..5d94e71 100755 --- a/maintenance_backups.py +++ b/maintenance_backups.py @@ -17,6 +17,15 @@ EXPORT_ROOT = Path('/var/lib/pihole-suite/exports') BACKUP_ID = re.compile(r'\d{8}T\d{6}Z-\d{8}T\d{12}Z-[a-f0-9]{32}') +def parse_timestamp(value: str): + return dt.datetime.fromisoformat(value[:-1] + '+00:00' if value.endswith('Z') else value) + +def stream_sha256(handle) -> str: + digest = hashlib.sha256() + while chunk := handle.read(65536): + digest.update(chunk) + return digest.hexdigest() + def valid_id(value: str) -> bool: return isinstance(value, str) and BACKUP_ID.fullmatch(value) is not None @@ -55,7 +64,7 @@ def read_index(root: Path) -> list[dict]: raise ValueError('Invalid index') if not isinstance(entry['created_at'], str) or len(entry['created_at']) > 40: raise ValueError('Invalid index') - dt.datetime.fromisoformat(entry['created_at']) + parse_timestamp(entry['created_at']) return entries @@ -73,7 +82,7 @@ def _verified_backup(context, backup_id: str) -> Path: manifest = json.loads((candidate / 'manifest.json').read_text()) if backup_id.split('-', 1)[1] != manifest['job_id']: raise ValueError('Backup ID mismatch') - dt.datetime.fromisoformat(manifest['created_at']) + parse_timestamp(manifest['created_at']) except RuntimeError as error: raise ValueError('Invalid backup') from error return candidate @@ -139,7 +148,7 @@ def refresh_exports(context) -> None: if member.name == 'manifest.json': if source.read() != (backup / 'manifest.json').read_bytes(): raise ValueError('Export manifest mismatch') - elif member.name not in expected or member.size != expected[member.name]['size'] or hashlib.file_digest(source, 'sha256').hexdigest() != expected[member.name]['sha256']: + elif member.name not in expected or member.size != expected[member.name]['size'] or stream_sha256(source) != expected[member.name]['sha256']: raise ValueError('Export verification failed') if covered != set(expected) | {'manifest.json'}: raise ValueError('Incomplete export') diff --git a/maintenance_install.py b/maintenance_install.py index a84b1ad..a06adcd 100644 --- a/maintenance_install.py +++ b/maintenance_install.py @@ -49,6 +49,7 @@ def main(): parser.add_argument('--hosts', default='') parser.add_argument('--lan-cidr', default='') parser.add_argument('--lock-fd', type=int) + parser.add_argument('--skip-export-refresh', action='store_true') args = parser.parse_args() if not args.destdir and os.geteuid() != 0: raise ValueError('Root required') @@ -63,7 +64,7 @@ def main(): config = configuration(hosts, cidr) atomic_file(config_path, json.dumps(config) + '\n') atomic_file(root / 'etc/caddy/Caddyfile.d/pihole-maintenance.caddy', caddy_config(config)) - if not args.destdir: + if not args.destdir and not args.skip_export_refresh: from maintenance_backups import refresh_exports from maintenance_runner import RunnerContext context = RunnerContext() diff --git a/maintenance_web.py b/maintenance_web.py index 1cda777..60cf3de 100644 --- a/maintenance_web.py +++ b/maintenance_web.py @@ -25,7 +25,7 @@ ) from starlette.concurrency import run_in_threadpool -from maintenance_backups import EXPORT_ROOT, read_index, safe_file, valid_id +from maintenance_backups import EXPORT_ROOT, read_index, safe_file, stream_sha256, valid_id SESSION_IDLE_SECONDS = 30 * 60 SESSION_MAX_SECONDS = 8 * 60 * 60 @@ -421,7 +421,7 @@ def download_backup(backup_id: str, _: tuple[str, Session] = Depends(require_ses raise HTTPException(status_code=404, detail='Backup not found') from None except (OSError, ValueError): raise HTTPException(status_code=503, detail='Backup unavailable') from None - if os.fstat(handle.fileno()).st_size != entry['size'] or hashlib.file_digest(handle, 'sha256').hexdigest() != entry['sha256']: + if os.fstat(handle.fileno()).st_size != entry['size'] or stream_sha256(handle) != entry['sha256']: handle.close() raise HTTPException(status_code=503, detail='Backup verification failed') handle.seek(0) diff --git a/scripts/install_maintenance_web.sh b/scripts/install_maintenance_web.sh index fb30a00..68a0d31 100755 --- a/scripts/install_maintenance_web.sh +++ b/scripts/install_maintenance_web.sh @@ -9,6 +9,7 @@ BACKUP_DIR="" WEB_HOSTS="" LAN_CIDR="" LOCK_HELD=false +UPGRADE_ONLY=false readonly DEPLOY_BACKUP_ROOT="/var/backups/pihole-suite-deploy" readonly APP_DIR="/var/lib/pihole-suite/app" @@ -48,7 +49,7 @@ readonly -a TARGETS=( ) usage() { - printf '%s\n' "Usage: $0 [--destdir ABSOLUTE_PATH] [--backup-only | --backup-dir ABSOLUTE_PATH] [--hosts HOST,IP] [--lan-cidr CIDR]" + printf '%s\n' "Usage: $0 [--upgrade-only] [--destdir ABSOLUTE_PATH] [--backup-only | --backup-dir ABSOLUTE_PATH] [--hosts HOST,IP] [--lan-cidr CIDR]" } die() { @@ -68,6 +69,10 @@ parse_args() { BACKUP_ONLY=true shift ;; + --upgrade-only) + UPGRADE_ONLY=true + shift + ;; --hosts|--lan-cidr) [[ $# -ge 2 ]] || die "$1 requires a value" case "$1" in @@ -96,6 +101,7 @@ parse_args() { [[ "$BACKUP_ONLY" == false || -z "$DESTDIR" ]] || die "--backup-only cannot use --destdir" [[ -z "$BACKUP_DIR" || ( "$BACKUP_DIR" = /* && "$BACKUP_DIR" != "/" ) ]] || die "--backup-dir must be a non-root absolute path" [[ "$BACKUP_ONLY" == false || -z "$BACKUP_DIR" ]] || die "choose either --backup-only or --backup-dir" + [[ "$UPGRADE_ONLY" == false || ( -z "$BACKUP_DIR" && "$BACKUP_ONLY" == false ) ]] || die "upgrade-only requires a fresh locked snapshot" } destination() { @@ -271,6 +277,34 @@ validate_existing_backup_dir() { backup_real="$(realpath -e -- "$BACKUP_DIR")" [[ "$(dirname -- "$backup_real")" == "$root_real" ]] || die "backup directory must be directly below $DEPLOY_BACKUP_ROOT" [[ -f "$backup_real/manifest.tsv" && ! -L "$backup_real/manifest.tsv" ]] || die "backup directory has no safe manifest" + bash "$SOURCE_DIR/scripts/rollback_maintenance_web.sh" --validate-only --backup-dir "$backup_real" || die "deployment backup validation failed" +} + +validate_runtime_dependencies() { + sudo -n -u pihole-suite -- "$1" -I -c 'import fastapi, uvicorn, httpx' +} + +verify_upgrade_preconditions() { + local path unit python_real unbound_port + for unit in caddy pihole-suite pihole-FTL unbound; do + systemctl is-active --quiet "$unit" || die "upgrade requires active service: $unit" + done + for unit in caddy pihole-suite; do + systemctl is-enabled --quiet "$unit" || die "upgrade requires enabled service: $unit" + done + for path in "$APP_DIR" "$APP_DIR/web" /var/lib/pihole-suite/venv; do + assert_safe_parent_chain "$path/.guard" + [[ -d "$path" && ! -L "$path" ]] || die "missing safe runtime directory: $path" + [[ "$(stat -c %u "$path")" == 0 ]] || die "runtime directory is not root-owned: $path" + [[ -z "$(find -P "$path" \( ! -user root -o -perm /022 \) ! -type l -print -quit)" ]] || die "untrusted runtime content: $path" + done + python_real="$(realpath -e /var/lib/pihole-suite/venv/bin/python)" || die "missing Python interpreter" + [[ "$python_real" == /usr/bin/python3* && -f "$python_real" ]] || die "untrusted Python interpreter" + [[ "$(stat -c %u "$python_real")" == 0 && -z "$(find -P "$python_real" -perm /022 -print -quit)" ]] || die "unsafe Python interpreter permissions" + validate_runtime_dependencies /var/lib/pihole-suite/venv/bin/python || die "existing Python runtime lacks required dependencies" + /usr/bin/dig +time=3 +tries=1 +short @127.0.0.1 example.com A | /usr/bin/grep -Eq '^[0-9.]+$' || die "Pi-hole DNS baseline failed" + unbound_port="$(bash -c 'source "$1"; rollback_unbound_port "$2"' _ "$SOURCE_DIR/scripts/rollback_maintenance_web.sh" /etc/pihole-suite/pihole-suite.env)" || die "invalid Unbound port configuration" + /usr/bin/dig +time=3 +tries=1 +short @127.0.0.1 -p "$unbound_port" example.com A | /usr/bin/grep -Eq '^[0-9.]+$' || die "Unbound DNS baseline failed" } validate_live_installation() { @@ -286,7 +320,9 @@ validate_live_installation() { activate_live_installation() { local attempt listeners /usr/bin/systemctl daemon-reload - /usr/bin/systemctl enable --now caddy + if [[ "$UPGRADE_ONLY" == false ]]; then + /usr/bin/systemctl enable --now caddy + fi /usr/bin/systemctl reload caddy /usr/bin/systemctl restart pihole-suite.service /usr/bin/systemctl is-active --quiet pihole-suite.service @@ -310,24 +346,30 @@ install_live() { for command in /usr/bin/python3 /usr/sbin/visudo /usr/bin/systemd-analyze /usr/bin/systemctl /usr/bin/curl /usr/bin/ss /usr/bin/grep; do [[ -x "$command" ]] || die "required command unavailable: $command" done - prepare_runtime_directories - if [[ -n "$BACKUP_DIR" ]]; then - validate_existing_backup_dir + if [[ "$UPGRADE_ONLY" == true ]]; then + verify_upgrade_preconditions else - BACKUP_DIR="$(create_deployment_backup)" + [[ ! -e "$APP_DIR/start_suite.py" ]] || die "existing suite requires --upgrade-only" + prepare_runtime_directories fi - printf 'Deployment snapshot: %s\n' "$BACKUP_DIR" /usr/bin/python3 -c 'import os, stat; fd = os.open("/run/lock/pihole-maintenance-web.lock", os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600); metadata = os.fstat(fd); assert metadata.st_uid == 0 and stat.S_ISREG(metadata.st_mode); os.close(fd)' exec 9<>/run/lock/pihole-maintenance-web.lock /usr/bin/flock --nonblock 9 || die "maintenance is active; retry after completion" LOCK_HELD=true - trap rollback_failed_install EXIT - /usr/bin/systemctl stop pihole-suite.service for action in check backup update; do if /usr/bin/systemctl is-active --quiet "pihole-maintenance-$action.service"; then die "maintenance is active; retry after completion" fi done + if [[ -n "$BACKUP_DIR" ]]; then + validate_existing_backup_dir + else + BACKUP_DIR="$(create_deployment_backup)" + validate_existing_backup_dir + fi + printf 'Deployment snapshot: %s\n' "$BACKUP_DIR" + trap rollback_failed_install EXIT + /usr/bin/systemctl stop pihole-suite.service ensure_caddy_import local target for target in "${TARGETS[@]}"; do @@ -364,6 +406,7 @@ install_staged() { provision() { local -a args=(--destdir "$DESTDIR" --hosts "$WEB_HOSTS" --lan-cidr "$LAN_CIDR") [[ "$LOCK_HELD" == false ]] || args+=(--lock-fd 9) + [[ "$UPGRADE_ONLY" == false ]] || args+=(--skip-export-refresh) /usr/bin/python3 "$SOURCE_DIR/maintenance_install.py" "${args[@]}" "$@" } @@ -382,4 +425,6 @@ main() { install_live } -main "$@" +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + main "$@" +fi diff --git a/scripts/rollback_maintenance_web.sh b/scripts/rollback_maintenance_web.sh index ab8351a..dcfaf27 100755 --- a/scripts/rollback_maintenance_web.sh +++ b/scripts/rollback_maintenance_web.sh @@ -107,7 +107,9 @@ restore_manifest() { present) assert_safe_parent_chain "$target" [[ ! -L "$target" ]] || die "refusing to replace symlink target" - install -d -o root -g root -m 0755 "$(dirname -- "$target")" + if [[ ! -d "$(dirname -- "$target")" ]]; then + install -d -o root -g root -m 0755 "$(dirname -- "$target")" + fi install -o "$uid" -g "$gid" -m "$mode" -- "$backup_dir/$saved" "$target" ;; absent) @@ -135,8 +137,26 @@ restore_app_tree() { fi } +rollback_unbound_port() { + local file="$1" value="" count=0 line + if [[ -f "$file" ]]; then + while IFS= read -r line; do + [[ "$line" == UNBOUND_PORT=* ]] || continue + value="${line#UNBOUND_PORT=}" + value="${value#\"}" + value="${value%\"}" + count=$((count + 1)) + done < "$file" + fi + [[ "$count" -le 1 ]] || die "ambiguous UNBOUND_PORT" + [[ "$count" != 0 ]] || value=5335 + [[ "$value" =~ ^[0-9]{1,5}$ ]] || die "invalid UNBOUND_PORT" + (( 10#$value >= 1 && 10#$value <= 65535 )) || die "invalid UNBOUND_PORT" + printf '%s\n' "$value" +} + validate_and_activate() { - local unit artifact + local unit artifact unbound_port local -a python_artifacts=() for artifact in /usr/local/libexec/pihole-maintenance-runner /var/lib/pihole-suite/app/start_suite.py /var/lib/pihole-suite/app/maintenance_web.py; do [[ -f "$artifact" ]] && python_artifacts+=("$artifact") @@ -156,10 +176,17 @@ validate_and_activate() { /usr/bin/systemctl is-active --quiet pihole-FTL.service /usr/bin/systemctl is-active --quiet unbound.service /usr/bin/dig +time=3 +tries=1 +short @127.0.0.1 example.com A | /usr/bin/grep -Eq '^[0-9.]+' - /usr/bin/dig +time=3 +tries=1 +short @127.0.0.1 -p 5335 example.com A | /usr/bin/grep -Eq '^[0-9.]+' + unbound_port="$(rollback_unbound_port /etc/pihole-suite/pihole-suite.env)" + /usr/bin/dig +time=3 +tries=1 +short @127.0.0.1 -p "$unbound_port" example.com A | /usr/bin/grep -Eq '^[0-9.]+' } main() { + if [[ $# -eq 3 && "$1" == '--validate-only' && "$2" == '--backup-dir' ]]; then + local validated_backup + validated_backup="$(validate_backup_dir "$3")" + validate_manifest "$validated_backup" + return + fi [[ $# -eq 2 && "$1" == '--backup-dir' ]] || die "usage: $0 --backup-dir /var/backups/pihole-suite-deploy/NAME" local backup_dir backup_dir="$(validate_backup_dir "$2")" diff --git a/tests/test_install_maintenance_web.sh b/tests/test_install_maintenance_web.sh index e95cd97..bb76eba 100755 --- a/tests/test_install_maintenance_web.sh +++ b/tests/test_install_maintenance_web.sh @@ -21,6 +21,47 @@ run_staged_install() { } run_staged_install +VENV_TEST_ROOT="$VENV_TEST_ROOT" ROOT_DIR="$ROOT_DIR" bash -c ' + source "$ROOT_DIR/scripts/install_maintenance_web.sh" + sudo() { + test "$1" = -n && test "$2" = -u && test "$3" = pihole-suite && test "$4" = -- + printf "%s\n" "$5" > "$VENV_TEST_ROOT/runtime-checked" + } + validate_runtime_dependencies /tmp/runtime-python + grep -Fqx /tmp/runtime-python "$VENV_TEST_ROOT/runtime-checked" +' +VENV_TEST_ROOT="$VENV_TEST_ROOT" ROOT_DIR="$ROOT_DIR" bash -c ' + source "$ROOT_DIR/scripts/rollback_maintenance_web.sh" + printf "UNBOUND_PORT=5353\n" > "$VENV_TEST_ROOT/env-port" + test "$(rollback_unbound_port "$VENV_TEST_ROOT/env-port")" = 5353 + printf "UNBOUND_PORT=bad\n" > "$VENV_TEST_ROOT/env-port" + if (rollback_unbound_port "$VENV_TEST_ROOT/env-port") >/dev/null 2>&1; then exit 1; fi +' +grep -Fq -- '--validate-only' "$ROOT_DIR/scripts/rollback_maintenance_web.sh" +grep -Fq 'verify_upgrade_preconditions' "$ROOT_DIR/scripts/install_maintenance_web.sh" +VENV_TEST_ROOT="$VENV_TEST_ROOT" ROOT_DIR="$ROOT_DIR" bash -c ' + source "$ROOT_DIR/scripts/rollback_maintenance_web.sh" + backup="$VENV_TEST_ROOT/manifest-check" + mkdir -p "$backup/files" + printf original > "$backup/files/1" + digest="$(sha256sum "$backup/files/1" | cut -d " " -f 1)" + printf "version|2\n" > "$backup/manifest.tsv" + first=true + for target in "${TARGETS[@]}"; do + if "$first"; then + printf "%s|present|files/1|644|0|0|%s\n" "$target" "$digest" >> "$backup/manifest.tsv" + first=false + else + printf "%s|absent|-|-|-|-|-\n" "$target" >> "$backup/manifest.tsv" + fi + done + validate_manifest "$backup" + printf changed > "$backup/files/1" + if ( validate_manifest "$backup" ) >/dev/null 2>&1; then + printf "tampered snapshot was accepted\n" >&2 + exit 1 + fi +' VENV_TEST_ROOT="$VENV_TEST_ROOT" ROOT_DIR="$ROOT_DIR" bash -c ' source "$ROOT_DIR/install.sh" systemctl() { return 1; } @@ -31,6 +72,16 @@ VENV_TEST_ROOT="$VENV_TEST_ROOT" ROOT_DIR="$ROOT_DIR" bash -c ' exit 1 fi ' +VENV_TEST_ROOT="$VENV_TEST_ROOT" ROOT_DIR="$ROOT_DIR" bash -c ' + source "$ROOT_DIR/scripts/rollback_maintenance_web.sh" + backup="$VENV_TEST_ROOT/restore-mode" + mkdir -p "$backup/files" "$VENV_TEST_ROOT/private-dir" + chmod 0700 "$VENV_TEST_ROOT/private-dir" + printf original > "$backup/files/1" + printf "version|2\n%s|present|files/1|644|%s|%s|-\n" "$VENV_TEST_ROOT/private-dir/restored" "$(id -u)" "$(id -g)" > "$backup/manifest.tsv" + restore_manifest "$backup" + test "$(stat -c %a "$VENV_TEST_ROOT/private-dir")" = 700 +' VENV_TEST_ROOT="$VENV_TEST_ROOT" ROOT_DIR="$ROOT_DIR" bash -c ' source "$ROOT_DIR/scripts/rollback_maintenance_web.sh" backup="$VENV_TEST_ROOT/app-rollback" diff --git a/tests/test_maintenance_backups.py b/tests/test_maintenance_backups.py index f082780..f4dc8c1 100644 --- a/tests/test_maintenance_backups.py +++ b/tests/test_maintenance_backups.py @@ -8,6 +8,12 @@ from maintenance_backups import delete_backup, read_index, refresh_exports from maintenance_runner import run_job +def test_utc_timestamp_and_stream_hash_work_without_newer_python_apis(): + import io + from maintenance_backups import parse_timestamp, stream_sha256 + assert parse_timestamp('2026-09-07T12:00:00Z').utcoffset().total_seconds() == 0 + assert stream_sha256(io.BytesIO(b'abc')) == hashlib.sha256(b'abc').hexdigest() + def test_export_index_and_confirmed_deletion(tmp_path): context, _ = make_context(tmp_path) From c544ee51f59db984c491129fc53325ad0c94cbc5 Mon Sep 17 00:00:00 2001 From: Tim Baumann Date: Wed, 28 Oct 2026 00:14:37 +0100 Subject: [PATCH 20/24] Constrain installer paths and remove dynamic test execution --- maintenance_install.py | 27 +++++++++++++++++++++++++-- tests/test_dashboard_dom.js | 12 +++++------- tests/test_maintenance_config.py | 9 +++++++++ 3 files changed, 39 insertions(+), 9 deletions(-) diff --git a/maintenance_install.py b/maintenance_install.py index a06adcd..02c123b 100644 --- a/maintenance_install.py +++ b/maintenance_install.py @@ -12,6 +12,25 @@ from maintenance_config import caddy_config, configuration +INSTALL_TARGETS = frozenset({ + 'etc/pihole-suite/maintenance-web.json', + 'etc/caddy/Caddyfile.d/pihole-maintenance.caddy', +}) + +def installation_target(root: Path, name: str) -> Path: + if name not in INSTALL_TARGETS or not root.is_absolute(): + raise ValueError('Invalid installation target') + canonical_root = root.resolve(strict=True) + if not canonical_root.is_dir() or root != canonical_root: + raise ValueError('Unsafe installation root') + path = canonical_root / name + canonical_path = path.resolve(strict=False) + if os.path.commonpath((str(canonical_root), str(canonical_path))) != str(canonical_root) or canonical_path != path: + raise ValueError('Unsafe installation path') + if path.is_symlink() or any(parent.is_symlink() for parent in path.parents): + raise ValueError('Unsafe installation path') + return path + def atomic_file(path: Path, value: str) -> None: if path.is_symlink() or any(parent.is_symlink() for parent in path.parents): @@ -51,10 +70,14 @@ def main(): parser.add_argument('--lock-fd', type=int) parser.add_argument('--skip-export-refresh', action='store_true') args = parser.parse_args() + if args.destdir and os.geteuid() == 0: + raise ValueError('Staged installation must not run as root') if not args.destdir and os.geteuid() != 0: raise ValueError('Root required') root = Path(args.destdir or '/') - config_path = root / 'etc/pihole-suite/maintenance-web.json' + if args.destdir and root == Path('/'): + raise ValueError('Staging root must not be /') + config_path = installation_target(root, 'etc/pihole-suite/maintenance-web.json') if not args.hosts and not args.lan_cidr and config_path.exists(): raw = json.loads(config_path.read_text()) hosts, cidr = ','.join(raw['hosts']), raw['lan_cidr'] @@ -63,7 +86,7 @@ def main(): hosts, cidr = args.hosts or detected_hosts, args.lan_cidr or detected_cidr config = configuration(hosts, cidr) atomic_file(config_path, json.dumps(config) + '\n') - atomic_file(root / 'etc/caddy/Caddyfile.d/pihole-maintenance.caddy', caddy_config(config)) + atomic_file(installation_target(root, 'etc/caddy/Caddyfile.d/pihole-maintenance.caddy'), caddy_config(config)) if not args.destdir and not args.skip_export_refresh: from maintenance_backups import refresh_exports from maintenance_runner import RunnerContext diff --git a/tests/test_dashboard_dom.js b/tests/test_dashboard_dom.js index 7c54e41..c81518d 100644 --- a/tests/test_dashboard_dom.js +++ b/tests/test_dashboard_dom.js @@ -1,5 +1,3 @@ -const fs = require('fs'); -const vm = require('vm'); const assert = require('assert'); const nodes = new Map(); function element(id) { @@ -18,11 +16,11 @@ const document = { getElementById(id) { return id.includes(' ') ? null : element(id); }, querySelector(selector) { return element(selector.slice(1)); } }; -vm.runInNewContext(fs.readFileSync('dashboard/app.js', 'utf8'), { - document, sessionStorage: {getItem: () => 'key'}, - fetch: async url => ({ok: true, json: async () => data[url]}), - setTimeout() {}, console, Date -}); +global.document = document; +global.sessionStorage = {getItem: () => 'key'}; +global.fetch = async url => ({ok: true, json: async () => data[url]}); +global.setTimeout = () => {}; +require('../dashboard/app.js'); setImmediate(() => { assert.strictEqual(element('overall-status .status-indicator').className, 'status-indicator healthy'); assert.strictEqual(element('overall-status .status-text').textContent, 'Alle Kernsysteme gesund'); diff --git a/tests/test_maintenance_config.py b/tests/test_maintenance_config.py index 230ff6e..146719c 100644 --- a/tests/test_maintenance_config.py +++ b/tests/test_maintenance_config.py @@ -1,4 +1,13 @@ import pytest +from maintenance_install import installation_target + +def test_installation_target_rejects_traversal_and_symlinks(tmp_path): + assert installation_target(tmp_path, 'etc/pihole-suite/maintenance-web.json') == tmp_path / 'etc/pihole-suite/maintenance-web.json' + with pytest.raises(ValueError): + installation_target(tmp_path, '../outside') + (tmp_path / 'etc').symlink_to(tmp_path.parent, target_is_directory=True) + with pytest.raises(ValueError): + installation_target(tmp_path, 'etc/pihole-suite/maintenance-web.json') from maintenance_config import caddy_config, configuration From 3353653ef503aced157f5f22e9fdf6538cac0563 Mon Sep 17 00:00:00 2001 From: Tim Baumann Date: Wed, 28 Oct 2026 00:18:09 +0100 Subject: [PATCH 21/24] Test isolated upgrades and constrain provisioning paths --- .github/workflows/test.yml | 1 + maintenance_install.py | 45 ++++++--------- scripts/install_maintenance_web.sh | 23 +++++++- tests/test_maintenance_config.py | 18 +++--- tests/test_upgrade_container.sh | 89 ++++++++++++++++++++++++++++++ 5 files changed, 137 insertions(+), 39 deletions(-) create mode 100644 tests/test_upgrade_container.sh diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 49f8e16..cdf31b1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -29,3 +29,4 @@ jobs: PYTHONPATH=. pytest tests/ bash tests/test_install_maintenance_web.sh node tests/test_dashboard_dom.js + bash tests/test_upgrade_container.sh diff --git a/maintenance_install.py b/maintenance_install.py index 02c123b..57ba6e7 100644 --- a/maintenance_install.py +++ b/maintenance_install.py @@ -12,25 +12,6 @@ from maintenance_config import caddy_config, configuration -INSTALL_TARGETS = frozenset({ - 'etc/pihole-suite/maintenance-web.json', - 'etc/caddy/Caddyfile.d/pihole-maintenance.caddy', -}) - -def installation_target(root: Path, name: str) -> Path: - if name not in INSTALL_TARGETS or not root.is_absolute(): - raise ValueError('Invalid installation target') - canonical_root = root.resolve(strict=True) - if not canonical_root.is_dir() or root != canonical_root: - raise ValueError('Unsafe installation root') - path = canonical_root / name - canonical_path = path.resolve(strict=False) - if os.path.commonpath((str(canonical_root), str(canonical_path))) != str(canonical_root) or canonical_path != path: - raise ValueError('Unsafe installation path') - if path.is_symlink() or any(parent.is_symlink() for parent in path.parents): - raise ValueError('Unsafe installation path') - return path - def atomic_file(path: Path, value: str) -> None: if path.is_symlink() or any(parent.is_symlink() for parent in path.parents): @@ -64,30 +45,36 @@ def detect_network() -> tuple[str, str]: def main(): parser = argparse.ArgumentParser() - parser.add_argument('--destdir', default='') + parser.add_argument('--print-config', action='store_true') + parser.add_argument('--print-caddy', action='store_true') parser.add_argument('--hosts', default='') parser.add_argument('--lan-cidr', default='') parser.add_argument('--lock-fd', type=int) parser.add_argument('--skip-export-refresh', action='store_true') args = parser.parse_args() - if args.destdir and os.geteuid() == 0: - raise ValueError('Staged installation must not run as root') - if not args.destdir and os.geteuid() != 0: + print_only = args.print_config or args.print_caddy + if args.print_config and args.print_caddy: + raise ValueError('Choose one output') + if not print_only and os.geteuid() != 0: raise ValueError('Root required') - root = Path(args.destdir or '/') - if args.destdir and root == Path('/'): - raise ValueError('Staging root must not be /') - config_path = installation_target(root, 'etc/pihole-suite/maintenance-web.json') + config_path = Path('/etc/pihole-suite/maintenance-web.json') + if print_only and not (args.hosts and args.lan_cidr): + raise ValueError('Staging requires explicit hosts and LAN CIDR') if not args.hosts and not args.lan_cidr and config_path.exists(): + if config_path.is_symlink() or any(parent.is_symlink() for parent in config_path.parents): + raise ValueError('Unsafe installation path') raw = json.loads(config_path.read_text()) hosts, cidr = ','.join(raw['hosts']), raw['lan_cidr'] else: detected_hosts, detected_cidr = detect_network() if not (args.hosts and args.lan_cidr) else ('', '') hosts, cidr = args.hosts or detected_hosts, args.lan_cidr or detected_cidr config = configuration(hosts, cidr) + if print_only: + print(json.dumps(config) if args.print_config else caddy_config(config), end='\n' if args.print_config else '') + return atomic_file(config_path, json.dumps(config) + '\n') - atomic_file(installation_target(root, 'etc/caddy/Caddyfile.d/pihole-maintenance.caddy'), caddy_config(config)) - if not args.destdir and not args.skip_export_refresh: + atomic_file(Path('/etc/caddy/Caddyfile.d/pihole-maintenance.caddy'), caddy_config(config)) + if not args.skip_export_refresh: from maintenance_backups import refresh_exports from maintenance_runner import RunnerContext context = RunnerContext() diff --git a/scripts/install_maintenance_web.sh b/scripts/install_maintenance_web.sh index 68a0d31..2b7d996 100755 --- a/scripts/install_maintenance_web.sh +++ b/scripts/install_maintenance_web.sh @@ -404,7 +404,28 @@ install_staged() { } provision() { - local -a args=(--destdir "$DESTDIR" --hosts "$WEB_HOSTS" --lan-cidr "$LAN_CIDR") + if [[ -n "$DESTDIR" ]]; then + local output target temp + for output in config caddy; do + if [[ "$output" == config ]]; then + target="$(destination /etc/pihole-suite/maintenance-web.json)" + else + target="$(destination "$CADDY_TARGET")" + fi + assert_safe_parent_chain "$target" + [[ ! -L "$target" ]] || die "refusing symlinked staging target" + mkdir -p -- "$(dirname -- "$target")" + temp="$(mktemp "$(dirname -- "$target")/.stage.XXXXXX")" + if ! /usr/bin/python3 "$SOURCE_DIR/maintenance_install.py" "--print-$output" --hosts "$WEB_HOSTS" --lan-cidr "$LAN_CIDR" > "$temp"; then + rm -f -- "$temp" + die "staged provisioning failed" + fi + chmod 0644 "$temp" + mv -f -- "$temp" "$target" + done + return + fi + local -a args=(--hosts "$WEB_HOSTS" --lan-cidr "$LAN_CIDR") [[ "$LOCK_HELD" == false ]] || args+=(--lock-fd 9) [[ "$UPGRADE_ONLY" == false ]] || args+=(--skip-export-refresh) /usr/bin/python3 "$SOURCE_DIR/maintenance_install.py" "${args[@]}" "$@" diff --git a/tests/test_maintenance_config.py b/tests/test_maintenance_config.py index 146719c..c36cb9b 100644 --- a/tests/test_maintenance_config.py +++ b/tests/test_maintenance_config.py @@ -1,13 +1,13 @@ import pytest -from maintenance_install import installation_target - -def test_installation_target_rejects_traversal_and_symlinks(tmp_path): - assert installation_target(tmp_path, 'etc/pihole-suite/maintenance-web.json') == tmp_path / 'etc/pihole-suite/maintenance-web.json' - with pytest.raises(ValueError): - installation_target(tmp_path, '../outside') - (tmp_path / 'etc').symlink_to(tmp_path.parent, target_is_directory=True) - with pytest.raises(ValueError): - installation_target(tmp_path, 'etc/pihole-suite/maintenance-web.json') +def test_staging_output_does_not_write_files(tmp_path): + import json + import subprocess + import sys + from pathlib import Path + script = Path(__file__).resolve().parents[1] / 'maintenance_install.py' + result = subprocess.run([sys.executable, str(script), '--print-config', '--hosts', 'pi.hole,192.168.178.2', '--lan-cidr', '192.168.178.0/24'], cwd=tmp_path, check=True, capture_output=True, text=True) + assert json.loads(result.stdout)['lan_cidr'] == '192.168.178.0/24' + assert list(tmp_path.iterdir()) == [] from maintenance_config import caddy_config, configuration diff --git a/tests/test_upgrade_container.sh b/tests/test_upgrade_container.sh new file mode 100644 index 0000000..528e844 --- /dev/null +++ b/tests/test_upgrade_container.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ "${1:-}" != --inside ]]; then + root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" + exec docker run --rm --network none --security-opt no-new-privileges \ + --mount "type=bind,src=$root,dst=/src,readonly" \ + mcr.microsoft.com/playwright:v1.56.1-noble bash /src/tests/test_upgrade_container.sh --inside +fi + +mkdir -p /etc/caddy/Caddyfile.d /etc/pihole-suite /etc/systemd/system/pihole-suite.service.d \ + /etc/unbound/unbound.conf.d /etc/pihole \ + /etc/sudoers.d /var/lib/pihole-suite/app/web /var/lib/pihole-suite/app/dashboard \ + /var/lib/pihole-suite/venv/bin /var/lib/pihole-suite/jobs /var/lib/pihole-suite/exports \ + /var/lib/pihole-suite/sessions /var/backups/pihole-suite-deploy /var/backups/pihole-suite /run/lock +ln -s /usr/bin/python3 /var/lib/pihole-suite/venv/bin/python +printf 'old-suite\n' > /var/lib/pihole-suite/app/start_suite.py +printf 'custom-data\n' > /var/lib/pihole-suite/app/custom.txt +printf 'old-caddy\n' > /etc/caddy/Caddyfile +printf 'UNBOUND_PORT=5353\n' > /etc/pihole-suite/pihole-suite.env +printf 'dns-baseline\n' > /etc/pihole-suite/dns-sentinel +printf 'upstream config\n' > /etc/pihole/pihole.toml +printf 'unbound config\n' > /etc/unbound/unbound.conf.d/forward.conf +resolver_checksum="$(sha256sum /etc/resolv.conf)" +printf 'old-venv\n' > /var/lib/pihole-suite/venv/preserved + +for command in systemctl systemd-analyze caddy dig curl ss sudo; do + rm -f -- "/usr/bin/$command" +done +rm -f /usr/sbin/visudo +cat > /usr/bin/systemctl <<'EOF' +#!/usr/bin/env bash +if [[ "$1" == is-active && "$*" == *pihole-maintenance-* ]]; then exit 1; fi +exit 0 +EOF +cat > /usr/bin/systemd-analyze <<'EOF' +#!/usr/bin/env bash +exit 0 +EOF +cat > /usr/sbin/visudo <<'EOF' +#!/usr/bin/env bash +exit 0 +EOF +cat > /usr/bin/caddy <<'EOF' +#!/usr/bin/env bash +if [[ -f /tmp/fail-caddy ]]; then rm /tmp/fail-caddy; exit 1; fi +exit 0 +EOF +cat > /usr/bin/dig <<'EOF' +#!/usr/bin/env bash +printf '1.1.1.1\n' +EOF +cat > /usr/bin/curl <<'EOF' +#!/usr/bin/env bash +exit 0 +EOF +cat > /usr/bin/ss <<'EOF' +#!/usr/bin/env bash +printf 'LISTEN 0 128 127.0.0.1:8090 0.0.0.0:*\n' +EOF +cat > /usr/bin/sudo <<'EOF' +#!/usr/bin/env bash +[[ "$1 $2 $3 $4" == '-n -u pihole-suite --' ]] +EOF +chmod 0755 /usr/bin/{systemctl,systemd-analyze,caddy,dig,curl,ss,sudo} /usr/sbin/visudo + +touch /tmp/fail-caddy +if bash /src/scripts/install_maintenance_web.sh --upgrade-only --hosts pi.hole,192.168.178.2 --lan-cidr 192.168.178.0/24; then + printf 'injected deployment failure was ignored\n' >&2 + exit 1 +fi +grep -Fqx old-suite /var/lib/pihole-suite/app/start_suite.py +grep -Fqx old-caddy /etc/caddy/Caddyfile +grep -Fqx custom-data /var/lib/pihole-suite/app/custom.txt +grep -Fqx old-venv /var/lib/pihole-suite/venv/preserved +grep -Fqx dns-baseline /etc/pihole-suite/dns-sentinel +grep -Fqx 'upstream config' /etc/pihole/pihole.toml +grep -Fqx 'unbound config' /etc/unbound/unbound.conf.d/forward.conf +test "$(sha256sum /etc/resolv.conf)" = "$resolver_checksum" + +bash /src/scripts/install_maintenance_web.sh --upgrade-only --hosts pi.hole,192.168.178.2 --lan-cidr 192.168.178.0/24 +cmp /src/start_suite.py /var/lib/pihole-suite/app/start_suite.py +grep -Fqx custom-data /var/lib/pihole-suite/app/custom.txt +grep -Fqx old-venv /var/lib/pihole-suite/venv/preserved +grep -Fqx dns-baseline /etc/pihole-suite/dns-sentinel +grep -Fqx 'upstream config' /etc/pihole/pihole.toml +grep -Fqx 'unbound config' /etc/unbound/unbound.conf.d/forward.conf +test "$(sha256sum /etc/resolv.conf)" = "$resolver_checksum" +printf 'isolated upgrade and rollback: PASS\n' From f12cf0934583d27f2084f5bfa802a1ff04f15c2b Mon Sep 17 00:00:00 2001 From: Tim Baumann Date: Wed, 28 Oct 2026 00:20:23 +0100 Subject: [PATCH 22/24] Expose guarded upgrade for existing installations --- README.de.md | 2 ++ README.md | 2 ++ docs/MAINTENANCE_WEB.md | 9 ++++++--- install.sh | 26 ++++++++++++++++++++++++++ tests/test_install_maintenance_web.sh | 13 +++++++++++++ tests/test_upgrade_container.sh | 4 ++-- 6 files changed, 51 insertions(+), 5 deletions(-) diff --git a/README.de.md b/README.de.md index 3882e93..96653df 100644 --- a/README.de.md +++ b/README.de.md @@ -43,6 +43,8 @@ sudo ./install.sh > Als **normaler Nutzer** klonen (z.B. `pi`), nicht als root. Der Installer wird via `sudo ./install.sh` ausgeführt. +Für eine bereits laufende Suite mit Wartungsoberfläche aktualisiert `sudo ./install.sh --upgrade-only` ausschließlich Suite- und Webdateien. DNS-Konfiguration und Python-Environment bleiben erhalten; Voraussetzungen und Rollback stehen in [docs/MAINTENANCE_WEB.md](docs/MAINTENANCE_WEB.md). + --- ## ✅ Voraussetzungen diff --git a/README.md b/README.md index 8267b22..033d16d 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,8 @@ sudo ./install.sh > Clone as a **normal user** (`pi`), not root. The installer requires `sudo ./install.sh`. +For an existing suite with the maintenance interface, `sudo ./install.sh --upgrade-only` updates the suite and web files without changing DNS configuration or the Python environment. See [docs/MAINTENANCE_WEB.md](docs/MAINTENANCE_WEB.md) for prerequisites and rollback. + --- ## ✅ Requirements diff --git a/docs/MAINTENANCE_WEB.md b/docs/MAINTENANCE_WEB.md index e5bc018..9411fc3 100644 --- a/docs/MAINTENANCE_WEB.md +++ b/docs/MAINTENANCE_WEB.md @@ -22,7 +22,7 @@ sudo apt update sudo apt install caddy ``` -Danach installiert der vorhandene Projekt-Installer den Wartungsdienst gezielt: +Bei einer Neuinstallation installiert der Projekt-Installer den Wartungsdienst gezielt: ```bash sudo ./install.sh --with-maintenance-web @@ -110,10 +110,10 @@ dig +short @127.0.0.1 -p 5335 example.com Der Gesamtinstaller verweigert einen Lauf bei erkannter bestehender Installation, weil sein Rollback DNS, Resolver, Dienste und Python-Environment nicht vollständig abdeckt. Ein Upgrade erfordert eine separat geprüfte Migration und Sicherung. -Für eine bereits laufende Suite steht der eingeschränkte Web-Upgradepfad bereit: +Für eine bereits laufende Suite steht der eingeschränkte Suite- und Web-Upgradepfad bereit: ```bash -sudo ./scripts/install_maintenance_web.sh --upgrade-only +sudo ./install.sh --upgrade-only ``` Er setzt laufendes Pi-hole, Unbound, Caddy und die Suite sowie aktivierte Webdienste @@ -122,6 +122,9 @@ unter Wartungssperre ein validiertes Deployment-Backup. Er ändert weder DNS- no Resolverkonfiguration oder das Python-Environment. Caddy wird neu geladen und die Suite neu gestartet. Der Pfad ersetzt kein vollständiges Systembackup und muss vor einer produktiven Nutzung auf dem Zielsystem überprüft werden. +Bei Bedarf können `--maintenance-hosts HOST,IP` und `--maintenance-lan-cidr CIDR` +übergeben werden. Allgemeine Installationsoptionen wie `--force` sind bei diesem +selektiven Upgrade bewusst nicht zulässig. Der Web-Installer erstellt ein root-eigenes Deployment-Backup unter `/var/backups/pihole-suite-deploy/`. Es deckt die im Manifest aufgeführten diff --git a/install.sh b/install.sh index ade0109..fc5ef43 100755 --- a/install.sh +++ b/install.sh @@ -65,6 +65,7 @@ AUTO_REMOVE_CONFLICTS=false INSTALL_NETALERTX=false INSTALL_PYTHON_SUITE=true INSTALL_MAINTENANCE_WEB=false +UPGRADE_ONLY=false MAINTENANCE_HOSTS="" MAINTENANCE_LAN_CIDR="" @@ -330,6 +331,7 @@ parse_args() { --skip-netalertx) INSTALL_NETALERTX=false ;; --skip-python-api) INSTALL_PYTHON_SUITE=false ;; --with-maintenance-web) INSTALL_MAINTENANCE_WEB=true ;; + --upgrade-only) UPGRADE_ONLY=true ;; --skip-maintenance-web) INSTALL_MAINTENANCE_WEB=false ;; --maintenance-hosts|--maintenance-lan-cidr) [[ $# -ge 2 ]] || { log_error "$1 requires a value"; exit 1; } @@ -1374,6 +1376,25 @@ run_healthchecks() { # ============================================= # MAIN # ============================================= +validate_upgrade_arguments() { + while [[ $# -gt 0 ]]; do + case "$1" in + --upgrade-only) shift ;; + --maintenance-hosts|--maintenance-lan-cidr) + [[ $# -ge 2 && "$2" != --* ]] || { log_error "$1 requires a value"; return 1; } + shift 2 ;; + *) log_error "Selective upgrade does not support $1"; return 1 ;; + esac + done +} + +run_existing_upgrade() { + local -a args=(--upgrade-only) + [[ -z "$MAINTENANCE_HOSTS" ]] || args+=(--hosts "$MAINTENANCE_HOSTS") + [[ -z "$MAINTENANCE_LAN_CIDR" ]] || args+=(--lan-cidr "$MAINTENANCE_LAN_CIDR") + "$SCRIPT_DIR/scripts/install_maintenance_web.sh" "${args[@]}" +} + guard_existing_install() { local pihole_config="$1" unbound_config="$2" suite_app="$3" if [[ -e "$pihole_config" || -L "$pihole_config" || -e "$unbound_config" || -L "$unbound_config" || -e "$suite_app" || -L "$suite_app" ]] \ @@ -1387,6 +1408,11 @@ guard_existing_install() { main() { local maintenance_backup_dir="" parse_args "$@" + if [[ "$UPGRADE_ONLY" == true ]]; then + validate_upgrade_arguments "$@" || return 1 + run_existing_upgrade + return + fi if [[ "$INSTALL_MAINTENANCE_WEB" == true && "$INSTALL_PYTHON_SUITE" != true ]]; then log_error "--with-maintenance-web requires the Python API Suite" exit 1 diff --git a/tests/test_install_maintenance_web.sh b/tests/test_install_maintenance_web.sh index bb76eba..237d1d9 100755 --- a/tests/test_install_maintenance_web.sh +++ b/tests/test_install_maintenance_web.sh @@ -21,6 +21,19 @@ run_staged_install() { } run_staged_install +ROOT_DIR="$ROOT_DIR" bash -c ' + source "$ROOT_DIR/install.sh" + run_existing_upgrade() { test "$MAINTENANCE_HOSTS" = pi.hole; test "$MAINTENANCE_LAN_CIDR" = 192.168.178.0/24; } + main --upgrade-only --maintenance-hosts pi.hole --maintenance-lan-cidr 192.168.178.0/24 +' +if ROOT_DIR="$ROOT_DIR" bash -c ' + source "$ROOT_DIR/install.sh" + run_existing_upgrade() { return 0; } + main --upgrade-only --force +' >/dev/null 2>&1; then + printf '%s\n' 'unsafe full-installer option accepted for selective upgrade' >&2 + exit 1 +fi VENV_TEST_ROOT="$VENV_TEST_ROOT" ROOT_DIR="$ROOT_DIR" bash -c ' source "$ROOT_DIR/scripts/install_maintenance_web.sh" sudo() { diff --git a/tests/test_upgrade_container.sh b/tests/test_upgrade_container.sh index 528e844..1f67e95 100644 --- a/tests/test_upgrade_container.sh +++ b/tests/test_upgrade_container.sh @@ -65,7 +65,7 @@ EOF chmod 0755 /usr/bin/{systemctl,systemd-analyze,caddy,dig,curl,ss,sudo} /usr/sbin/visudo touch /tmp/fail-caddy -if bash /src/scripts/install_maintenance_web.sh --upgrade-only --hosts pi.hole,192.168.178.2 --lan-cidr 192.168.178.0/24; then +if bash /src/install.sh --upgrade-only --maintenance-hosts pi.hole,192.168.178.2 --maintenance-lan-cidr 192.168.178.0/24; then printf 'injected deployment failure was ignored\n' >&2 exit 1 fi @@ -78,7 +78,7 @@ grep -Fqx 'upstream config' /etc/pihole/pihole.toml grep -Fqx 'unbound config' /etc/unbound/unbound.conf.d/forward.conf test "$(sha256sum /etc/resolv.conf)" = "$resolver_checksum" -bash /src/scripts/install_maintenance_web.sh --upgrade-only --hosts pi.hole,192.168.178.2 --lan-cidr 192.168.178.0/24 +bash /src/install.sh --upgrade-only --maintenance-hosts pi.hole,192.168.178.2 --maintenance-lan-cidr 192.168.178.0/24 cmp /src/start_suite.py /var/lib/pihole-suite/app/start_suite.py grep -Fqx custom-data /var/lib/pihole-suite/app/custom.txt grep -Fqx old-venv /var/lib/pihole-suite/venv/preserved From ee81b916d2bec19d9c4da89ca17e54f0e9ea0303 Mon Sep 17 00:00:00 2001 From: Tim Baumann Date: Wed, 28 Oct 2026 00:21:49 +0100 Subject: [PATCH 23/24] Expose API routes and report unavailable metrics honestly --- dashboard/app.js | 2 +- deploy/Caddyfile.maintenance | 2 +- maintenance_config.py | 2 +- start_suite.py | 37 +++++++++++++++++++++----------- tests/test_maintenance_config.py | 3 ++- tests/test_suite.py | 16 ++++++++++++++ 6 files changed, 45 insertions(+), 17 deletions(-) diff --git a/dashboard/app.js b/dashboard/app.js index bf4d6c9..f578dc7 100644 --- a/dashboard/app.js +++ b/dashboard/app.js @@ -170,7 +170,7 @@ document.addEventListener('DOMContentLoaded', () => { } if (stats) { - updateElementText('pihole-recent-queries', stats.recent_queries); + updateElementText('pihole-recent-queries', stats.recent_queries ?? 'Nicht verfügbar'); } // Process Unbound diff --git a/deploy/Caddyfile.maintenance b/deploy/Caddyfile.maintenance index d24c036..90eebc1 100644 --- a/deploy/Caddyfile.maintenance +++ b/deploy/Caddyfile.maintenance @@ -5,7 +5,7 @@ pi.hole:8443, 192.168.178.2:8443 { request_body { max_size 8KB } - @maintenance path / /maintenance.css /maintenance.js /api/session /api/session/logout /api/maintenance/* /dashboard /dashboard/* /health /urls /pihole /unbound /netalertx /stats + @maintenance path / /maintenance.css /maintenance.js /api/session /api/session/logout /api/maintenance/* /dashboard /dashboard/* /health /urls /pihole /unbound /netalertx /stats /version /dns /devices /leases handle @maintenance { reverse_proxy 127.0.0.1:8090 } diff --git a/maintenance_config.py b/maintenance_config.py index 72771c8..815506e 100644 --- a/maintenance_config.py +++ b/maintenance_config.py @@ -27,7 +27,7 @@ def caddy_config(config: dict) -> str: request_body {{ max_size 8KB }} - @maintenance path / /maintenance.css /maintenance.js /api/session /api/session/logout /api/maintenance/* /dashboard /dashboard/* /health /urls /pihole /unbound /netalertx /stats + @maintenance path / /maintenance.css /maintenance.js /api/session /api/session/logout /api/maintenance/* /dashboard /dashboard/* /health /urls /pihole /unbound /netalertx /stats /version /dns /devices /leases handle @maintenance {{ reverse_proxy 127.0.0.1:8090 }} diff --git a/start_suite.py b/start_suite.py index 2fa6cb6..20f3d03 100755 --- a/start_suite.py +++ b/start_suite.py @@ -74,12 +74,12 @@ def require_api_key(x_api_key: str | None = Header(default=None, alias="X-API-Ke if os.path.isdir(os.path.join(os.path.dirname(__file__), "dashboard")): app.mount("/dashboard", StaticFiles(directory=os.path.join(os.path.dirname(__file__), "dashboard"), html=True), name="dashboard") -def _read_lines(path: str, limit: int) -> list[str]: +def _read_lines(path: str, limit: int) -> list[str] | None: try: with open(path, "r", encoding="utf-8", errors="replace") as f: return list(deque(f, maxlen=limit)) except OSError: - return [] + return None def _run(cmd: list[str], timeout: float = 2.0) -> dict[str, Any]: @@ -123,19 +123,22 @@ def _http_ok(url: str, timeout: float = 1.5) -> bool: return False -def _parse_pihole_log(limit: int) -> list[dict[str, Any]]: +def _parse_pihole_log(limit: int) -> list[dict[str, Any]] | None: # Best-effort parser for classic Pi-hole log format. - # If file isn't present/accessible, returns empty list. + # If no candidate is present and readable, report unavailable. candidates = [ "/var/log/pihole/pihole.log", "/var/log/pihole.log", ] - lines: list[str] = [] + lines: list[str] | None = None for p in candidates: lines = _read_lines(p, limit * 3) - if lines: + if lines is not None: break + if lines is None: + return None + out: list[dict[str, Any]] = [] # Example patterns differ by versions; keep it permissive. # Jan 1 12:00:00 dnsmasq[1234]: query[A] example.com from 192.168.1.2 @@ -161,7 +164,7 @@ def _parse_pihole_log(limit: int) -> list[dict[str, Any]]: return list(reversed(out)) -def _parse_dhcp_leases(limit: int) -> list[dict[str, Any]]: +def _parse_dhcp_leases(limit: int) -> list[dict[str, Any]] | None: # Pi-hole DHCP leases file: /etc/pihole/dhcp.leases # Format: # We only have expiry; lease_start may be unknown. @@ -169,6 +172,8 @@ def _parse_dhcp_leases(limit: int) -> list[dict[str, Any]]: path = "/etc/pihole/dhcp.leases" lines = _read_lines(path, limit * 3) + if lines is None: + return None out: list[dict[str, Any]] = [] def to_iso(epoch: str) -> str | None: @@ -300,29 +305,35 @@ def netalertx() -> dict[str, Any]: @app.get("/dns", dependencies=[Depends(require_api_key)]) def dns(limit: int = 50) -> list[dict[str, Any]]: limit = max(1, min(limit, 500)) - return _parse_pihole_log(limit) + entries = _parse_pihole_log(limit) + if entries is None: + raise HTTPException(status_code=503, detail="DNS log unavailable") + return entries @app.get("/devices", dependencies=[Depends(require_api_key)]) def devices() -> list[dict[str, Any]]: # Placeholder: device discovery depends on NetAlertX (formerly Pi.Alert) # APIs and is environment-specific. - return [] + raise HTTPException(status_code=503, detail="Device discovery is not configured") @app.get("/leases", dependencies=[Depends(require_api_key)]) def leases(limit: int = 200) -> list[dict[str, Any]]: limit = max(1, min(limit, 2000)) - return _parse_dhcp_leases(limit) + entries = _parse_dhcp_leases(limit) + if entries is None: + raise HTTPException(status_code=503, detail="DHCP leases unavailable") + return entries @app.get("/stats", dependencies=[Depends(require_api_key)]) def stats() -> dict[str, Any]: recent = _parse_pihole_log(100) return { - "total_dns_logs": len(recent), - "total_devices": 0, - "recent_queries": len(recent), + "total_dns_logs": len(recent) if recent is not None else None, + "total_devices": None, + "recent_queries": len(recent) if recent is not None else None, "note": "DNS stats are derived from best-effort log parsing; may be empty depending on Pi-hole logging/permissions.", } diff --git a/tests/test_maintenance_config.py b/tests/test_maintenance_config.py index c36cb9b..ea16dad 100644 --- a/tests/test_maintenance_config.py +++ b/tests/test_maintenance_config.py @@ -19,7 +19,8 @@ def test_network_config_and_route_boundary(): assert 'path / /maintenance.css /maintenance.js /api/session /api/session/logout /api/maintenance/*' in caddy assert 'respond "Not found" 404' in caddy assert 'max_size 8KB' in caddy - assert '/dns' not in caddy + for route in ('/version', '/dns', '/devices', '/leases'): + assert route in caddy @pytest.mark.parametrize('host,network', [('host {', '192.168.1.0/24'), ('https://pi.hole', '192.168.1.0/24'), ('pi.hole', '0.0.0.0/0'), ('pi.hole', '8.8.8.0/24')]) diff --git a/tests/test_suite.py b/tests/test_suite.py index ecd2df2..f1392ed 100644 --- a/tests/test_suite.py +++ b/tests/test_suite.py @@ -15,6 +15,22 @@ HEADERS = {"X-API-Key": "test-key"} BAD_HEADERS = {"X-API-Key": "wrong-key"} +def test_unknown_device_count_is_not_reported_as_zero(): + assert client.get('/stats', headers=HEADERS).json()['total_devices'] is None + response = client.get('/devices', headers=HEADERS) + assert response.status_code == 503 + +def test_exposed_monitoring_endpoints_require_api_key(): + for path in ('/version', '/dns', '/devices', '/leases'): + assert client.get(path).status_code == 401 + +def test_missing_dns_log_is_unavailable_not_zero(monkeypatch): + monkeypatch.setattr(start_suite, '_read_lines', lambda *args: None) + response = client.get('/stats', headers=HEADERS) + assert response.json()['recent_queries'] is None + assert client.get('/dns', headers=HEADERS).status_code == 503 + assert client.get('/leases', headers=HEADERS).status_code == 503 + def test_read_lines_uses_bounded_iteration(tmp_path, monkeypatch): import builtins path = tmp_path / 'log' From 58f03c6af460e7c2a74de537db9d675b596f7b08 Mon Sep 17 00:00:00 2001 From: Tim Baumann Date: Wed, 28 Oct 2026 04:44:23 +0100 Subject: [PATCH 24/24] fix: bound DNS log parsing and avoid persistent dashboard key --- dashboard/app.js | 15 ++++++--------- start_suite.py | 35 ++++++++++++++++++++++++----------- tests/test_dashboard_dom.js | 9 +++++---- tests/test_suite.py | 6 ++++++ 4 files changed, 41 insertions(+), 24 deletions(-) diff --git a/dashboard/app.js b/dashboard/app.js index f578dc7..9dc1da1 100644 --- a/dashboard/app.js +++ b/dashboard/app.js @@ -9,15 +9,13 @@ document.addEventListener('DOMContentLoaded', () => { const lastUpdateEl = document.getElementById('last-update'); // API Key management - let apiKey = sessionStorage.getItem('suite_api_key'); + // Do not persist the permanent API key in browser storage. It is held only + // for this page lifetime and must be entered again after a reload. + sessionStorage.removeItem('suite_api_key'); + let apiKey = null; // Initialization - if (apiKey) { - showDashboard(); - fetchData(); - } else { - showAuth(); - } + showAuth(); // Event Listeners authForm.addEventListener('submit', async (e) => { @@ -33,7 +31,7 @@ document.addEventListener('DOMContentLoaded', () => { if (res.ok) { apiKey = key; - sessionStorage.setItem('suite_api_key', key); + apiKeyInput.value = ''; authError.classList.add('hidden'); showDashboard(); fetchData(); @@ -48,7 +46,6 @@ document.addEventListener('DOMContentLoaded', () => { }); logoutBtn.addEventListener('click', () => { - sessionStorage.removeItem('suite_api_key'); apiKey = null; apiKeyInput.value = ''; showAuth(); diff --git a/start_suite.py b/start_suite.py index 20f3d03..36c08be 100755 --- a/start_suite.py +++ b/start_suite.py @@ -140,21 +140,34 @@ def _parse_pihole_log(limit: int) -> list[dict[str, Any]] | None: return None out: list[dict[str, Any]] = [] - # Example patterns differ by versions; keep it permissive. - # Jan 1 12:00:00 dnsmasq[1234]: query[A] example.com from 192.168.1.2 - rx = re.compile( - r"^(?P\w{3}\s+\d+\s+\d+:\d+:\d+).*(query\[[A-Z]+\])\s+(?P[^\s]+)\s+from\s+(?P[^\s]+)" - ) - for line in reversed(lines): - m = rx.search(line) - if not m: + # A syslog record is short. Ignore oversized/corrupt records before + # parsing, and use bounded splitting instead of regex on log content. + if len(line) > 8192: + continue + marker = " query[" + marker_at = line.find(marker) + if marker_at < 0: + continue + prefix = line[:marker_at] + daemon_at = prefix.find(" dnsmasq[") + timestamp = " ".join((prefix[:daemon_at] if daemon_at >= 0 else prefix).split()[-3:]) + query_start = line.find("] ", marker_at) + if query_start < 0: + continue + query_end = line.find(" from ", query_start + 2) + if query_end < 0: + continue + query = line[query_start + 2:query_end] + client_fields = line[query_end + 6:].split(None, 1) + client = client_fields[0] if client_fields else "" + if not timestamp or not query or not client: continue out.append( { - "timestamp": m.group("ts"), - "client": m.group("client"), - "query": m.group("q"), + "timestamp": timestamp, + "client": client, + "query": query, "action": "query", } ) diff --git a/tests/test_dashboard_dom.js b/tests/test_dashboard_dom.js index c81518d..da9f4ed 100644 --- a/tests/test_dashboard_dom.js +++ b/tests/test_dashboard_dom.js @@ -1,7 +1,7 @@ const assert = require('assert'); const nodes = new Map(); function element(id) { - if (!nodes.has(id)) nodes.set(id, { className: '', textContent: '', classList: {add(){}, remove(){}}, addEventListener(){}, focus(){} }); + if (!nodes.has(id)) nodes.set(id, { className: '', textContent: '', focused: false, classList: {add(){}, remove(){}}, addEventListener(){}, focus(){this.focused = true;} }); return nodes.get(id); } const data = { @@ -17,11 +17,12 @@ const document = { querySelector(selector) { return element(selector.slice(1)); } }; global.document = document; -global.sessionStorage = {getItem: () => 'key'}; +let removedSessionKey = false; +global.sessionStorage = {removeItem(key) { if (key === 'suite_api_key') removedSessionKey = true; }}; global.fetch = async url => ({ok: true, json: async () => data[url]}); global.setTimeout = () => {}; require('../dashboard/app.js'); setImmediate(() => { - assert.strictEqual(element('overall-status .status-indicator').className, 'status-indicator healthy'); - assert.strictEqual(element('overall-status .status-text').textContent, 'Alle Kernsysteme gesund'); + assert.strictEqual(removedSessionKey, true); + assert.strictEqual(element('api-key').focused, true); }); diff --git a/tests/test_suite.py b/tests/test_suite.py index f1392ed..8067ae3 100644 --- a/tests/test_suite.py +++ b/tests/test_suite.py @@ -31,6 +31,12 @@ def test_missing_dns_log_is_unavailable_not_zero(monkeypatch): assert client.get('/dns', headers=HEADERS).status_code == 503 assert client.get('/leases', headers=HEADERS).status_code == 503 +def test_dns_log_skips_oversized_lines_and_parses_queries(monkeypatch): + lines = ['x' * 100000 + ' query[A] attack.example from 10.0.0.1', + 'Jan 1 12:00:00 dnsmasq[1234]: query[A] example.com from 192.168.1.2\n'] + monkeypatch.setattr(start_suite, '_read_lines', lambda *args: lines) + assert start_suite._parse_pihole_log(5) == [{'timestamp': 'Jan 1 12:00:00', 'client': '192.168.1.2', 'query': 'example.com', 'action': 'query'}] + def test_read_lines_uses_bounded_iteration(tmp_path, monkeypatch): import builtins path = tmp_path / 'log'