rebreak-monorepo/ops/mdm/DOH_CLIENTID_HANDSHAKE.md
chahinebrini db7875fb34 feat(ops/mdm): AdGuard ClientID handshake — nginx + watcher
End-to-end DoH-to-backend wiring for Mac auto-activation:

  Mac → dns.rebreak.org/dns-query/<token> → nginx → AdGuard
  → querylog.json (CP field) → watcher.py → POST /handshake → backend

- ops/nginx/dns.rebreak.org.conf: vhost with `location ^~ /dns-query`
  prefix-match (not exact). proxy_pass without trailing slash preserves
  the full path so AdGuard parses the ClientID natively.
- watcher.py: NDJSON tail with inode-based rotation safety, per-token
  60s in-memory cooldown, urllib (no external deps), graceful 401/404/5xx
- rebreak-handshake-watcher.service: systemd unit, EnvironmentFile with
  chmod 600 (HANDSHAKE_SECRET never in git), NoNewPrivileges + PrivateTmp
- DOH_CLIENTID_HANDSHAKE.md: architecture + flow diagram + risk table
- RUNBOOK.md: status/logs/restart commands + deploy ordering

Not yet deployed. Verify-checklist before `nginx -s reload`:
  1. confirm AdGuard DoH port (config assumes 127.0.0.1:3000)
  2. confirm TLS cert exists for dns.rebreak.org
  3. snapshot current nginx config
  4. `nginx -t` dry-run
  5. functional curl + grep CP in querylog before starting watcher
2026-05-15 22:41:38 +02:00

8.1 KiB

DoH ClientID Handshake — Architektur

Flow-Diagramm

Mac/iPhone (DoH-Profil: dns.rebreak.org/dns-query/<dnsToken>)
  |
  | HTTPS GET /dns-query/<dnsToken>?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": "<dnsToken>"
  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: <HANDSHAKE_SECRET>
  Body:   { "token": "<dnsToken>" }
  |
  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=<token> Query-Parameter — nicht in AdGuard's nativer ClientID-Implementierung
  3. Pfad-Segment /dns-query/<clientid> — 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/<dnsToken>

nginx — Diff vs. alter Konfiguration

Alte Config (exact-match):

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):

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:

{
  "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):

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)

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:

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

ssh rebreak-mdm "ls /etc/letsencrypt/live/dns.rebreak.org/"

Falls kein Cert für dns.rebreak.org existiert:

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

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!):

ssh rebreak-mdm "cp /etc/nginx/sites-available/dns.rebreak.org.bak.<DATUM> /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