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.
|
`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
|
## Sync-Invariante
|
||||||
AGH **schreibt die YAML beim Restart um** (normalisiert) → **nicht** gegen die Repo-Datei diffen,
|
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`.
|
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) |
|
| Aktion | CLI | AGH-API (RMW) |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Domain global entsperren | `allow-domain <d>` | `GET /control/filtering/status` → +`@@\|\|<d>^` → `POST /control/filtering/set_rules` |
|
| Domain **pro Person** entsperren | `allow-domain <d> --client <person>` | `GET /control/filtering/status` → +`@@\|\|<d>^$client='<person>'` → `POST /control/filtering/set_rules` |
|
||||||
| Entsperrung zurück | `unallow-domain <d>` | dito, Regel entfernen |
|
| Domain global entsperren (Admin) | `allow-domain <d>` | dito, ohne `$client` |
|
||||||
| Client entfiltern | `unfilter <ip>` | `GET /control/clients` → Objekt, `filtering_enabled=false` → `POST /control/clients/update` |
|
| 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 |
|
| 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
|
**Kein Timing in der CLI** — Ablauf/Revert macht der Aufrufer (HA-Timer) und, ab Stufe 6, der
|
||||||
RouterOS-Backstop.
|
RouterOS-Backstop.
|
||||||
|
|
||||||
|
|||||||
@@ -2,22 +2,37 @@
|
|||||||
"""
|
"""
|
||||||
agh-exception.py — TP23 Stufe 5 Exception-Primitive gegen das AGH-Paar (.36 + .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
|
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)
|
allow-domain <domain> [--client <name|ip[,ip]>] → user_rules += @@||domain^[$client=…]
|
||||||
unallow-domain <domain> → user_rules -= @@||domain^
|
unallow-domain <domain> [--client <name|ip[,ip]>] → dieselbe Regel wieder entfernen
|
||||||
unfilter <ip> → Client (dessen ids <ip> enthält) filtering_enabled=false
|
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)
|
refilter <ip> → denselben Client wieder filtern (use_global_settings=true)
|
||||||
status → user_rules + Client-Policy beider Instanzen
|
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).
|
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)
|
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}$")
|
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"]
|
INST = ["10.40.20.36", "10.40.20.37"]
|
||||||
# Creds-Quellen in Reihenfolge: erste, die User+Pass liefert, gewinnt.
|
# 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")
|
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):
|
def api(ip, path, method="GET", body=None):
|
||||||
url = f"http://{ip}{path}"
|
url = f"http://{ip}{path}"
|
||||||
data = json.dumps(body).encode() if body is not None else None
|
data = json.dumps(body).encode() if body is not None else None
|
||||||
req = urllib.request.Request(url, data=data, method=method,
|
req = urllib.request.Request(url, data=data, method=method,
|
||||||
headers={"Authorization": AUTH,
|
headers={"Authorization": auth_header(),
|
||||||
"Content-Type": "application/json"})
|
"Content-Type": "application/json"})
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(req, timeout=8) as r:
|
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}")
|
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()
|
domain = domain.strip().lower()
|
||||||
if not DOMAIN_RE.match(domain):
|
if not DOMAIN_RE.match(domain):
|
||||||
sys.exit(f"ungültige Domain: {domain!r}")
|
sys.exit(f"ungültige Domain: {domain!r}")
|
||||||
r = rule(domain)
|
r = scoped_rule(domain, client)
|
||||||
for ip in INST:
|
for ip in INST:
|
||||||
st = api(ip, "/control/filtering/status")
|
st = api(ip, "/control/filtering/status")
|
||||||
rules = st.get("user_rules", [])
|
rules = st.get("user_rules", [])
|
||||||
if remove:
|
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:
|
elif r not in rules:
|
||||||
rules = rules + [r]
|
rules = rules + [r]
|
||||||
api(ip, "/control/filtering/set_rules", "POST", {"rules": rules})
|
api(ip, "/control/filtering/set_rules", "POST", {"rules": rules})
|
||||||
print(f"{ip}: {'entfernt' if remove else 'gesetzt'} {r} (user_rules={len(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):
|
def set_filter(ip_addr, enabled):
|
||||||
if not IP_RE.match(ip_addr):
|
if not IP_RE.match(ip_addr):
|
||||||
sys.exit(f"ungültige IP: {ip_addr!r}")
|
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']}")
|
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__":
|
if __name__ == "__main__":
|
||||||
a = sys.argv[1:]
|
ap = argparse.ArgumentParser(add_help=False)
|
||||||
if not a: sys.exit(__doc__)
|
ap.add_argument("cmd", nargs="?")
|
||||||
cmd = a[0]
|
ap.add_argument("arg", nargs="?")
|
||||||
if cmd == "allow-domain": allow(a[1])
|
ap.add_argument("--client")
|
||||||
elif cmd == "unallow-domain": allow(a[1], remove=True)
|
ap.add_argument("--permanent", action="store_true") # semantisch = kein Timer; wirkt in HA, nicht hier
|
||||||
elif cmd == "unfilter": set_filter(a[1], False)
|
ap.add_argument("--limit", type=int, default=20)
|
||||||
elif cmd == "refilter": set_filter(a[1], True)
|
ap.add_argument("--json", action="store_true")
|
||||||
elif cmd == "status": status()
|
a = ap.parse_args()
|
||||||
else: sys.exit(f"unbekannt: {cmd}\n{__doc__}")
|
|
||||||
|
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
|
> 🔴 **Status: Bau-Spec, noch nicht deployt.** Die alte Fassung (single-domain, temp-only,
|
||||||
temporär (JIT) über das AGH-Paar `.36`/`.37` zu entsperren. Ablauf/Revert nativ per `timer`.
|
> 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
|
## Ziel (User-Requirements 2026-09-12)
|
||||||
ganze Liste). HA ruft deshalb die deployte RMW-CLI `agh-exception.py` per `shell_command`.
|
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)
|
| # | Requirement | Umsetzung |
|
||||||
| Typ | entity_id | Zweck |
|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| input_text | `input_text.dns_ausnahme_domain` | einzugebende Domain |
|
| 1 | **Pro Person** (Login), Sicht über alle Geräte | HA-User = Person; Person → AGH-Client (Name), dessen `ids` = ihre Geräte-IPs |
|
||||||
| input_text | `input_text.dns_ausnahme_aktiv` | aktuell aktive Ausnahme (für leak-freien Revert; init `""`) |
|
| 2 | **„zuletzt geblockt" → 1 Klick** | `recent-blocked --client <person>` aus dem AGH-Query-Log, auf die Geräte der Person gefiltert |
|
||||||
| input_select | `input_select.dns_ausnahme_dauer` | 15 Minuten / 1 Stunde / 4 Stunden |
|
| 3 | **temporär UND dauerhaft**, Modus umschaltbar | Dauer-Select inkl. „dauerhaft"; `input_boolean` self-permanent ⇄ Anfrage. **Start: self-permanent** |
|
||||||
| timer | `timer.dns_ausnahme` | JIT-Countdown (restore=false) |
|
| 4 | **HA-Dashboard, scoped** pro Person | ein View je Person, nur für ihre HA-User-ID sichtbar |
|
||||||
| 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-*`)
|
## 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
|
```yaml
|
||||||
shell_command:
|
shell_command:
|
||||||
agh_allow_domain: "python3 /config/agh-exception.py allow-domain '{{ domain }}'"
|
agh_allow: "python3 /config/agh-exception.py allow-domain '{{ domain }}' --client '{{ client }}'"
|
||||||
agh_unallow_domain: "python3 /config/agh-exception.py unallow-domain '{{ domain }}'"
|
agh_unallow: "python3 /config/agh-exception.py unallow-domain '{{ domain }}' --client '{{ client }}'"
|
||||||
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)
|
## Helfer / Entities (via ha-mcp in .storage)
|
||||||
- `/config/agh-exception.py` — Kopie von `adguard/agh-exception.py` (liest Creds via `/config/agh-exception.secrets`).
|
Pro Person (Beispiel `christoph`) — bei mehreren Personen je einmal, Suffix = Personen-Slug:
|
||||||
- `/config/agh-exception.secrets` (chmod 600, root) — `AGH_AUTOMATION_USER`/`_PASS`.
|
| Typ | entity_id | Zweck |
|
||||||
- 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"`.
|
|---|---|---|
|
||||||
|
| 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
|
## Dashboard — ein scoped View je Person
|
||||||
Domain eintragen → Dauer wählen → **DNS-Ausnahme starten**. Bei Ablauf/Abbruch wird die Regel auf
|
- Eigener storage-Dashboard-View, **nur für die HA-User-ID der Person sichtbar** (`visible:` mit
|
||||||
beiden AGH automatisch zurückgenommen. Ungültige Domains werden HA- **und** CLI-seitig abgelehnt.
|
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)
|
## Genehmigungs-Variante (Modus-Switch aus)
|
||||||
Eigenes storage-mode Dashboard `url_path: dns-ausnahmen` (Sidebar, `require_admin: false`),
|
`input_boolean.dns_permanent_ohne_anfrage = off` → „dauerhaft"-Klick legt statt der Freigabe einen
|
||||||
angelegt via `ha_config_set_dashboard`. Da es in HA-`.storage` lebt, hier die Repro-Quelle:
|
Eintrag in einer **`todo`-Liste** „DNS-Freigaben zu prüfen" an (+ Notification an Christoph). Beim
|
||||||
```json
|
Abhaken führt eine Admin-Automation `agh_allow` ohne Timer aus. Beide Varianten sind gebaut; der
|
||||||
{"views":[{"title":"DNS-Ausnahmen","path":"dns","type":"sections","icon":"mdi:dns","max_columns":2,"sections":[
|
Switch entscheidet zur Laufzeit.
|
||||||
{"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)
|
## Alt-Reste, die beim Bau wegfallen/fixen
|
||||||
Button → `@@||doubleclick.net^` auf `.36`+`.37`, Timer 15:00, `_aktiv` gesetzt; Timer-Abbruch →
|
- Single-domain-Flow (`input_text.dns_ausnahme_domain/_aktiv`, `input_button…_starten`,
|
||||||
Regel auf beiden entfernt, `_aktiv` geleert, `.36==.37`.
|
`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`.
|
||||||
|
|||||||
@@ -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**.
|
- **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–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**
|
> 🟢 **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 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
|
> - **Stufe 4 (CIDR-Substrat):** 7 VLAN-Gruppen + `dns-bypass-exempt` in `clients.persistent` auf beiden
|
||||||
|
|||||||
Reference in New Issue
Block a user