From 7edf01b021a53347da217409d8bf1156bd5309a8 Mon Sep 17 00:00:00 2001 From: Christoph Emsenhuber Date: Sun, 6 Sep 2026 16:45:00 +0200 Subject: [PATCH] TP23 Stufe 5: HA-Self-Service + Dashboard-Draft + Doku-Nachzug - agh-exception.py: Creds aus Env/Datei (Mac ~/.secrets + HAOS /config), Domain/IP-Validierung - HA-Self-Service dokumentiert: shell_command + Helper/Timer/2 Automations (adguard/ha-self-service.md) - Eigenes Dashboard 'dns-ausnahmen' (1. Draft, sections) inkl. Repro-JSON - teilprojekte.md: TP23 Quick-Index + dated Update + Ist-Stand-Block nachgezogen (Stufe 1-5 durch; offen nur Stufe 6 Bypass-Sperre -> separate Session) Co-Authored-By: Claude Opus 4.8 --- adguard/agh-exception.py | 42 ++++++++++++++++++++----- adguard/ha-self-service.md | 63 ++++++++++++++++++++++++++++++++++++++ docs/teilprojekte.md | 23 +++++++++++++- 3 files changed, 119 insertions(+), 9 deletions(-) create mode 100644 adguard/ha-self-service.md diff --git a/adguard/agh-exception.py b/adguard/agh-exception.py index 75d878b..dbb73ad 100755 --- a/adguard/agh-exception.py +++ b/adguard/agh-exception.py @@ -14,19 +14,40 @@ Aufrufer (HA-Timer) bzw. der RouterOS-Backstop (Stufe 6). Diese CLI ist reines a Auth: AGH_AUTOMATION_USER/_PASS aus ~/.secrets/credentials.env (nie in argv). """ -import sys, os, json, base64, urllib.request, urllib.error, pathlib +import sys, os, json, base64, urllib.request, urllib.error, pathlib, re + +DOMAIN_RE = re.compile(r"^(?=.{1,253}$)([a-z0-9](-?[a-z0-9]+)*\.)+[a-z]{2,}$", re.I) +IP_RE = re.compile(r"^\d{1,3}(\.\d{1,3}){3}$") INST = ["10.40.20.36", "10.40.20.37"] -SECRETS = pathlib.Path.home() / ".secrets/credentials.env" +# Creds-Quellen in Reihenfolge: erste, die User+Pass liefert, gewinnt. +# So läuft derselbe Code auf dem Mac (~/.secrets) und auf HAOS (/config). +CRED_FILES = [ + os.environ.get("AGH_CREDS_FILE"), + str(pathlib.Path.home() / ".secrets/credentials.env"), + "/config/agh-exception.secrets", +] + + +def _from_file(path): + u = p = None + try: + for line in open(path).read().splitlines(): + if line.startswith("AGH_AUTOMATION_USER="): u = line.split("=", 1)[1].strip() + if line.startswith("AGH_AUTOMATION_PASS="): p = line.split("=", 1)[1].strip() + except OSError: + return None + return (u, p) if (u and p) else None def creds(): - u = p = None - for line in SECRETS.read_text().splitlines(): - if line.startswith("AGH_AUTOMATION_USER="): u = line.split("=", 1)[1].strip() - if line.startswith("AGH_AUTOMATION_PASS="): p = line.split("=", 1)[1].strip() - if not (u and p): sys.exit("AGH_AUTOMATION_USER/_PASS fehlen in ~/.secrets") - return u, p + eu, ep = os.environ.get("AGH_AUTOMATION_USER"), os.environ.get("AGH_AUTOMATION_PASS") + if eu and ep: + return eu, ep + for f in CRED_FILES: + if f and (r := _from_file(f)): + return r + sys.exit("AGH_AUTOMATION_USER/_PASS: weder Env noch eine Creds-Datei gefunden") AUTH = "Basic " + base64.b64encode(":".join(creds()).encode()).decode() @@ -53,6 +74,9 @@ def rule(domain): def allow(domain, remove=False): + domain = domain.strip().lower() + if not DOMAIN_RE.match(domain): + sys.exit(f"ungültige Domain: {domain!r}") r = rule(domain) for ip in INST: st = api(ip, "/control/filtering/status") @@ -66,6 +90,8 @@ def allow(domain, remove=False): def set_filter(ip_addr, enabled): + if not IP_RE.match(ip_addr): + sys.exit(f"ungültige IP: {ip_addr!r}") for ip in INST: data = api(ip, "/control/clients") c = None diff --git a/adguard/ha-self-service.md b/adguard/ha-self-service.md new file mode 100644 index 0000000..ff4d3d7 --- /dev/null +++ b/adguard/ha-self-service.md @@ -0,0 +1,63 @@ +# TP23 Stufe 5 — HA-Self-Service für DNS-Ausnahmen + +Home Assistant (HAOS-VM 100, `10.40.20.105`) gibt eine Ein-Klick-Oberfläche, um eine Domain +temporär (JIT) über das AGH-Paar `.36`/`.37` zu entsperren. Ablauf/Revert nativ per `timer`. + +**Warum kein `rest_command`:** die AGH-Endpunkte sind read-modify-write (`set_rules` ersetzt die +ganze Liste). HA ruft deshalb die deployte RMW-CLI `agh-exception.py` per `shell_command`. + +## Bausteine (alle in HA .storage, via ha-mcp angelegt — keine YAML außer shell_command) +| Typ | entity_id | Zweck | +|---|---|---| +| input_text | `input_text.dns_ausnahme_domain` | einzugebende Domain | +| input_text | `input_text.dns_ausnahme_aktiv` | aktuell aktive Ausnahme (für leak-freien Revert; init `""`) | +| input_select | `input_select.dns_ausnahme_dauer` | 15 Minuten / 1 Stunde / 4 Stunden | +| timer | `timer.dns_ausnahme` | JIT-Countdown (restore=false) | +| input_button | `input_button.dns_ausnahme_starten` | Auslöser | +| automation | `automation.dns_ausnahme_starten` | Button → validieren, alte Ausnahme aufräumen, `agh_allow_domain`, Timer starten | +| automation | `automation.dns_ausnahme_ablauf` | `timer.finished`/`timer.cancelled` → `agh_unallow_domain`, `_aktiv` leeren | + +## `configuration.yaml` (der einzige YAML-Teil, Backup `.bak-tp23-*`) +```yaml +shell_command: + agh_allow_domain: "python3 /config/agh-exception.py allow-domain '{{ domain }}'" + agh_unallow_domain: "python3 /config/agh-exception.py unallow-domain '{{ domain }}'" + agh_unfilter_client: "python3 /config/agh-exception.py unfilter '{{ ip }}'" + agh_refilter_client: "python3 /config/agh-exception.py refilter '{{ ip }}'" +``` +Neue Domain → **HA-Neustart** nötig (reload_all lädt eine erstmals hinzugefügte Domain nicht). + +## Deploy auf HAOS (Zugang `ssh admin@10.40.20.105`, sudo passwortlos) +- `/config/agh-exception.py` — Kopie von `adguard/agh-exception.py` (liest Creds via `/config/agh-exception.secrets`). +- `/config/agh-exception.secrets` (chmod 600, root) — `AGH_AUTOMATION_USER`/`_PASS`. +- Redeploy nach Script-Änderung: `base64 < adguard/agh-exception.py | ssh admin@10.40.20.105 "base64 -d | sudo tee /config/agh-exception.py >/dev/null"`. + +## Bedienung +Domain eintragen → Dauer wählen → **DNS-Ausnahme starten**. Bei Ablauf/Abbruch wird die Regel auf +beiden AGH automatisch zurückgenommen. Ungültige Domains werden HA- **und** CLI-seitig abgelehnt. + +## Dashboard (1. Draft, 2026-09-06) +Eigenes storage-mode Dashboard `url_path: dns-ausnahmen` (Sidebar, `require_admin: false`), +angelegt via `ha_config_set_dashboard`. Da es in HA-`.storage` lebt, hier die Repro-Quelle: +```json +{"views":[{"title":"DNS-Ausnahmen","path":"dns","type":"sections","icon":"mdi:dns","max_columns":2,"sections":[ + {"type":"grid","cards":[ + {"type":"heading","heading":"Domain temporaer freigeben","icon":"mdi:lock-open-variant"}, + {"type":"entities","entities":[{"entity":"input_text.dns_ausnahme_domain","name":"Domain"},{"entity":"input_select.dns_ausnahme_dauer","name":"Dauer"}]}, + {"type":"button","name":"DNS-Ausnahme starten","icon":"mdi:lock-open-check","tap_action":{"action":"perform-action","perform_action":"input_button.press","target":{"entity_id":"input_button.dns_ausnahme_starten"}},"grid_options":{"columns":"full"}}]}, + {"type":"grid","cards":[ + {"type":"heading","heading":"Status","icon":"mdi:information-outline"}, + {"type":"tile","entity":"timer.dns_ausnahme","name":"Restlaufzeit"}, + {"type":"tile","entity":"input_text.dns_ausnahme_aktiv","name":"Aktive Ausnahme"}, + {"type":"markdown","content":"**AGH-Resolver-Paar** — .36: {{ states('binary_sensor.adguard_bf1_status') }} / .37: {{ states('binary_sensor.adguard_bf2_status') }}"}]}, + {"type":"grid","cards":[ + {"type":"heading","heading":"VLAN-Filter-Policy (AGH-Gruppen)","heading_style":"subtitle"}, + {"type":"markdown","content":"MAIN/IOT/UNTRUSTED=an · INFRA/MEDIA/MGMT=aus · GUEST=an+SafeSearch/Parental · dns-bypass-exempt=aus"}]} +]}]} +``` +⚠️ Draft-Notiz: `binary_sensor.adguard_bf1_status` ist stale (hing am alten `.16`-Container, TP42) — +zeigt evtl. `unavailable`, obwohl `.36` läuft. Sensor-Quelle gehört bei Gelegenheit auf `.36` gefixt. + +## Verifiziert (2026-09-04) +Button → `@@||doubleclick.net^` auf `.36`+`.37`, Timer 15:00, `_aktiv` gesetzt; Timer-Abbruch → +Regel auf beiden entfernt, `_aktiv` geleert, `.36==.37`. diff --git a/docs/teilprojekte.md b/docs/teilprojekte.md index 335150a..5343b3d 100644 --- a/docs/teilprojekte.md +++ b/docs/teilprojekte.md @@ -324,6 +324,11 @@ liest, liest ihn als Erstes: er wird bei jeder Änderung mitgezogen, nicht nur d mit `.bf.`-Suffix + alte suffixlose parallel). DHCP liefert `.36`/`.37` als DNS in allen 7 VLANs (`domain=emsenhuber.net`), Firewall forward UDP+TCP/53 auf beide. Der Core-Resolver (`10.40.X.1`) bleibt als Bootstrap unverändert (DoH, `allow-remote-requests=yes`, `mdns-repeat-ifaces`). + 🆕 **TP23 Stufe 4+5 ✅ 2026-09-04:** `clients.persistent` = 7 VLAN-CIDR-Gruppen + `dns-bypass-exempt` + (Policy-Matrix, INFRA/MEDIA/MGMT `filtering=aus`), API-User `automation`, Sync-Tool + redigierte + Repo-SSoT unter `adguard/`. HA-Self-Service (Button → JIT-Domain-Freigabe, nativer Timer-Revert) live. + Offen nur **Stufe 6** (Bypass-Sperre, TP5). Werkzeuge/Doku: `adguard/{agh-sync,agh-exception}.py`, + `adguard/{README,ha-self-service}.md`. - **Ausgebaut / gelöscht:** ALLNET-`sw1` und Omada-`sw2` sind **physisch weg** (2026-08-08 bzw. 2026-07-23, in Nautobot *Decommissioned*); Omada-Controller + `omada-mcp` gelöscht (2026-07-16); FritzBox 6660 raus seit 2026-07-01 (Liwest-Tausch auf Arris). **VLAN1 ist leer** (`/ip arp` auf `bridge` = 0 Einträge, @@ -1451,7 +1456,23 @@ des Dimmers (= Lichtausfall in Diele UG/Wohnzimmer) wurde daraufhin **nicht** au - **Verwandt/Abgrenzung:** vw ist auch in **TP16** offen (CF-Access: `bypass`→WARP-geschützt) — **DB-HA ≠ Access**, getrennt behandeln. Reziprokes 2-Site-Muster analog **TP16**/**TP18**. - Abh.: **TP20** (Tier-1 Postgres muss stehen), **TP10** (WG-Transport für Phase-2), **TP18** (Backup-Schirm). Kein Blocker für TP21. -### TP23 — DNS-Policy & Self-Service-Ausnahmen ▸ ⏳ **STUFE 1 DURCH, STUFE 2 VORBEREITET (2026-08-18)** +### TP23 — DNS-Policy & Self-Service-Ausnahmen ▸ ⏳ **STUFE 1–5 DURCH (2026-09-04); offen nur Stufe 6 (Bypass-Sperre, in TP5)** +> 🟢 **Update 2026-09-04 — Stufe 4 + 5 gebaut, live verifiziert. Der `.16`-Text unten ist überholt** +> (Stufe 2 lebt seit 08-26 auf dem Paar `agh-bf1`/`agh-bf2` = `.36`/`.37`, LXC 110/111 — s. Ist-Stand-Block): +> - **Stufe 4 (CIDR-Substrat):** 7 VLAN-Gruppen + `dns-bypass-exempt` in `clients.persistent` auf beiden +> Instanzen; Policy-Matrix (INFRA/MEDIA/MGMT `filtering=aus`, GUEST safe-search+parental an). Neuer +> AGH-API-User `automation` (PW in `~/.secrets` → TP30). Sync-Werkzeug `adguard/agh-sync.py` +> (`check`/`push`), Repo-SSoT `adguard/AdGuardHome.yaml` mit **redigierten** Hashes. Invariante `.36==.37`. +> - **Stufe 5 (HA-Self-Service):** `agh-exception.py` (RMW-CLI, `allow-domain`/`unallow`/`unfilter`/`refilter`) +> auf HAOS deployt; `shell_command` + 5 Helper + Timer + 2 Automations (`ha-mcp`). Button → Domain für +> 15 min/1 h/4 h entsperren, nativer `timer` revertiert; RouterOS-`jit-dns-bypass`-Backstop erst mit Stufe 6. +> Doku: `adguard/README.md` + `adguard/ha-self-service.md`. End-to-End getestet (Start + Ablauf). +> - **Offen:** nur **Stufe 6** (DNS-Bypass-Sperre) — bewusst NICHT geschaltet; braucht die scharfe +> Firewall-Regel (via Nautobot GC) + die abgenommene Self-Service-Oberfläche (jetzt vorhanden). +> - **WARP-Managed-Network (Stufe 2 im alten Plan)** bleibt offen (TP41-nah). Der `.16`-basierte +> Abschnitt darunter ist die historische Fassung (2026-08-18), bewusst erhalten. + +**[Historische Fassung 2026-08-18 — `.16`-basiert, durch den Block oben überholt:]** - ✅ **Stufe 1 — DoH auf dem Core scharf** (`routeros/tp23-stufe1-doh-core.rsc`), schliesst zugleich **TP21.1 TODO 1**. `use-doh-server=https://px35ynlol3.cloudflare-gateway.com/dns-query`, **`verify-doh-cert=yes`**. `servers=1.1.1.1` bleibt als Bootstrap fuer den DoH-Hostnamen stehen.