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 <noreply@anthropic.com>
This commit is contained in:
@@ -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).
|
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"]
|
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():
|
def creds():
|
||||||
u = p = None
|
eu, ep = os.environ.get("AGH_AUTOMATION_USER"), os.environ.get("AGH_AUTOMATION_PASS")
|
||||||
for line in SECRETS.read_text().splitlines():
|
if eu and ep:
|
||||||
if line.startswith("AGH_AUTOMATION_USER="): u = line.split("=", 1)[1].strip()
|
return eu, ep
|
||||||
if line.startswith("AGH_AUTOMATION_PASS="): p = line.split("=", 1)[1].strip()
|
for f in CRED_FILES:
|
||||||
if not (u and p): sys.exit("AGH_AUTOMATION_USER/_PASS fehlen in ~/.secrets")
|
if f and (r := _from_file(f)):
|
||||||
return u, p
|
return r
|
||||||
|
sys.exit("AGH_AUTOMATION_USER/_PASS: weder Env noch eine Creds-Datei gefunden")
|
||||||
|
|
||||||
|
|
||||||
AUTH = "Basic " + base64.b64encode(":".join(creds()).encode()).decode()
|
AUTH = "Basic " + base64.b64encode(":".join(creds()).encode()).decode()
|
||||||
@@ -53,6 +74,9 @@ def rule(domain):
|
|||||||
|
|
||||||
|
|
||||||
def allow(domain, remove=False):
|
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)
|
r = rule(domain)
|
||||||
for ip in INST:
|
for ip in INST:
|
||||||
st = api(ip, "/control/filtering/status")
|
st = api(ip, "/control/filtering/status")
|
||||||
@@ -66,6 +90,8 @@ def allow(domain, remove=False):
|
|||||||
|
|
||||||
|
|
||||||
def set_filter(ip_addr, enabled):
|
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:
|
for ip in INST:
|
||||||
data = api(ip, "/control/clients")
|
data = api(ip, "/control/clients")
|
||||||
c = None
|
c = None
|
||||||
|
|||||||
63
adguard/ha-self-service.md
Normal file
63
adguard/ha-self-service.md
Normal file
@@ -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`.
|
||||||
@@ -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
|
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`)
|
(`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`).
|
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,
|
- **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
|
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,
|
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**.
|
- **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.
|
- 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
|
- ✅ **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`,
|
**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.
|
**`verify-doh-cert=yes`**. `servers=1.1.1.1` bleibt als Bootstrap fuer den DoH-Hostnamen stehen.
|
||||||
|
|||||||
Reference in New Issue
Block a user