# DoH ClientID Handshake — Architektur ## Flow-Diagramm ``` Mac/iPhone (DoH-Profil: dns.rebreak.org/dns-query/) | | HTTPS GET /dns-query/?dns=... v nginx (rebreak-mdm, 178.105.101.137) | location ^~ /dns-query -> proxy_pass http://127.0.0.1:3000 | Path wird UNVERÄNDERT weitergegeben (kein Stripping) v AdGuard Home (127.0.0.1:3000) | Parst Pfad-Segment nach /dns-query/ als ClientID | Schreibt QueryLog-Eintrag mit Feld "CP": "" v querylog.json (NDJSON, /opt/adguardhome/data/querylog.json) | | inotify-ähnliches Polling (1s), rotation-safe v watcher.py (systemd-service: rebreak-handshake-watcher) | - liest neue Zeilen | - extrahiert CP-Feld (ClientID = dnsToken) | - in-memory cooldown: 1 POST pro Token pro 60s v POST https://staging.rebreak.org/api/devices/protected/handshake Header: x-handshake-secret: Body: { "token": "" } | v Backend (Nitro/Nuxt, rebreak-server) | - prüft shared secret | - lookup ProtectedDevice by dnsToken | - pending → status=active, installedAt=NOW [statusChanged=true] | - active → lastDnsQueryAt=NOW [statusChanged=false] | - revoked → 200 { ignored: true } [silent] v Supabase Postgres + Realtime | v App-UI (useProtectedDevicesRealtime hook) Zeigt "Schutz aktiv" ohne manuelles Reload ``` ## Warum ClientID-Pfad und nicht Query-Parameter AdGuard Home hat drei Methoden um einen Client zu identifizieren: 1. IP-Adresse — funktioniert nicht wenn alle Clients hinter derselben Hetzner-IP sitzen 2. `?clientid=` Query-Parameter — nicht in AdGuard's nativer ClientID-Implementierung 3. Pfad-Segment `/dns-query/` — nativ unterstützt, landet im QueryLog-Feld `CP` Pfad-Methode ist die einzige, die keine AdGuard-Konfiguration per Client erfordert und trotzdem im QueryLog identifizierbar ist. Das Gerät bettet seinen dnsToken einfach in die DoH-URL ein — das MDM/DNS-Profil auf dem Gerät enthält die vollständige URL: `https://dns.rebreak.org/dns-query/` ## nginx — Diff vs. alter Konfiguration Alte Config (exact-match): ```nginx location = /dns-query { proxy_pass http://127.0.0.1:3000/dns-query; ... } ``` Probleme mit exact-match: - `/dns-query/abc123` matched NICHT → nginx gibt 404 - AdGuard bekommt niemals Requests mit ClientID - CP-Feld in querylog bleibt immer leer Neue Config (prefix-match, path unverändert): ```nginx location ^~ /dns-query { proxy_pass http://127.0.0.1:3000; ... } ``` Wichtig: - `^~` (longest-match-prefix) verhindert dass nachfolgende regex locations greifen - `proxy_pass http://127.0.0.1:3000;` OHNE trailing slash und OHNE Pfad-Suffix → nginx hängt `$request_uri` vollständig an. Also `/dns-query/abc123` landet als `/dns-query/abc123` bei AdGuard. Kein Stripping, kein Rewrite. - Wäre `proxy_pass http://127.0.0.1:3000/dns-query;`, würde nginx den matched Prefix ERSETZEN → CID würde abgeschnitten. Das ist falsch. Vollständige Config: `ops/nginx/dns.rebreak.org.conf` ## AdGuard QueryLog Format AdGuard Home schreibt `/opt/adguardhome/data/querylog.json` als NDJSON (newline-delimited JSON). Relevantes Feld: ```json { "T": "2026-05-15T12:34:56.789Z", "QH": "example.com", "QT": "A", "CP": "abc123def456abc1", "Result": { "IsFiltered": false }, "Elapsed": 1234567, "IP": "127.0.0.1" } ``` - `CP` = ClientID (nur gesetzt wenn via /dns-query/-Pfad) - `QH` = Query-Hostname (geblockter Domain → `IsFiltered: true`) - `T` = Timestamp ISO8601 Die querylog.json wird rotiert sobald sie eine konfigurierte Größe überschreitet (Standard: 30MB oder nach 24h). AdGuard renamed die aktuelle Datei und legt eine neue an. watcher.py erkennt das via Inode-Vergleich und re-öffnet. ## Secrets HANDSHAKE_SECRET kommt ausschließlich aus Infisical (nie in Code/Git). Infisical-Key: `HANDSHAKE_SECRET` Infisical-Projekt: rebreak (Project-ID 14b11b35-ef59-4b8a-a16b-398f0cc3ad93) Environments: staging (für staging.rebreak.org), production (für rebreak.org) Auf dem Server landet das Secret in `/etc/rebreak-handshake-watcher.env` (chmod 600). Diese Datei wird beim Deploy geschrieben — niemals committen. Wert generieren (User-Aktion, einmalig): ```bash openssl rand -hex 16 ``` Dann in Infisical eintragen unter HANDSHAKE_SECRET. ## Deploy-Schritte (Reihenfolge) 1. User generiert HANDSHAKE_SECRET via `openssl rand -hex 16` + trägt in Infisical ein 2. nginx-Config deployen (ops/nginx/dns.rebreak.org.conf → /etc/nginx/sites-available/) 3. nginx -t prüfen, dann reload (User-GO nötig) 4. Verify: `curl -v https://dns.rebreak.org/dns-query/TESTTOKEN -H "accept: application/dns-json" "?name=example.com&type=A"` → kein 404 5. AdGuard QueryLog prüfen: CP-Feld muss "TESTTOKEN" enthalten 6. watcher.py deployen: /opt/rebreak-handshake-watcher/watcher.py 7. EnvironmentFile schreiben: /etc/rebreak-handshake-watcher.env (chmod 600) 8. systemd-unit deployen: /etc/systemd/system/rebreak-handshake-watcher.service 9. systemctl daemon-reload + systemctl enable + systemctl start (User-GO nötig) 10. Verify: journalctl -u rebreak-handshake-watcher -f → sollte starten und tailen ## Datei-Übersicht | Datei | Ziel auf Server | Beschreibung | |-------|----------------|--------------| | ops/nginx/dns.rebreak.org.conf | /etc/nginx/sites-available/dns.rebreak.org | nginx vhost mit prefix-match | | ops/mdm/adguard-handshake-watcher/watcher.py | /opt/rebreak-handshake-watcher/watcher.py | Python watcher | | ops/mdm/adguard-handshake-watcher/rebreak-handshake-watcher.service | /etc/systemd/system/rebreak-handshake-watcher.service | systemd unit | ## Verify-Checklist vor nginx -s reload Siehe Abschnitt "Risiken + Verify-Checklist" in diesem Dokument. ## Risiken + Verify-Checklist ### Vor `nginx -t` + `systemctl reload nginx` Reihenfolge einhalten. Keinen Schritt überspringen. **1. Snapshot der aktuellen Config erstellen (rollback-Basis)** ```bash ssh rebreak-mdm "cp /etc/nginx/sites-available/dns.rebreak.org /etc/nginx/sites-available/dns.rebreak.org.bak.$(date +%Y%m%d_%H%M%S)" ``` Existiert die Config noch nicht → kein Snapshot nötig, nur neue Datei anlegen. **2. AdGuard-Port verifizieren** Die Config nimmt an, dass AdGuard DoH auf `127.0.0.1:3000` läuft. Vor dem Deploy tatsächlichen Port prüfen: ```bash ssh rebreak-mdm "docker ps | grep adguard" ssh rebreak-mdm "ss -tlnp | grep 3000" # oder ssh rebreak-mdm "docker exec adguardhome cat /opt/adguardhome/conf/AdGuardHome.yaml | grep -A10 'bind_port\|https_port\|dns:'" ``` Port in dns.rebreak.org.conf anpassen falls abweichend. **3. TLS-Cert-Pfad prüfen** ```bash ssh rebreak-mdm "ls /etc/letsencrypt/live/dns.rebreak.org/" ``` Falls kein Cert für dns.rebreak.org existiert: ```bash ssh rebreak-mdm "certbot --nginx -d dns.rebreak.org --dry-run" # erst dry-run, dann ohne --dry-run wenn ok ``` Rate-Limit: maximal 5 Cert-Requests pro Domain pro Woche. **4. nginx -t Dry-Run** ```bash ssh rebreak-mdm "nginx -t" ``` Muss `syntax is ok` + `test is successful` ausgeben. Bei Fehler: Config korrigieren, nicht fortfahren. **5. Rollback-Plan** Falls nach reload DoH-Anfragen failen (DNS bricht für enrolled Geräte!): ```bash ssh rebreak-mdm "cp /etc/nginx/sites-available/dns.rebreak.org.bak. /etc/nginx/sites-available/dns.rebreak.org && systemctl reload nginx" ``` ### Risiken | Risiko | Auswirkung | Mitigation | |--------|-----------|------------| | Falscher AdGuard-Port | nginx gibt 502, alle DoH-Queries failen | Port vor Deploy verifizieren (Schritt 2) | | TLS-Cert fehlt für dns.rebreak.org | nginx startet nicht | Cert vor Deploy ausstellen (Schritt 3) | | Pfad-Stripping durch falsche proxy_pass-Syntax | CP bleibt leer, Handshake kommt nie | Aktuelle Config nutzt `proxy_pass http://...;` ohne Suffix — korrekt | | querylog-Feld CP heißt anders (Version-abhängig) | watcher erkennt ClientIDs nicht | Nach Deploy Testquery machen + `grep CP querylog.json` | | HANDSHAKE_SECRET in git | Credential-Leak | Secret kommt aus Infisical, EnvironmentFile ist .gitignored | | watcher crasht bei malformed JSON | einzelne Zeile wird übersprungen | watcher hat try/except um json.loads, kein crash |