diff --git a/adguard/README.md b/adguard/README.md index 0652dcd..bf5bde2 100644 --- a/adguard/README.md +++ b/adguard/README.md @@ -27,6 +27,19 @@ Ein VLAN = eine CIDR-Gruppe. Alle nutzen denselben CF-Upstream-Filter; Divergenz `filtering aus` bei INFRA/MEDIA/MGMT = bewusste Reduktion (Streaming/Server/Mgmt nicht brechen), per Gruppe rückholbar. +## Personen-Clients (Stufe 5 Redesign 2026-09-12) — SSoT für Person→Gerät +Zusätzlich zu den VLAN-CIDR-Gruppen bekommt **jede Person einen eigenen AGH-Client** +(`/control/clients`): `name` = Personenname, `ids` = **alle** Geräte-IPs der Person. Das ist der +**einzige** Ort, an dem Person→Gerät gepflegt wird (kein zweites Mapping-File). Damit: +- wirken Self-Service-Ausnahmen **pro Person**: `@@||domain^$client='Personenname'` statt global; +- filtert `recent-blocked --client ` das Query-Log auf **ihre** Geräte; +- bleiben Ausnahmen stabil bei Gerätewechsel (Name statt IP). + +🔴 **On-network verifizieren, bevor Stufe 6 scharf geht:** dass AGH `$client=` auf **Allow**-Regeln +(`@@`) in der laufenden Version greift. Fällt der Test negativ aus → Fallback `$client=|` +(die Geräte-IPs); `agh-exception.py scoped_rule()` kann beides. Die CIDR-Gruppen (Policy-Grenze) +bleiben unberührt — Personen-Clients tragen **nur** die Ausnahmen. + ## Sync-Invariante AGH **schreibt die YAML beim Restart um** (normalisiert) → **nicht** gegen die Repo-Datei diffen, sondern **`.36` == `.37`**. Genau das prüft `agh-sync.py check`. @@ -42,11 +55,16 @@ Referenz-RMW-Implementierung, die D aufruft (Deploy-Weg auf HAOS in Etappe D zu | Aktion | CLI | AGH-API (RMW) | |---|---|---| -| Domain global entsperren | `allow-domain ` | `GET /control/filtering/status` → +`@@\|\|^` → `POST /control/filtering/set_rules` | -| Entsperrung zurück | `unallow-domain ` | dito, Regel entfernen | -| Client entfiltern | `unfilter ` | `GET /control/clients` → Objekt, `filtering_enabled=false` → `POST /control/clients/update` | +| Domain **pro Person** entsperren | `allow-domain --client ` | `GET /control/filtering/status` → +`@@\|\|^$client=''` → `POST /control/filtering/set_rules` | +| Domain global entsperren (Admin) | `allow-domain ` | dito, ohne `$client` | +| Entsperrung zurück | `unallow-domain [--client ]` | dito, exakt dieselbe Regel entfernen | +| „meine Ausnahmen" | `list-exceptions --client --json` | `GET /control/filtering/status`, `@@`-Regeln parsen/filtern | +| „zuletzt geblockt" | `recent-blocked --client --json` | `GET /control/querylog?response_status=blocked`, client-seitig auf Geräte-IPs gefiltert | +| Client entfiltern (grob) | `unfilter ` | `GET /control/clients` → Objekt, `filtering_enabled=false` → `POST /control/clients/update` | | Client wieder filtern | `refilter ` | dito, zurück auf global | +Reine Regel-/Filter-Logik ist netzunabhängig getestet: `python3 agh-exception.py selftest`. + **Kein Timing in der CLI** — Ablauf/Revert macht der Aufrufer (HA-Timer) und, ab Stufe 6, der RouterOS-Backstop. diff --git a/adguard/agh-exception.py b/adguard/agh-exception.py index dbb73ad..05a9f2a 100755 --- a/adguard/agh-exception.py +++ b/adguard/agh-exception.py @@ -2,22 +2,37 @@ """ agh-exception.py — TP23 Stufe 5 Exception-Primitive gegen das AGH-Paar (.36 + .37). -Von der HA-Self-Service-Schicht (rest_command) ODER von Hand aufrufbar. Wirkt IMMER auf +Von der HA-Self-Service-Schicht (shell_command) ODER von Hand aufrufbar. Wirkt IMMER auf BEIDE Instanzen (kein Sync im Datenpfad). KEIN eigenes Timing — Ablauf/Revert macht der -Aufrufer (HA-Timer) bzw. der RouterOS-Backstop (Stufe 6). Diese CLI ist reines add/remove. +Aufrufer (HA-Timer) bzw. der RouterOS-Backstop (Stufe 6). Diese CLI ist reines add/remove/read. - allow-domain → user_rules += @@||domain^ (Domain für ALLE entsperren) - unallow-domain → user_rules -= @@||domain^ - unfilter → Client (dessen ids enthält) filtering_enabled=false + allow-domain [--client ] → user_rules += @@||domain^[$client=…] + unallow-domain [--client ] → dieselbe Regel wieder entfernen + list-exceptions [--client ] [--json] → alle @@-Regeln, nach Client gruppiert + recent-blocked --client [--limit N] [--json] + → zuletzt geblockte Domains DIESER Person + unfilter → Client (dessen ids enthält) filtering_enabled=false (grob!) refilter → denselben Client wieder filtern (use_global_settings=true) status → user_rules + Client-Policy beider Instanzen +--client: + * ein AGH-Client-NAME (empfohlen, stabil bei Gerätewechsel) → Regel bekommt $client='Name', + IPs werden für recent-blocked aus /control/clients (ids) aufgelöst. + * ODER eine/mehrere IP/CIDR, komma-getrennt → $client=|, recent-blocked filtert darauf. + ohne --client bei allow/unallow = GLOBAL (für alle) — nur für Admin-Zwecke. + +🔴 ANNAHME, on-network zu verifizieren, BEVOR Stufe 6 scharf geht: dass AGH $client= auf + ALLOW-Regeln (@@) in der laufenden Version wirkt. Die Regelkonstruktion steht isoliert in + scoped_rule()/parse_allow_rule() — falls die Syntax abweicht, nur dort anpassen. + Auth: AGH_AUTOMATION_USER/_PASS aus ~/.secrets/credentials.env (nie in argv). """ -import sys, os, json, base64, urllib.request, urllib.error, pathlib, re +import sys, os, json, base64, urllib.request, urllib.error, urllib.parse, pathlib, re, ipaddress, argparse 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}$") +CIDR_RE = re.compile(r"^\d{1,3}(\.\d{1,3}){3}/\d{1,2}$") +# Ein Client-„spec" ist IP-artig, wenn JEDES komma-Glied IP oder CIDR ist; sonst = Name. INST = ["10.40.20.36", "10.40.20.37"] # Creds-Quellen in Reihenfolge: erste, die User+Pass liefert, gewinnt. @@ -50,14 +65,20 @@ def creds(): sys.exit("AGH_AUTOMATION_USER/_PASS: weder Env noch eine Creds-Datei gefunden") -AUTH = "Basic " + base64.b64encode(":".join(creds()).encode()).decode() +_AUTH = None +def auth_header(): + # lazy: erst beim ersten echten API-Call — so bleibt das Modul ohne Creds importier-/testbar. + global _AUTH + if _AUTH is None: + _AUTH = "Basic " + base64.b64encode(":".join(creds()).encode()).decode() + return _AUTH def api(ip, path, method="GET", body=None): url = f"http://{ip}{path}" data = json.dumps(body).encode() if body is not None else None req = urllib.request.Request(url, data=data, method=method, - headers={"Authorization": AUTH, + headers={"Authorization": auth_header(), "Content-Type": "application/json"}) try: with urllib.request.urlopen(req, timeout=8) as r: @@ -69,26 +90,129 @@ def api(ip, path, method="GET", body=None): sys.exit(f"{ip} {path} → {e}") -def rule(domain): - return f"@@||{domain}^" +# --------------------------------------------------------------------------- +# Reine Regel-Logik (netzunabhängig, self-testbar). Bei AGH-Syntax-Drift NUR hier anfassen. +# --------------------------------------------------------------------------- +def is_ip_spec(spec): + """True, wenn spec eine (Komma-)Liste aus lauter IPs/CIDRs ist (→ IP-spec), sonst Name.""" + parts = [s.strip() for s in spec.split(",") if s.strip()] + return bool(parts) and all(IP_RE.match(p) or CIDR_RE.match(p) for p in parts) -def allow(domain, remove=False): +def client_modifier(spec): + """$client-Modifier-Wert für eine Regel. IP-spec → ip|ip ; Name → 'Name' (quoted).""" + if is_ip_spec(spec): + return "|".join(s.strip() for s in spec.split(",") if s.strip()) + return "'" + spec.replace("'", "") + "'" + + +def scoped_rule(domain, client=None): + """@@||domain^ bzw. @@||domain^$client=… (client = Name oder ip[,ip]).""" + base = f"@@||{domain}^" + return base if not client else f"{base}$client={client_modifier(client)}" + + +_ALLOW_RE = re.compile(r"^@@\|\|(?P[^\^]+)\^(?:\$client=(?P.+))?$") +def parse_allow_rule(rule): + """@@-Regel → {'domain':…, 'client': , 'raw': rule} oder None.""" + m = _ALLOW_RE.match(rule.strip()) + if not m: + return None + return {"domain": m.group("domain"), "client": m.group("client"), "raw": rule.strip()} + + +def ip_in_specs(ip, specs): + """Ist ip (aus dem Query-Log) von einer der specs (IP oder CIDR) erfasst?""" + try: + addr = ipaddress.ip_address(ip) + except ValueError: + return False + for s in specs: + s = s.strip() + try: + if "/" in s: + if addr in ipaddress.ip_network(s, strict=False): + return True + elif ip == s: + return True + except ValueError: + continue + return False + + +# --------------------------------------------------------------------------- +def resolve_client_ips(spec): + """spec → Liste von IP/CIDR-strings. IP-spec direkt; Name → ids des AGH-Clients (von .36).""" + if is_ip_spec(spec): + return [s.strip() for s in spec.split(",") if s.strip()] + data = api(INST[0], "/control/clients") + for c in data.get("clients", []): + if c.get("name") == spec: + return list(c.get("ids", [])) + sys.exit(f"kein AGH-Client mit name={spec!r} (für IP-spec Komma-Liste aus IP/CIDR angeben)") + + +def allow(domain, client=None, remove=False): domain = domain.strip().lower() if not DOMAIN_RE.match(domain): sys.exit(f"ungültige Domain: {domain!r}") - r = rule(domain) + r = scoped_rule(domain, client) for ip in INST: st = api(ip, "/control/filtering/status") rules = st.get("user_rules", []) if remove: - rules = [x for x in rules if x != r] + rules = [x for x in rules if x.strip() != r] elif r not in rules: rules = rules + [r] api(ip, "/control/filtering/set_rules", "POST", {"rules": rules}) print(f"{ip}: {'entfernt' if remove else 'gesetzt'} {r} (user_rules={len(rules)})") +def list_exceptions(client=None, as_json=False): + st = api(INST[0], "/control/filtering/status") # .36 == .37 (Invariante, via agh-sync geprüft) + items = [] + for raw in st.get("user_rules", []): + p = parse_allow_rule(raw) + if not p: + continue + if client is not None and (p["client"] or "") != client_modifier(client): + continue + items.append(p) + if as_json: + print(json.dumps(items, ensure_ascii=False)) + return + if not items: + print("— keine Ausnahmen —"); return + for p in items: + who = p["client"] or "GLOBAL" + print(f" {p['domain']:40} client={who}") + + +def recent_blocked(client, limit=20, as_json=False): + specs = resolve_client_ips(client) + q = urllib.parse.urlencode({"response_status": "blocked", "limit": max(limit * 8, 100)}) + data = api(INST[0], f"/control/querylog?{q}") + seen = {} # domain -> {count, last} + for e in data.get("data", []): + c = e.get("client", "") + if not ip_in_specs(c, specs): + continue + dom = (e.get("question", {}) or {}).get("name", "").rstrip(".").lower() + if not dom: + continue + rec = seen.setdefault(dom, {"domain": dom, "count": 0, "last": e.get("time", "")}) + rec["count"] += 1 + if e.get("time", "") > rec["last"]: + rec["last"] = e.get("time", "") + out = sorted(seen.values(), key=lambda r: r["last"], reverse=True)[:limit] + if as_json: + print(json.dumps(out, ensure_ascii=False)); return + if not out: + print("— nichts geblockt —"); return + for r in out: + print(f" {r['domain']:40} x{r['count']:<4} {r['last']}") + + def set_filter(ip_addr, enabled): if not IP_RE.match(ip_addr): sys.exit(f"ungültige IP: {ip_addr!r}") @@ -117,13 +241,47 @@ def status(): print(f" {c['name']:16} ids={c['ids']} global={c['use_global_settings']} filt={c['filtering_enabled']}") +def _selftest(): + """Netzunabhängiger Test der reinen Regel-Logik.""" + assert is_ip_spec("10.40.10.82") and is_ip_spec("10.40.10.82,10.40.10.183") + assert is_ip_spec("10.40.10.0/24") and not is_ip_spec("christoph") + assert scoped_rule("x.com") == "@@||x.com^" + assert scoped_rule("x.com", "10.40.10.82,10.40.10.183") == "@@||x.com^$client=10.40.10.82|10.40.10.183" + assert scoped_rule("x.com", "christoph") == "@@||x.com^$client='christoph'" + p = parse_allow_rule("@@||x.com^$client='christoph'") + assert p and p["domain"] == "x.com" and p["client"] == "'christoph'" + p2 = parse_allow_rule("@@||y.com^") + assert p2 and p2["client"] is None + assert parse_allow_rule("||block.com^") is None + assert ip_in_specs("10.40.10.82", ["10.40.10.82"]) is True + assert ip_in_specs("10.40.10.5", ["10.40.10.0/24"]) is True + assert ip_in_specs("10.40.20.5", ["10.40.10.0/24"]) is False + # round-trip: eine scoped Regel wird von list-exceptions --client wieder getroffen + assert (parse_allow_rule(scoped_rule("z.com", "christoph"))["client"] + == client_modifier("christoph")) + print("selftest OK") + + if __name__ == "__main__": - a = sys.argv[1:] - if not a: sys.exit(__doc__) - cmd = a[0] - if cmd == "allow-domain": allow(a[1]) - elif cmd == "unallow-domain": allow(a[1], remove=True) - elif cmd == "unfilter": set_filter(a[1], False) - elif cmd == "refilter": set_filter(a[1], True) - elif cmd == "status": status() - else: sys.exit(f"unbekannt: {cmd}\n{__doc__}") + ap = argparse.ArgumentParser(add_help=False) + ap.add_argument("cmd", nargs="?") + ap.add_argument("arg", nargs="?") + ap.add_argument("--client") + ap.add_argument("--permanent", action="store_true") # semantisch = kein Timer; wirkt in HA, nicht hier + ap.add_argument("--limit", type=int, default=20) + ap.add_argument("--json", action="store_true") + a = ap.parse_args() + + if not a.cmd: + sys.exit(__doc__) + if a.cmd == "allow-domain": allow(a.arg, a.client) + elif a.cmd == "unallow-domain": allow(a.arg, a.client, remove=True) + elif a.cmd == "list-exceptions": list_exceptions(a.client, a.json) + elif a.cmd == "recent-blocked": + if not a.client: sys.exit("recent-blocked braucht --client ") + recent_blocked(a.client, a.limit, a.json) + elif a.cmd == "unfilter": set_filter(a.arg, False) + elif a.cmd == "refilter": set_filter(a.arg, True) + elif a.cmd == "status": status() + elif a.cmd == "selftest": _selftest() + else: sys.exit(f"unbekannt: {a.cmd}\n{__doc__}") diff --git a/adguard/ha-self-service.md b/adguard/ha-self-service.md index ff4d3d7..74e20b7 100644 --- a/adguard/ha-self-service.md +++ b/adguard/ha-self-service.md @@ -1,63 +1,98 @@ -# TP23 Stufe 5 — HA-Self-Service für DNS-Ausnahmen +# TP23 Stufe 5 (Redesign 2026-09-12) — DAU-tauglicher, personenbezogener DNS-Self-Service -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`. +> 🔴 **Status: Bau-Spec, noch nicht deployt.** Die alte Fassung (single-domain, temp-only, +> HA-Neustart bei neuer Domain) war „funktional ein kastriertes Pi-hole" (User-Befund 2026-09-12) +> und ist ersetzt. Bau + Abnahme laufen **on-network** (HAOS `10.40.20.105`, AGH `.36/.37`) — beim +> Anlegen den **home-assistant-best-practices-Skill** konsultieren und via **ha-mcp** in `.storage` +> anlegen (native Helper, `entity_id` statt `device_id`). Diese Datei ist die Repro-/SSoT-Quelle. -**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`. +## Ziel (User-Requirements 2026-09-12) +Ein **einfaches, klar verständliches** End-User-UI zum Entsperren: +- **alles was den Nutzer betrifft, nichts** von anderen / Admin / Power-User; +- Policy nur sichtbar, wo sie für den einfachen Nutzer Relevanz hat. -## Bausteine (alle in HA .storage, via ha-mcp angelegt — keine YAML außer shell_command) -| Typ | entity_id | Zweck | +| # | Requirement | Umsetzung | |---|---|---| -| 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 | +| 1 | **Pro Person** (Login), Sicht über alle Geräte | HA-User = Person; Person → AGH-Client (Name), dessen `ids` = ihre Geräte-IPs | +| 2 | **„zuletzt geblockt" → 1 Klick** | `recent-blocked --client ` aus dem AGH-Query-Log, auf die Geräte der Person gefiltert | +| 3 | **temporär UND dauerhaft**, Modus umschaltbar | Dauer-Select inkl. „dauerhaft"; `input_boolean` self-permanent ⇄ Anfrage. **Start: self-permanent** | +| 4 | **HA-Dashboard, scoped** pro Person | ein View je Person, nur für ihre HA-User-ID sichtbar | -## `configuration.yaml` (der einzige YAML-Teil, Backup `.bak-tp23-*`) +## Identitätsmodell — AGH-Client je Person ist die SSoT +- Pro Person **ein AGH-Client** (`/control/clients`), `name` = Personenname, `ids` = **alle** Geräte-IPs + der Person (die per DHCP faktisch statisch sind). Damit ist Person→Gerät **an einem Ort** gepflegt + (kein zweites Mapping-File), und Ausnahmen sind stabil bei Gerätewechsel. +- Ausnahme-Regel = `@@||domain^$client='Personenname'` (statt globalem `@@||domain^`). + 🔴 **Vor Scharfschalten on-network verifizieren, dass AGH `$client=` auf `@@`-Regeln wirkt** + (s. `agh-exception.py` Kopf). Fällt der Test negativ aus: Fallback = `$client=|` mit den + Geräte-IPs statt dem Namen (Code kann beides, `scoped_rule()`). +- Die bestehenden **VLAN-CIDR-Gruppen** bleiben die Policy-Grenze (Filter an/aus je VLAN); + Personen-Clients sind spezifischer und tragen nur die *Ausnahmen*. + +## CLI (deployt nach `/config/agh-exception.py`, wirkt auf `.36`+`.37`) +``` +allow-domain --client # Ausnahme setzen (temp: HA revertiert; perm: kein Timer) +unallow-domain --client # dieselbe Ausnahme entfernen +list-exceptions --client --json # „meine Ausnahmen" (für Übersicht + Entfernen) +recent-blocked --client --json # „zuletzt geblockt" (nur Geräte der Person) +``` +`--client` nimmt einen AGH-Client-Namen (→ IPs via `/control/clients`) ODER eine Komma-IP/CIDR-Liste. + +## `configuration.yaml` — `shell_command` (einziger YAML-Teil, Backup `.bak-tp23-*`) +Domain **und** Client sind Parameter → **kein HA-Neustart mehr** bei neuer Domain (das war der alte +DAU-Killer: pro Domain ein eigener `shell_command`). ```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 }}'" + agh_allow: "python3 /config/agh-exception.py allow-domain '{{ domain }}' --client '{{ client }}'" + agh_unallow: "python3 /config/agh-exception.py unallow-domain '{{ domain }}' --client '{{ client }}'" ``` -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"`. +## Helfer / Entities (via ha-mcp in .storage) +Pro Person (Beispiel `christoph`) — bei mehreren Personen je einmal, Suffix = Personen-Slug: +| Typ | entity_id | Zweck | +|---|---|---| +| command_line sensor | `sensor.dns_recent_blocked_` | `recent-blocked --client --json` → `json_attributes: items` (Liste {domain,count,last}); Scan-Intervall ~60 s | +| command_line sensor | `sensor.dns_exceptions_` | `list-exceptions --client --json` → aktive Ausnahmen der Person | +| input_select | `input_select.dns_dauer_` | 1 Stunde / 4 Stunden / **dauerhaft** | +| timer | `timer.dns_ausnahme_` | JIT-Countdown je temporärer Freigabe (restore=false) | +| input_boolean | `input_boolean.dns_permanent_ohne_anfrage` | **global**: an = „dauerhaft" wirkt sofort (Start); aus = erzeugt Admin-Genehmigung | +| script | `script.dns_freigeben_` | nimmt `domain` als Feld → `agh_allow`; bei „dauerhaft"+Switch-aus → `todo`/Notification statt Freigabe; sonst Timer starten | +| automation | `automation.dns_ablauf_` | `timer.finished`/`.cancelled` → `agh_unallow`, Aufräumen | -## 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 — ein scoped View je Person +- Eigener storage-Dashboard-View, **nur für die HA-User-ID der Person sichtbar** (`visible:` mit + der user-id; `require_admin:false`). Kleiner Haushalt → ein View je Person ist praktikabel und + umgeht das „Template kennt den aktuellen Betrachter nicht"-Problem. +- **Karte 1 „Zuletzt geblockt (antippen zum Freigeben)":** rendert `sensor.dns_recent_blocked_` + Attribut `items`; jede Zeile hat eine `tap_action` → `script.dns_freigeben_` mit `domain` + der Zeile + aktueller `input_select`-Dauer. (Dynamische Per-Zeilen-tap_action braucht i.d.R. + `auto-entities` + `template`/`button-card` **oder** ein kleines Custom-List-Template — **beim Bau + on-network entscheiden**, das ist der Teil, der Live-Verifikation braucht.) +- **Karte 2 „Meine Ausnahmen":** `sensor.dns_exceptions_` als Liste, je Eintrag Entfernen + (`agh_unallow`). +- **Karte 3 „Dauer":** `input_select.dns_dauer_` (inkl. „dauerhaft"). +- **Kein** globaler Status, keine fremden Clients, keine Policy-Interna. Nur wenn relevant ein + kurzer Hinweis („durch den Familien-Filter blockiert"). -## 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. +## Genehmigungs-Variante (Modus-Switch aus) +`input_boolean.dns_permanent_ohne_anfrage = off` → „dauerhaft"-Klick legt statt der Freigabe einen +Eintrag in einer **`todo`-Liste** „DNS-Freigaben zu prüfen" an (+ Notification an Christoph). Beim +Abhaken führt eine Admin-Automation `agh_allow` ohne Timer aus. Beide Varianten sind gebaut; der +Switch entscheidet zur Laufzeit. -## 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`. +## Alt-Reste, die beim Bau wegfallen/fixen +- Single-domain-Flow (`input_text.dns_ausnahme_domain/_aktiv`, `input_button…_starten`, + `automation…_starten/_ablauf`) und der `agh_allow_domain`/`_unallow_domain`-**pro-Domain**-Zwang. +- Statuskachel-Sensor `binary_sensor.adguard_bf1_status` hing am toten `.16` → auf `.36`/`.37` neu. + +## Abnahme (on-network, = neue echte Stufe-6-Vorbedingung) +Mit einem **echten Nicht-Admin-Login**: geblockte Seite → erscheint in *ihrer* Fläche → 1 Klick +temporär (revertiert nach Ablauf) **und** 1 Klick dauerhaft (bleibt) → wirkt **nur** für diese +Person (Gegentest: anderer Client sieht die Domain weiter geblockt). `.36 == .37` +(`agh-sync.py check`). Modus-Switch beide Wege einmal durchgespielt. + +## Verweise +- CLI: `adguard/agh-exception.py` (Subcommands + `selftest`). Deploy-Weg unverändert (base64→scp, + s. unten). Creds `/config/agh-exception.secrets` (chmod 600). +- Redeploy: `base64 < adguard/agh-exception.py | ssh admin@10.40.20.105 "base64 -d | sudo tee /config/agh-exception.py >/dev/null"`. +- Personen-Client-Modell: `adguard/README.md`. Kontext/Reihenfolge: TP23 in `docs/teilprojekte.md`. diff --git a/docs/teilprojekte.md b/docs/teilprojekte.md index edd9135..4206695 100644 --- a/docs/teilprojekte.md +++ b/docs/teilprojekte.md @@ -974,7 +974,28 @@ 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–5 DURCH (2026-09-04); offen nur Stufe 6 (Bypass-Sperre, in TP5)** +### TP23 — DNS-Policy & Self-Service-Ausnahmen ▸ ⏳ **STUFE 5 WIEDER OFFEN (Redesign 2026-09-12): Self-Service deckt die DAU-Requirements nicht ab → Stufe 6 bleibt dahinter gated** +> 🔴 **Redesign 2026-09-12 — Stufe 5 reopened.** Bei „Stufe 6 angehen" fiel auf: die 09-04-Stufe-5 +> (single-domain, temp-only 15m/1h/4h, **eine** Ausnahme gleichzeitig, **HA-Neustart bei neuer +> Domain**, `allow-domain` global statt pro Person) deckt die Nutzer-Requirements **nicht** ab +> („funktional ein kastriertes Pi-hole", User). Die harte Reihenfolge-Regel „erst der Ersatz, dann +> die Sperre" ist damit real **nicht** erfüllt → **Stufe 6 darf nicht laufen**, bis der neue +> Self-Service nachweislich trägt. +> - **Neue Requirements (User 2026-09-12):** (1) **pro Person** (Login), Sicht über alle Geräte; +> (2) **„zuletzt geblockt" → 1 Klick**; (3) temporär **und** dauerhaft, Modus umschaltbar +> (self-permanent ⇄ Anfrage, Start self-permanent); (4) **scoped HA-Dashboard** je Person — +> nichts von anderen/Admin/Power-User, AGH-Web-UI bleibt reines Admin-Werkzeug. +> - **Design/Bau:** Personen-Client je AGH (SSoT Person→Gerät), Ausnahmen via `$client=`-Modifier, +> `agh-exception.py` um scoped `allow/unallow` + `list-exceptions` + `recent-blocked` erweitert +> (reine Logik netzunabhängig getestet, `selftest` ✅). HA-Dashboard/Sensoren/Modus-Switch als +> Bau-Spec in [`adguard/ha-self-service.md`](../adguard/ha-self-service.md) (on-network zu bauen). +> - 🔴 **Load-bearing, on-network zu prüfen:** wirkt AGH `$client=` auf **Allow**-Regeln (`@@`)? +> Fallback = IP-Liste statt Client-Name (Code kann beides). Plan: +> `~/.claude/plans/tp23-stufe-6-angehen-soft-sunrise.md`. +> - **Neue echte Stufe-6-Vorbedingung:** End-to-End-Abnahme mit echtem Nicht-Admin-Flow. +> - **Stufe-6-Runbook-Korrektur:** Core `10.40.X.1` in der Allowlist überdenken (Client-Gateway → +> DoH direkt zu CF = interner Bypass der AGH-Personen-Policy). + > 🟢 **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