Domains & DNS
Überblick
Peers im GateControl-VPN sollen einander und die Administration mit Namen erreichen können, nicht nur mit IPs. Ein Admin tippt nas1.domaincaster.lan statt 10.8.0.8, ein RDP-Client nutzt die registrierte FQDN statt einer Tunnel-IP, und Desktop-Clients können den eigenen OS-Hostnamen automatisch melden. Dahinter steckt keine dynamische DNS-Zone im Sinne von nsupdate, sondern eine einfache Hosts-File-Pipeline: GateControl schreibt /data/dns/peers.hosts, dnsmasq lädt sie bei SIGHUP, fertig.
Die interessanten Probleme sind nicht die Mechanik, sondern die Policy: Was passiert, wenn ein Admin und der Desktop-Agent denselben Peer unterschiedlich benennen wollen? Was, wenn zwei Agents gleichzeitig denselben Namen reklamieren? Wie verhindert man, dass ein Backup-Restore die aktuellen Hostnames überbügelt? Diese Policies sind im Peers-Service implementiert und werden hier erklärt.
Das Feature ist hinter dem Lizenz-Flag internal_dns gated. Ohne Lizenz bleibt die Spalte peers.hostname leer und die Endpoints antworten mit 403.
Zone und Records
Jeder Peer mit Hostname landet in der Zone <hostname>.<config.dns.domain>. Die Default-Domain kann per GC_DNS_DOMAIN überschrieben werden; üblich ist etwas wie domaincaster.lan oder gc.internal.
Statische Records
src/routes/api/system.js (GET /api/v1/system/dns/records) liefert diese drei zusätzlich:
gateway.<domain> → 10.8.0.1
server.<domain> → 10.8.0.1
gc-server.<domain> → 10.8.0.1
Alle zeigen auf die Server-seitige Tunnel-IP (config.wireguard.gatewayIp). Diese drei Namen sind im RFC-1123-Validator als reserviert eingetragen und können nicht als Peer-Hostname vergeben werden.
Dynamische Records
Pro Peer:
<ip> <hostname>.<domain> <hostname>
Die kurze Form ist für Clients, die mit search domain=… im resolv.conf laufen und keinen FQDN tippen wollen.
Datenmodell
peers (
...
hostname TEXT, -- lowercase, RFC-1123, max 63 chars
hostname_source TEXT, -- 'admin' | 'agent' | 'stale' | NULL
hostname_reported_at TEXT -- ISO8601 des letzten Schreibs
)
CREATE UNIQUE INDEX idx_peers_hostname_nocase
ON peers(hostname COLLATE NOCASE) WHERE hostname IS NOT NULL;
Die drei Quellen
| Source | Bedeutung | Priorität |
|---|---|---|
admin |
Explizit im Admin-UI oder via PATCH /api/v1/peers/:id/hostname gesetzt |
Höchste — nichts überschreibt das |
agent |
Desktop-Client oder Gateway-Companion hat den OS-Hostname gemeldet | Kann nur von admin überschrieben werden |
stale |
Nach Backup-Restore: alte agent-Werte, bis Agent sich wieder meldet | Wird von agent und admin überschrieben |
Sticky-Admin-Policy: Sobald ein Admin einen Hostname setzt, werden alle Agent-Reports für diesen Peer stumm ignoriert.
Validierung
normalizeHostname(input)
Strippt Whitespace, lowercased, prüft Nicht-Leer und <= 63 Zeichen. Wirft bei Verstoss. Keine Policy-Checks (Reserved-Liste, Uniqueness) — das sind getrennte Layer.
strictHostnameAssert(hostname)
Zweite Verteidigungslinie, wird an der API-Boundary UND am Renderer aufgerufen. Prüft:
- Nicht-leer,
<= 63 Zeichen. - Regex
/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/— RFC-1123, kein führender/abschliessender Bindestrich, keine Uppercase. - Nicht in
RESERVED_HOSTNAMES(lowercase-Set):localhost, local, host, broadcasthost, gateway, server, gc-server, admin, root, router, dns, vpn, api, auth, mail, ns, ns1, ns2 - Pro-Byte-Check: Control-Chars (
<0x21),#(dnsmasq-Kommentarzeichen),>0x7ewerden explizit abgelehnt. Das wiederholt zwar die Regex-Garantie, ist aber bewusst redundant: Falls die Regex jemals gelockert wird, schliesst der Byte-Check weiterhin Injektionen aus.
Der Renderer ruft strictHostnameAssert vor jedem Write in die Hosts-Datei. Wenn ein Bug in der API je einen malformed Wert in die DB schiebt, wird er hier gestoppt und der Peer einfach übersprungen (mit Warn-Log), statt ihn als potenziell injizierte Zeile in die dnsmasq-Config zu lassen.
Uniqueness — reserveUniqueHostname
Zwei Peers dürfen nicht denselben Hostnamen haben (case-insensitive). Bei Kollision wird -2, -3, … angehängt, bis frei. Die ganze Sequenz läuft in einer db.transaction:
attempt = candidate
for n in 1..99:
if n > 1: attempt = candidate[:63-len('-N')] + '-N'
(rstrip trailing '-', revalidate strict)
if no other peer has attempt:
updateFn(attempt) # in TX
return attempt
Die Transaktion sorgt dafür, dass zwei gleichzeitig meldende Agents nicht beide denselben Suffix bekommen. Die 63-Zeichen-Grenze wird beim Suffix-Anhängen beibehalten (wir truncieren den Candidate entsprechend).
Hosts-File-Rendering
renderHostsContent() liefert den kompletten Dateiinhalt:
# Auto-generated by GateControl services/dns.js — do not edit.
# Regenerated on every peer mutation and reloaded into dnsmasq via SIGHUP.
10.8.0.8 nas1.domaincaster.lan nas1
10.8.0.12 laptop.domaincaster.lan laptop
ORDER BY hostname— deterministisch, damit unnötige File-Rewrites durch Sortier-Jitter ausbleiben.- Peers ohne gültige IPv4 (z.B. IPv6-only) werden übersprungen (Debug-Log).
- Peers mit Hostname, der
strictHostnameAssertnicht überlebt, werden übersprungen (Warn-Log) — sie resolven einfach nicht, bis ein Admin/Agent den Namen korrigiert.
rebuildNow():
renderHostsContent()→ Full-Content-String.atomicWrite(hostsFile, content)— temp + rename, nie halbe Datei.execFile('pkill', ['-HUP', 'dnsmasq'])— dnsmasq neuloaden. Exit-Code 1 (kein Prozess gefunden) wird nicht propagiert, damit Test-Umgebungen ohne dnsmasq nicht crashen.
Debouncing
Burst-Mutationen (z.B. Bulk-Peer-Create) würden naiv N Rebuilds + N SIGHUPs auslösen. scheduleRebuild() debounced trailing-edge mit config.dns.rebuildDebounceMs (default 500ms):
call N+1 within window → reset timer, keep pending promise
timer fires → rebuildNow(), resolve pending promise
Die Funktion liefert ein Promise zurück, auf das Tests oder Flush-Pfade warten können. flushPendingRebuild() forced einen sofortigen Rebuild.
Der Peer-Service ruft dns.scheduleRebuild() nach jedem hostname-relevanten Write. Der Gateway-Heartbeat-Handler und der Client-Heartbeat-Handler ebenso.
setHostname — die Policy-Funktion
src/services/peers.js:setHostname(peerId, rawHostname, source) ist der einzige Mutation-Pfad. Ablauf:
- Source-Check — nur
'admin'oder'agent'('stale'wird nur intern beim Restore gesetzt). - Clear-Case (
nulloder'') — beisource='agent'UNDpreviousSource='admin': NO-OP (Sticky-Admin). Sonst: Hostname auf NULL, Activity-Log, DNS-Rebuild. - Sticky-Admin-Block —
source='agent'+previousSource='admin'→ Debug-Log, Returnchanged:false. - Normalize + Strict-Assert — wirft bei Violation (wird zu 400 im API-Layer).
- No-Op-Check — Hostname case-insensitive gleich, nichts tun.
reserveUniqueHostname— innerhalb der Transaktion den finalen Namen ermitteln und ins Peer-Row schreiben.- Activity-Log
peer_hostname_changedmit alt/neu/source. dns.scheduleRebuild().
API
| Endpoint | Feature-Gate | Rate-Limit | Zweck |
|---|---|---|---|
GET /api/v1/system/dns/status |
internal_dns |
— | Statistik: Anzahl Peers je Source, Hostsfile-Metadaten |
GET /api/v1/system/dns/records |
internal_dns |
— | Volle Zone: static + dynamic, für Admin-UI-Liste |
PATCH /api/v1/peers/:id/hostname |
internal_dns |
— | Admin setzt Hostname (source='admin') |
POST /api/v1/client/peer/hostname |
internal_dns |
3/min | Desktop-Agent meldet os.hostname() |
Der Client-Endpoint ist token-gebunden: tokenPeerId aus dem API-Token bestimmt den Peer, nicht ein Body-Feld — so kann ein Token nicht Hostnames für fremde Peers setzen.
Opportunistische Captures
Zwei weitere Pfade schreiben Agent-Hostnames, ohne dass der Client einen extra Call machen muss:
Client-Heartbeat
POST /api/v1/client/peer/heartbeat enthält {connected, rxBytes, txBytes, uptime, hostname}. Wenn hostname gesetzt und internal_dns lizenziert: peers.setHostname(peer.id, hostname, 'agent') mit Sticky-Admin-Check. Fehler werden nur debug-geloggt, der Heartbeat selbst gibt 200 zurück.
Gateway-Heartbeat
POST /api/v1/gateway/heartbeat — gleicher Mechanismus für Home-Gateways. So zeigt der Gateway auch als nas1.<domain> auf, wenn ein solcher Host-Name kommuniziert wird.
Ziel: Ein neu installierter Client oder Gateway taucht innerhalb einer Heartbeat-Iteration in der DNS-Zone auf, ohne explizite Setup-Aktion.
Backup-Restore-Verhalten
Nach einem restore-Flow aus einem älteren Backup könnten die wiederhergestellten peers.hostname-Werte veraltet sein — der Peer auf einem Rechner, dessen Hostname sich zwischenzeitlich geändert hat, würde unter dem alten Namen resolven, bis der Agent wieder reportet.
Lösung: markHostnamesStale() in src/services/peers.js:
UPDATE peers
SET hostname_source = 'stale', hostname_reported_at = NULL
WHERE hostname_source IN ('agent', 'stale') AND hostname IS NOT NULL
admin-Einträge bleiben unangetastet (sie sind explizit gesetzt, nicht von Agent-Seite).- Agent-Einträge werden zu
stale— sie resolven weiter (der Name könnte noch stimmen), aber der erste Agent-Report überschreibt sie ohne Sticky-Block.
Status und Debugging
GET /api/v1/system/dns/status liefert:
{
"enabled": true,
"domain": "domaincaster.lan",
"peers": {
"total": 12,
"with_hostname": 10,
"admin_source": 3,
"agent_source": 6,
"stale_source": 1
},
"hostsFile": {
"path": "/data/dns/peers.hosts",
"mtime": "2026-04-21T10:22:14.000Z",
"size": 437
}
}
Die mtime ist der einfachste Debug-Hinweis: Wenn sie nicht mit dem erwarteten letzten Schreiben zusammenpasst, ist entweder atomicWrite fehlgeschlagen, pkill -HUP nicht durchgegangen, oder dnsmasq läuft nicht.
Lizenz-Gating
Alle Endpoints haben requireFeature('internal_dns'). Die opportunistischen Captures im Heartbeat prüfen hasFeature('internal_dns') und überspringen sonst. Ohne Lizenz bleibt die DB-Spalte leer, das dns/records-Endpoint antwortet 403, und dns.enabled kann via Config-Flag ebenfalls deaktiviert werden (config.dns.enabled).
Siehe auch
- home-gateway.md — Gateway-Heartbeat meldet Hostname ebenfalls.
- licensing.md —
internal_dns-Feature-Flag. - rdp-routes.md — RDP-Client-Flow nutzt Peer-FQDN für Cert-Validierung bei CredSSP.
- ../API.md — Endpoint-Schemas.
Quelldateien
src/services/dns.js—normalizeHostname,strictHostnameAssert,reserveUniqueHostname,renderHostsContent,rebuildNow,scheduleRebuild,getStatus.src/services/peers.js—setHostname,markHostnamesStale.src/routes/api/system.js—/system/dns/status,/system/dns/records.src/routes/api/client.js—/client/peer/hostname, Heartbeat-Capture.src/routes/api/gateway.js— Gateway-Heartbeat-Capture.src/routes/api/peers.js—PATCH /peers/:id/hostname.src/db/migrations.js— Migration 35 (peer_internal_hostname): Spalten + UNIQUE-Index.config/default.js—dns.enabled,dns.domain,dns.hostsFile,dns.rebuildDebounceMs.entrypoint.sh— dnsmasq-Setup, statische Host-Records.