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

214 lines
8.1 KiB
Markdown

# 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):
```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/<cid>-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.<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 |