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:
2026-09-06 16:45:00 +02:00
parent f507c50623
commit 7edf01b021
3 changed files with 119 additions and 9 deletions

View File

@@ -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

View 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`.