2 Commits

Author SHA1 Message Date
7edf01b021 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>
2026-09-06 16:45:00 +02:00
f507c50623 TP23 Stufe 4: AGH CIDR-Gruppen+Policy auf .36/.37 + Sync/Exception-Tooling
- A0: API-User 'automation' auf agh-bf1/.36 + agh-bf2/.37 (PW in ~/.secrets, TP30-Merkposten)
- A: 8 clients.persistent (7 VLAN-CIDR-Gruppen + dns-bypass-exempt), Policy-Matrix
  (INFRA/MEDIA/MGMT filtering=aus, GUEST safe-search+parental an), live verifiziert
- B: agh-sync.py (check/push), Repo-SSoT AdGuardHome.yaml mit REDIGIERTEN Hashes
  (Alt-Hash-Exposure im Git entfernt); Invariante .36==.37 (AGH normalisiert beim Restart)
- C: agh-exception.py (allow-domain/unallow/unfilter/refilter/status) gegen beide
  Instanzen, end-to-end getestet; Contract in adguard/README.md
- Stufe 6 (Bypass-Sperre) NICHT geschaltet; kein DHCP-/Firewall-Write

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-04 21:19:50 +02:00
6 changed files with 613 additions and 63 deletions

View File

@@ -13,7 +13,9 @@ http:
session_ttl: 30d
users:
- name: admin
password: $2a$10$ue8PEedG4t2JRyT83XiGHeqIU4PFRzhn8rjDOZGYGt0sFC2qju2fi
password: '!on-device (siehe ~/.secrets + Gerät)'
- name: automation
password: '!on-device (siehe ~/.secrets + Gerät)'
auth_attempts: 5
block_auth_min: 15
http_proxy: ""
@@ -202,69 +204,9 @@ filtering:
- domain: gitea.emsenhuber.net
answer: 10.40.20.40
enabled: true
- domain: nautobot.emsenhuber.net
answer: 10.40.20.40
enabled: true
- domain: ds-bf.emsenhuber.net
answer: 10.40.20.40
enabled: true
- domain: ccu.emsenhuber.net
answer: 10.40.20.40
enabled: true
- domain: iobroker.emsenhuber.net
answer: 10.40.20.40
enabled: true
- domain: z2m25.emsenhuber.net
answer: 10.40.20.40
enabled: true
- domain: pve-mcp.emsenhuber.net
answer: 10.40.20.40
enabled: true
- domain: ha-mcp.emsenhuber.net
answer: 10.40.20.40
enabled: true
- domain: n8n-mcp.emsenhuber.net
answer: 10.40.20.40
enabled: true
- domain: gitea-mcp.emsenhuber.net
answer: 10.40.20.40
enabled: true
- domain: pve.emsenhuber.net
answer: 10.40.20.40
enabled: true
- domain: portainer.emsenhuber.net
answer: 10.40.20.40
enabled: true
- domain: pbs.emsenhuber.net
answer: 10.40.20.40
enabled: true
- domain: macmini.emsenhuber.net
answer: 10.40.20.167
enabled: true
- domain: mfc.emsenhuber.net
answer: 10.40.10.6
enabled: true
- domain: tv.emsenhuber.net
answer: 10.40.40.11
enabled: true
- domain: docker.emsenhuber.net
answer: 10.40.20.126
enabled: true
- domain: platform-pg.emsenhuber.net
answer: 10.40.20.20
enabled: true
- domain: core.emsenhuber.net
answer: 10.40.99.1
enabled: true
- domain: ap1.emsenhuber.net
answer: 10.40.99.11
enabled: true
- domain: ap2.emsenhuber.net
answer: 10.40.99.12
enabled: true
- domain: branch.emsenhuber.net
answer: 10.40.99.2
enabled: true
- domain: nas.bf.emsenhuber.net
answer: 10.40.20.10
enabled: true
@@ -316,7 +258,232 @@ clients:
rdns: true
dhcp: true
hosts: true
persistent: []
persistent:
- safe_search:
enabled: false
bing: false
duckduckgo: false
ecosia: false
google: false
pixabay: false
yandex: false
youtube: false
blocked_services:
schedule:
time_zone: Local
ids: []
name: dns-bypass-exempt
ids:
- 10.40.10.82
- 10.40.10.183
tags: []
upstreams: []
uid: 381d431a-1a43-41ea-adaa-2d67ada9aab0
upstreams_cache_size: 0
upstreams_cache_enabled: false
use_global_settings: false
filtering_enabled: false
parental_enabled: false
safebrowsing_enabled: false
use_global_blocked_services: true
ignore_querylog: false
ignore_statistics: false
- safe_search:
enabled: true
bing: false
duckduckgo: false
ecosia: false
google: false
pixabay: false
yandex: false
youtube: false
blocked_services:
schedule:
time_zone: Local
ids: []
name: grp-guest
ids:
- 10.40.90.0/24
tags: []
upstreams: []
uid: af70dd86-43a9-4165-8228-26c7b90db5e6
upstreams_cache_size: 0
upstreams_cache_enabled: false
use_global_settings: false
filtering_enabled: true
parental_enabled: true
safebrowsing_enabled: false
use_global_blocked_services: true
ignore_querylog: false
ignore_statistics: false
- safe_search:
enabled: false
bing: false
duckduckgo: false
ecosia: false
google: false
pixabay: false
yandex: false
youtube: false
blocked_services:
schedule:
time_zone: Local
ids: []
name: grp-infra
ids:
- 10.40.20.0/24
tags: []
upstreams: []
uid: 78b2da82-1946-41ee-b386-b04eaee49bc3
upstreams_cache_size: 0
upstreams_cache_enabled: false
use_global_settings: false
filtering_enabled: false
parental_enabled: false
safebrowsing_enabled: false
use_global_blocked_services: true
ignore_querylog: false
ignore_statistics: false
- safe_search:
enabled: false
bing: false
duckduckgo: false
ecosia: false
google: false
pixabay: false
yandex: false
youtube: false
blocked_services:
schedule:
time_zone: Local
ids: []
name: grp-iot
ids:
- 10.40.30.0/24
tags: []
upstreams: []
uid: 7a8ac674-4365-4b06-ad51-2516400903a1
upstreams_cache_size: 0
upstreams_cache_enabled: false
use_global_settings: true
filtering_enabled: true
parental_enabled: false
safebrowsing_enabled: false
use_global_blocked_services: true
ignore_querylog: false
ignore_statistics: false
- safe_search:
enabled: false
bing: false
duckduckgo: false
ecosia: false
google: false
pixabay: false
yandex: false
youtube: false
blocked_services:
schedule:
time_zone: Local
ids: []
name: grp-main
ids:
- 10.40.10.0/24
tags: []
upstreams: []
uid: 39955cc1-6ec2-4cf6-a757-233672b9dc3d
upstreams_cache_size: 0
upstreams_cache_enabled: false
use_global_settings: true
filtering_enabled: true
parental_enabled: false
safebrowsing_enabled: false
use_global_blocked_services: true
ignore_querylog: false
ignore_statistics: false
- safe_search:
enabled: false
bing: false
duckduckgo: false
ecosia: false
google: false
pixabay: false
yandex: false
youtube: false
blocked_services:
schedule:
time_zone: Local
ids: []
name: grp-media
ids:
- 10.40.40.0/24
tags: []
upstreams: []
uid: 44e4a1d7-d1b6-446c-a753-65e714ec93aa
upstreams_cache_size: 0
upstreams_cache_enabled: false
use_global_settings: false
filtering_enabled: false
parental_enabled: false
safebrowsing_enabled: false
use_global_blocked_services: true
ignore_querylog: false
ignore_statistics: false
- safe_search:
enabled: false
bing: false
duckduckgo: false
ecosia: false
google: false
pixabay: false
yandex: false
youtube: false
blocked_services:
schedule:
time_zone: Local
ids: []
name: grp-mgmt
ids:
- 10.40.99.0/24
tags: []
upstreams: []
uid: 4c5eeafb-f7f2-4fb1-ab87-fd2a2366d57a
upstreams_cache_size: 0
upstreams_cache_enabled: false
use_global_settings: false
filtering_enabled: false
parental_enabled: false
safebrowsing_enabled: false
use_global_blocked_services: true
ignore_querylog: false
ignore_statistics: false
- safe_search:
enabled: false
bing: false
duckduckgo: false
ecosia: false
google: false
pixabay: false
yandex: false
youtube: false
blocked_services:
schedule:
time_zone: Local
ids: []
name: grp-untrusted
ids:
- 10.40.50.0/24
tags: []
upstreams: []
uid: 7f182498-7b82-4580-b1b0-0a2d46c205ef
upstreams_cache_size: 0
upstreams_cache_enabled: false
use_global_settings: true
filtering_enabled: true
parental_enabled: false
safebrowsing_enabled: false
use_global_blocked_services: true
ignore_querylog: false
ignore_statistics: false
log:
enabled: true
file: ""

