Merge pull request 'Sichere Entfernung stillgelegter Uptime-Monitore' (#36) from feat/safe-uptime-monitor-removal-20260814 into main
This commit is contained in:
commit
7197ba2103
2 changed files with 98 additions and 2 deletions
78
app.py
78
app.py
|
|
@ -1499,6 +1499,84 @@ async def restore_iso_builder(dry_run: bool = Query(True), _=Depends(_verify)):
|
||||||
return result
|
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"
|
CADDY_HOST = "root@46.225.230.72"
|
||||||
CADDYFILE_PATH = "/app-config/caddy/Caddyfile"
|
CADDYFILE_PATH = "/app-config/caddy/Caddyfile"
|
||||||
HETZNER_DNS_API = "https://api.hetzner.cloud/v1"
|
HETZNER_DNS_API = "https://api.hetzner.cloud/v1"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import os
|
import os
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timedelta, timezone
|
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):
|
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, "_read", lambda _name: None)
|
||||||
monkeypatch.setattr(app, "_ssh", lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("must not sync vault")))
|
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():
|
def test_invalid_log_target_is_rejected_before_ssh():
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue