# 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 |