57
adguard/README.md Normal file
View File

@@ -0,0 +1,57 @@
# AdGuard Home — `agh-bf1` (.36) / `agh-bf2` (.37) · TP23 Stufe 2/4/5
Das AGH-**Paar** (pve LXC 110/111, `10.40.20.36` / `.37`) ist der Client-Resolver des BF-Netzes
(DHCP liefert beide in allen 7 VLANs). Upstream: DoH zum CF-Gateway; conditional
`[/bf.emsenhuber.net/] → 10.40.20.1`. **Kein natives HA** → Sync ist Betriebsaufwand.
## Dateien
| Datei | Zweck |
|---|---|
| `AdGuardHome.yaml` | **Repo-SSoT** der Config. **Passwort-Hashes REDIGIERT** (`'<redacted>'`) — keine Secrets im Git. Echte Hashes leben auf den Geräten; Klartext des API-Users in `~/.secrets`. |
| `agh-sync.py` | `check` (Drift `.36`↔`.37`) · `push` (SSoT auf beide, gestaffelter Restart, reinjiziert Hashes live). |
| `agh-exception.py` | Exception-Primitive (Stufe 5) gegen **beide** Instanzen. |
## Client-Policy (Stufe 4, `clients.persistent`)
Ein VLAN = eine CIDR-Gruppe. Alle nutzen denselben CF-Upstream-Filter; Divergenz = per-Client-Toggle.
| Gruppe | CIDR | filtering | safe_search | parental |
|---|---|---|---|---|
| grp-main | 10.40.10.0/24 | an (global) | – | – |
| grp-infra | 10.40.20.0/24 | **aus** | – | – |
| grp-iot | 10.40.30.0/24 | an | – | – |
| grp-media | 10.40.40.0/24 | **aus** | – | – |
| grp-untrusted | 10.40.50.0/24 | an | – | – |
| grp-guest | 10.40.90.0/24 | an | **on** | **on** |
| grp-mgmt | 10.40.99.0/24 | **aus** | – | – |
| dns-bypass-exempt | 10.40.10.183 + .82 | **aus** | – | – |
`filtering aus` bei INFRA/MEDIA/MGMT = bewusste Reduktion (Streaming/Server/Mgmt nicht brechen), per Gruppe rückholbar.
## 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`.
## Exception-Contract (Stufe 5 — was die HA-Schicht auslöst)
Auth: Basic, User `automation` (`~/.secrets` → `AGH_AUTOMATION_*`). **Immer auf `.36` UND `.37`.**
🔴 **Wichtig für D:** die beiden AGH-Endpunkte sind **read-modify-write**, kein simples Append —
`set_rules` **ersetzt die ganze Liste**, `clients/update` braucht das **volle Client-Objekt**.
Ein zustandsloses HA-`rest_command` reicht deshalb **nicht**; `agh-exception.py` ist die
Referenz-RMW-Implementierung, die D aufruft (Deploy-Weg auf HAOS in Etappe D zu entscheiden:
`shell_command`/`command_line` gegen die deployte CLI **oder** ein dünner Wrapper-Endpunkt).
| 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` |
| Client wieder filtern | `refilter <ip>` | dito, zurück auf global |
**Kein Timing in der CLI** — Ablauf/Revert macht der Aufrufer (HA-Timer) und, ab Stufe 6, der
RouterOS-Backstop.
## RouterOS-Backstop (Stufe 6, noch NICHT gebaut)
Analog `jit-admin` (`routeros/tp5-etappe4-input-mgmt.rsc`): Address-List `jit-dns-bypass` mit
`timeout` + Forward-Regel, die dieser Liste 53/853 nach außen erlaubt — der Fail-safe, falls HA
den Revert verpasst. Gebaut via **Nautobot GC** (kein Direkt-Write), erst wenn die Bypass-Sperre
scharf geschaltet wird.

129
adguard/agh-exception.py Executable file
View File

@@ -0,0 +1,129 @@
#!/usr/bin/env python3
"""
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
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.
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
refilter <ip> → denselben Client wieder filtern (use_global_settings=true)
status → user_rules + Client-Policy beider Instanzen
Auth: AGH_AUTOMATION_USER/_PASS aus ~/.secrets/credentials.env (nie in argv).
"""
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"]
# 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():
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()
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,
"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=8) as r:
raw = r.read()
return json.loads(raw) if raw else {}
except urllib.error.HTTPError as e:
sys.exit(f"{ip} {path} → HTTP {e.code}: {e.read().decode()[:200]}")
except Exception as e:
sys.exit(f"{ip} {path} → {e}")
def rule(domain):
return f"@@||{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")
rules = st.get("user_rules", [])
if remove:
rules = [x for x in rules if x != 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 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
for cand in data.get("clients", []):
if ip_addr in cand.get("ids", []):
c = cand; break
if not c:
sys.exit(f"{ip}: kein Client mit id exakt {ip_addr} — für Gruppen die CIDR angeben")
c = dict(c)
c["use_global_settings"] = enabled
c["filtering_enabled"] = enabled
api(ip, "/control/clients/update", "POST", {"name": c["name"], "data": c})
print(f"{ip}: {c['name']} filtering={'an' if enabled else 'AUS'}")
def status():
for ip in INST:
st = api(ip, "/control/filtering/status")
allows = [r for r in st.get("user_rules", []) if r.startswith("@@")]
cl = api(ip, "/control/clients").get("clients", [])
print(f"== {ip} == allow-rules={allows or '—'}")
for c in cl:
print(f" {c['name']:16} ids={c['ids']} global={c['use_global_settings']} filt={c['filtering_enabled']}")
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__}")

113
adguard/agh-sync.py Executable file
View File

@@ -0,0 +1,113 @@
#!/usr/bin/env python3
"""
agh-sync.py — AdGuard-Home-Paar agh-bf1 (.36) / agh-bf2 (.37) synchron halten.
TP23 Stufe 2/4: die beiden AGH-Instanzen (pve LXC 110/111) haben KEIN natives HA →
Config-Sync ist Betriebsaufwand. Dieses Werkzeug ist der Sync.
SSoT = adguard/AdGuardHome.yaml (Passwort-Hashes REDIGIERT — keine Secrets im Git).
Wahrheit im Datenpfad = die beiden Live-Instanzen; die Invariante ist .36 == .37
(AGH normalisiert die YAML beim Restart, daher NICHT gegen die Repo-Datei vergleichen,
sondern die beiden Live-Configs gegeneinander).
check pull .36 + .37, Passwörter redigiert diffen → meldet Drift (exit 1 bei Drift)
push Repo-SSoT nehmen, echte users-Hashes aus .36 (live) reinjizieren, validieren,
auf beide pushen, GESTAFFELT neu starten (110 → prüfen → 111), .36==.37 + API prüfen
Kein Direkt-`sshpass -p` (Leak). SSH-Key zu root@pve vorausgesetzt.
"""
import subprocess, sys, tempfile, base64, os, re, pathlib, urllib.request
PVE = "root@10.40.20.100"
INST = {"110": "10.40.20.36", "111": "10.40.20.37"} # ct-id -> ip
PRIMARY = "110" # Reihenfolge: primary zuerst
SVC = "AdGuardHome.service"
CFG = "/opt/AdGuardHome/AdGuardHome.yaml"
REPO = pathlib.Path(__file__).resolve().parent
SSOT = REPO / "AdGuardHome.yaml"
PW_RE = re.compile(r"^( password: ).*$")
def sh(*args, inp=None):
return subprocess.run(args, input=inp, capture_output=True, text=True)
def pull(ct):
r = sh("ssh", "-o", "ConnectTimeout=8", PVE, f"pct exec {ct} -- cat {CFG}")
if r.returncode:
sys.exit(f"pull {ct} fehlgeschlagen: {r.stderr.strip()}")
return r.stdout
def redact(text):
return "\n".join(PW_RE.sub(r"\1'<redacted>'", l) for l in text.splitlines())
def live_users_hashes(ct):
"""echte password-Zeilen (in Reihenfolge) aus der Live-Config von ct."""
out, inusers = [], False
for l in pull(ct).splitlines():
if l.startswith("users:"): inusers = True; continue
if inusers and l and not l.startswith((" ", "\t")): break
if inusers:
m = PW_RE.match(l)
if m: out.append(l)
return out
def cmd_check():
a, b = redact(pull("110")), redact(pull("111"))
if a == b:
print("✅ .36 == .37 (synchron)")
return 0
print("🔴 DRIFT zwischen .36 und .37:")
import difflib
for line in difflib.unified_diff(a.splitlines(), b.splitlines(),
".36", ".37", lineterm=""):
print(line)
return 1
def cmd_push():
if not SSOT.exists():
sys.exit(f"SSoT fehlt: {SSOT}")
ssot = SSOT.read_text()
# echte Hashes aus dem primary reinjizieren (SSoT trägt nur Redaktions-Platzhalter)
hashes = live_users_hashes(PRIMARY)
red = [i for i, l in enumerate(ssot.splitlines()) if PW_RE.match(l)]
if len(red) != len(hashes):
sys.exit(f"users-Anzahl SSoT({len(red)}) != live({len(hashes)}) — manuell prüfen")
lines = ssot.splitlines()
for idx, real in zip(red, hashes):
lines[idx] = real
merged = "\n".join(lines) + "\n"
# validieren
try:
import yaml
doc = yaml.safe_load(merged)
assert doc["users"] and doc["clients"]["persistent"]
except Exception as e:
sys.exit(f"merge invalide: {e}")
b64 = base64.b64encode(merged.encode()).decode()
order = [PRIMARY] + [c for c in INST if c != PRIMARY]
for ct in order:
print(f"→ push + restart {ct} ({INST[ct]}) …")
r = sh("ssh", "-o", "ConnectTimeout=10", PVE,
f"pct exec {ct} -- sh -c 'base64 -d > {CFG}.new && "
f"cp -a {CFG} {CFG}.presync && mv {CFG}.new {CFG} && "
f"systemctl restart {SVC} && sleep 2 && systemctl is-active {SVC}'",
inp=b64)
if r.returncode or "active" not in r.stdout:
sys.exit(f"push {ct} fehlgeschlagen: {r.stdout} {r.stderr}")
print(f" {ct} active")
print(cmd_check_msg())
return cmd_check()
def cmd_check_msg():
return "— Verifikation:"
if __name__ == "__main__":
cmd = sys.argv[1] if len(sys.argv) > 1 else "check"
sys.exit({"check": cmd_check, "push": cmd_push}.get(cmd, cmd_check)())

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

View File

@@ -324,6 +324,11 @@ liest, liest ihn als Erstes: er wird bei jeder Änderung mitgezogen, nicht nur d
mit `.bf.`-Suffix + alte suffixlose parallel). DHCP liefert `.36`/`.37` als DNS in allen 7 VLANs
(`domain=emsenhuber.net`), Firewall forward UDP+TCP/53 auf beide. Der Core-Resolver (`10.40.X.1`)
bleibt als Bootstrap unverändert (DoH, `allow-remote-requests=yes`, `mdns-repeat-ifaces`).
🆕 **TP23 Stufe 4+5 ✅ 2026-09-04:** `clients.persistent` = 7 VLAN-CIDR-Gruppen + `dns-bypass-exempt`
(Policy-Matrix, INFRA/MEDIA/MGMT `filtering=aus`), API-User `automation`, Sync-Tool + redigierte
Repo-SSoT unter `adguard/`. HA-Self-Service (Button → JIT-Domain-Freigabe, nativer Timer-Revert) live.
Offen nur **Stufe 6** (Bypass-Sperre, TP5). Werkzeuge/Doku: `adguard/{agh-sync,agh-exception}.py`,
`adguard/{README,ha-self-service}.md`.
- **Ausgebaut / gelöscht:** ALLNET-`sw1` und Omada-`sw2` sind **physisch weg** (2026-08-08 bzw. 2026-07-23,
in Nautobot *Decommissioned*); Omada-Controller + `omada-mcp` gelöscht (2026-07-16); FritzBox 6660 raus
seit 2026-07-01 (Liwest-Tausch auf Arris). **VLAN1 ist leer** (`/ip arp` auf `bridge` = 0 Einträge,
@@ -1451,7 +1456,23 @@ des Dimmers (= Lichtausfall in Diele UG/Wohnzimmer) wurde daraufhin **nicht** au
- **Verwandt/Abgrenzung:** vw ist auch in **TP16** offen (CF-Access: `bypass`→WARP-geschützt) — **DB-HA ≠ Access**, getrennt behandeln. Reziprokes 2-Site-Muster analog **TP16**/**TP18**.
- Abh.: **TP20** (Tier-1 Postgres muss stehen), **TP10** (WG-Transport für Phase-2), **TP18** (Backup-Schirm). Kein Blocker für TP21.
### TP23 — DNS-Policy & Self-Service-Ausnahmen ▸ ⏳ **STUFE 1 DURCH, STUFE 2 VORBEREITET (2026-08-18)**
### TP23 — DNS-Policy & Self-Service-Ausnahmen ▸ ⏳ **STUFE 1–5 DURCH (2026-09-04); offen nur Stufe 6 (Bypass-Sperre, in TP5)**
> 🟢 **Update 2026-09-04 — Stufe 4 + 5 gebaut, live verifiziert. Der `.16`-Text unten ist überholt**
> (Stufe 2 lebt seit 08-26 auf dem Paar `agh-bf1`/`agh-bf2` = `.36`/`.37`, LXC 110/111 — s. Ist-Stand-Block):
> - **Stufe 4 (CIDR-Substrat):** 7 VLAN-Gruppen + `dns-bypass-exempt` in `clients.persistent` auf beiden
> Instanzen; Policy-Matrix (INFRA/MEDIA/MGMT `filtering=aus`, GUEST safe-search+parental an). Neuer
> AGH-API-User `automation` (PW in `~/.secrets` → TP30). Sync-Werkzeug `adguard/agh-sync.py`
> (`check`/`push`), Repo-SSoT `adguard/AdGuardHome.yaml` mit **redigierten** Hashes. Invariante `.36==.37`.
> - **Stufe 5 (HA-Self-Service):** `agh-exception.py` (RMW-CLI, `allow-domain`/`unallow`/`unfilter`/`refilter`)
> auf HAOS deployt; `shell_command` + 5 Helper + Timer + 2 Automations (`ha-mcp`). Button → Domain für
> 15 min/1 h/4 h entsperren, nativer `timer` revertiert; RouterOS-`jit-dns-bypass`-Backstop erst mit Stufe 6.
> Doku: `adguard/README.md` + `adguard/ha-self-service.md`. End-to-End getestet (Start + Ablauf).
> - **Offen:** nur **Stufe 6** (DNS-Bypass-Sperre) — bewusst NICHT geschaltet; braucht die scharfe
> Firewall-Regel (via Nautobot GC) + die abgenommene Self-Service-Oberfläche (jetzt vorhanden).
> - **WARP-Managed-Network (Stufe 2 im alten Plan)** bleibt offen (TP41-nah). Der `.16`-basierte
> Abschnitt darunter ist die historische Fassung (2026-08-18), bewusst erhalten.
**[Historische Fassung 2026-08-18 — `.16`-basiert, durch den Block oben überholt:]**
- ✅ **Stufe 1 — DoH auf dem Core scharf** (`routeros/tp23-stufe1-doh-core.rsc`), schliesst zugleich
**TP21.1 TODO 1**. `use-doh-server=https://px35ynlol3.cloudflare-gateway.com/dns-query`,
**`verify-doh-cert=yes`**. `servers=1.1.1.1` bleibt als Bootstrap fuer den DoH-Hostnamen stehen.