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
214 lines
8.1 KiB
Markdown
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 |
|