TP23 Stufe 5 Redesign: pro-Person Self-Service (agh-exception.py + Bau-Spec)
Stufe 5 reopened — DAU-Requirements (pro Person, "zuletzt geblockt -> 1 Klick", temp+dauerhaft umschaltbar, scoped HA-Dashboard) nicht erfuellt → Stufe 6 gated. agh-exception.py: $client-scoped allow/unallow, list-exceptions, recent-blocked (selftest OK, reine Logik netzunabhaengig getestet). HA-Bau-Spec + Personen- Client-Modell dokumentiert. Bau/Abnahme on-network offen. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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 <person>` 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=<ip>|<ip>`
|
||||
(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 <d>` | `GET /control/filtering/status` → +`@@\|\|<d>^` → `POST /control/filtering/set_rules` |
|
||||
| Entsperrung zurück | `unallow-domain <d>` | dito, Regel entfernen |
|
||||
| Client entfiltern | `unfilter <ip>` | `GET /control/clients` → Objekt, `filtering_enabled=false` → `POST /control/clients/update` |
|
||||
| Domain **pro Person** entsperren | `allow-domain <d> --client <person>` | `GET /control/filtering/status` → +`@@\|\|<d>^$client='<person>'` → `POST /control/filtering/set_rules` |
|
||||
| Domain global entsperren (Admin) | `allow-domain <d>` | dito, ohne `$client` |
|
||||
| Entsperrung zurück | `unallow-domain <d> [--client <person>]` | dito, exakt dieselbe Regel entfernen |
|
||||
| „meine Ausnahmen" | `list-exceptions --client <person> --json` | `GET /control/filtering/status`, `@@`-Regeln parsen/filtern |
|
||||
| „zuletzt geblockt" | `recent-blocked --client <person> --json` | `GET /control/querylog?response_status=blocked`, client-seitig auf Geräte-IPs gefiltert |
|
||||
| Client entfiltern (grob) | `unfilter <ip>` | `GET /control/clients` → Objekt, `filtering_enabled=false` → `POST /control/clients/update` |
|
||||
| Client wieder filtern | `refilter <ip>` | 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.
|
||||
|
||||
|
||||
@@ -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 <domain> → user_rules += @@||domain^ (Domain für ALLE entsperren)
|
||||
unallow-domain <domain> → user_rules -= @@||domain^
|
||||
unfilter <ip> → Client (dessen ids <ip> enthält) filtering_enabled=false
|
||||
allow-domain <domain> [--client <name|ip[,ip]>] → user_rules += @@||domain^[$client=…]
|
||||
unallow-domain <domain> [--client <name|ip[,ip]>] → dieselbe Regel wieder entfernen
|
||||
list-exceptions [--client <name>] [--json] → alle @@-Regeln, nach Client gruppiert
|
||||
recent-blocked --client <name|ip[,ip]> [--limit N] [--json]
|
||||
→ zuletzt geblockte Domains DIESER Person
|
||||
unfilter <ip> → Client (dessen ids <ip> enthält) filtering_enabled=false (grob!)
|
||||
refilter <ip> → 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=<ip>|<ip>, 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<domain>[^\^]+)\^(?:\$client=(?P<client>.+))?$")
|
||||
def parse_allow_rule(rule):
|
||||
"""@@-Regel → {'domain':…, 'client': <str oder None>, '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 <name|ip[,ip]>")
|
||||
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__}")
|
||||
|
||||
@@ -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 <person>` 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=<ip>|<ip>` 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 <domain> --client <person> # Ausnahme setzen (temp: HA revertiert; perm: kein Timer)
|
||||
unallow-domain <domain> --client <person> # dieselbe Ausnahme entfernen
|
||||
list-exceptions --client <person> --json # „meine Ausnahmen" (für Übersicht + Entfernen)
|
||||
recent-blocked --client <person> --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_<slug>` | `recent-blocked --client <person> --json` → `json_attributes: items` (Liste {domain,count,last}); Scan-Intervall ~60 s |
|
||||
| command_line sensor | `sensor.dns_exceptions_<slug>` | `list-exceptions --client <person> --json` → aktive Ausnahmen der Person |
|
||||
| input_select | `input_select.dns_dauer_<slug>` | 1 Stunde / 4 Stunden / **dauerhaft** |
|
||||
| timer | `timer.dns_ausnahme_<slug>` | 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_<slug>` | nimmt `domain` als Feld → `agh_allow`; bei „dauerhaft"+Switch-aus → `todo`/Notification statt Freigabe; sonst Timer starten |
|
||||
| automation | `automation.dns_ablauf_<slug>` | `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_<slug>`
|
||||
Attribut `items`; jede Zeile hat eine `tap_action` → `script.dns_freigeben_<slug>` 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_<slug>` als Liste, je Eintrag Entfernen
|
||||
(`agh_unallow`).
|
||||
- **Karte 3 „Dauer":** `input_select.dns_dauer_<slug>` (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`.
|
||||
|
||||
Reference in New Issue
Block a user