From a37784c8969e36359d10ec914f9a79defbea2256 Mon Sep 17 00:00:00 2001 From: sascha Date: Fri, 14 Aug 2026 10:49:01 +0200 Subject: [PATCH 1/2] =?UTF-8?q?Sichere=20Uptime-Monitor-Entfernung=20erg?= =?UTF-8?q?=C3=A4nzen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.py | 78 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/app.py b/app.py index 84a123a..526598c 100644 --- a/app.py +++ b/app.py @@ -1499,6 +1499,84 @@ async def restore_iso_builder(dry_run: bool = Query(True), _=Depends(_verify)): return result +UPTIME_HOST = "sascha@10.5.85.5" +UPTIME_DB = "/app-config/kuma/kuma.db" + + +def _uptime_monitor_remove_command(monitor_id: int, expected_name: str, dry_run: bool) -> str: + script = '''import datetime, json, os, shutil, sqlite3, subprocess +monitor_id = %r +expected_name = %r +dry_run = %r +path = %r + +def docker(*args): + return subprocess.run(["sudo", "docker", *args], text=True, capture_output=True, timeout=60) + +connection = sqlite3.connect("file:" + path + "?mode=ro", uri=True) +connection.row_factory = sqlite3.Row +row = connection.execute("select id,name,url,active from monitor where id=?", (monitor_id,)).fetchone() +connection.close() +result = {"monitor_id": monitor_id, "expected_name": expected_name, "dry_run": dry_run, "found": dict(row) if row else None} +if not row: + print(json.dumps(result)); raise SystemExit(0) +if row["name"] != expected_name: + result["error"] = "Monitor name mismatch"; print(json.dumps(result)); raise SystemExit(2) +if dry_run: + print(json.dumps(result)); raise SystemExit(0) +stop = docker("stop", "kuma") +if stop.returncode != 0: + result["error"] = "Could not stop Kuma"; result["stderr"] = stop.stderr[-500:]; print(json.dumps(result)); raise SystemExit(3) +backup = path + ".pre-monitor-removal-" + datetime.datetime.now().strftime("%%Y%%m%%d-%%H%%M%%S") +try: + shutil.copy2(path, backup) + connection = sqlite3.connect(path) + connection.execute("pragma foreign_keys=off") + tables = [item[0] for item in connection.execute("select name from sqlite_master where type='table'")] + cleaned = [] + for table in tables: + if table == "monitor" or not table.replace("_", "").isalnum(): + continue + for fk in connection.execute('pragma foreign_key_list("' + table + '")'): + if fk[2] == "monitor" and fk[3].replace("_", "").isalnum(): + cursor = connection.execute('delete from "' + table + '" where "' + fk[3] + '"=?', (monitor_id,)) + if cursor.rowcount: + cleaned.append({"table": table, "rows": cursor.rowcount}) + deleted = connection.execute("delete from monitor where id=? and name=?", (monitor_id, expected_name)).rowcount + connection.commit(); connection.close() + result["backup"] = backup; result["dependencies_cleaned"] = cleaned; result["deleted"] = deleted +except Exception as exc: + shutil.copy2(backup, path) + result["error"] = str(exc) +finally: + start = docker("start", "kuma") + result["container_start_rc"] = start.returncode +if result.get("error") or result.get("deleted") != 1 or result["container_start_rc"] != 0: + print(json.dumps(result)); raise SystemExit(4) +connection = sqlite3.connect("file:" + path + "?mode=ro", uri=True) +result["remaining"] = connection.execute("select count(*) from monitor where id=?", (monitor_id,)).fetchone()[0] +connection.close() +print(json.dumps(result)) +''' % (monitor_id, expected_name, dry_run, UPTIME_DB) + encoded = base64.b64encode(script.encode()).decode() + return f"python3 -c \"import base64;exec(base64.b64decode('{encoded}'))\"" + + +@app.delete("/uptime/monitor/{monitor_id}") +async def uptime_monitor_remove(monitor_id: int, expected_name: str = Query(..., min_length=1, max_length=100), dry_run: bool = Query(True), _=Depends(_verify)): + if not re.fullmatch(r"[A-Za-z0-9 ._()-]+", expected_name): + raise HTTPException(400, "Invalid expected monitor name") + rc, out, err = _ssh(UPTIME_HOST, _uptime_monitor_remove_command(monitor_id, expected_name, dry_run), timeout=120) + try: + result = json.loads(out) + except Exception: + raise HTTPException(502, (err or out or "Uptime cleanup returned no JSON")[-1000:]) + if rc != 0: + raise HTTPException(409 if result.get("error") == "Monitor name mismatch" else 502, result) + _audit(f"/uptime/monitor/{monitor_id}", "DELETE", 200, f"dry_run={dry_run}") + return result + + CADDY_HOST = "root@46.225.230.72" CADDYFILE_PATH = "/app-config/caddy/Caddyfile" HETZNER_DNS_API = "https://api.hetzner.cloud/v1" -- 2.49.1 From c40e869ef8731142d4152125962e46ffb86e82c7 Mon Sep 17 00:00:00 2001 From: sascha Date: Fri, 14 Aug 2026 10:49:02 +0200 Subject: [PATCH 2/2] Uptime-Monitor-Entfernung testen --- tests/test_app.py | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/tests/test_app.py b/tests/test_app.py index cf541dd..0b30b71 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -1,5 +1,6 @@ import os import asyncio +import json import time from datetime import datetime, timedelta, timezone @@ -278,10 +279,27 @@ def test_dns_rrset_dry_run_returns_only_selected_records(monkeypatch): def test_hetzner_dns_token_accepts_single_sanitized_vault_alias(monkeypatch): - monkeypatch.setattr(app, "_vault_cache", {"hetzner-dns-api": "secret-value", "other": "ignored"}) + token = "a" * 48 + monkeypatch.setattr(app, "_vault_cache", {"hetzner-dns-api": f"Hetzner DNS API Token: {token}\nFür sascha-lutz.de", "other": "ignored"}) monkeypatch.setattr(app, "_read", lambda _name: None) monkeypatch.setattr(app, "_ssh", lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("must not sync vault"))) - assert app._get_hetzner_dns_token() == "secret-value" + assert app._get_hetzner_dns_token() == token + + +def test_uptime_monitor_remove_defaults_to_dry_run(monkeypatch): + payload = {"monitor_id": 71, "expected_name": "Outline Wiki", "dry_run": True, "found": {"id": 71, "name": "Outline Wiki"}} + monkeypatch.setattr(app, "_ssh", lambda target, command, timeout=120: (0, json.dumps(payload), "")) + with TestClient(app.app) as client: + response = client.delete("/uptime/monitor/71", params={"expected_name": "Outline Wiki"}, headers={"Authorization": "Bearer test-token"}) + assert response.status_code == 200 + assert response.json()["dry_run"] is True + + +def test_uptime_monitor_remove_rejects_invalid_expected_name_before_ssh(monkeypatch): + monkeypatch.setattr(app, "_ssh", lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("must not SSH"))) + with TestClient(app.app) as client: + response = client.delete("/uptime/monitor/71", params={"expected_name": "Outline; rm -rf /"}, headers={"Authorization": "Bearer test-token"}) + assert response.status_code == 400 def test_invalid_log_target_is_rejected_before_ssh(): -- 2.49.1