CallMeTechie
EN Anmelden
Home Produkte Blog Über mich Kontakt

Domains & DNS

v1.x · Updated vor 1 Monat

Ü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), >0x7e werden 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 strictHostnameAssert nicht überlebt, werden übersprungen (Warn-Log) — sie resolven einfach nicht, bis ein Admin/Agent den Namen korrigiert.

rebuildNow():

  1. renderHostsContent() → Full-Content-String.
  2. atomicWrite(hostsFile, content) — temp + rename, nie halbe Datei.
  3. 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:

  1. Source-Check — nur 'admin' oder 'agent' ('stale' wird nur intern beim Restore gesetzt).
  2. Clear-Case (null oder '') — bei source='agent' UND previousSource='admin': NO-OP (Sticky-Admin). Sonst: Hostname auf NULL, Activity-Log, DNS-Rebuild.
  3. Sticky-Admin-Blocksource='agent' + previousSource='admin' → Debug-Log, Return changed:false.
  4. Normalize + Strict-Assert — wirft bei Violation (wird zu 400 im API-Layer).
  5. No-Op-Check — Hostname case-insensitive gleich, nichts tun.
  6. reserveUniqueHostname — innerhalb der Transaktion den finalen Namen ermitteln und ins Peer-Row schreiben.
  7. Activity-Log peer_hostname_changed mit alt/neu/source.
  8. 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

Quelldateien

  • src/services/dns.jsnormalizeHostname, strictHostnameAssert, reserveUniqueHostname, renderHostsContent, rebuildNow, scheduleRebuild, getStatus.
  • src/services/peers.jssetHostname, 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.jsPATCH /peers/:id/hostname.
  • src/db/migrations.js — Migration 35 (peer_internal_hostname): Spalten + UNIQUE-Index.
  • config/default.jsdns.enabled, dns.domain, dns.hostsFile, dns.rebuildDebounceMs.
  • entrypoint.sh — dnsmasq-Setup, statische Host-Records.

Cookie Settings

Wir verwenden Cookies, um Ihre Erfahrung zu verbessern. Essentielle Cookies sind immer aktiv.

Datenschutzerklärung
ESC
↑↓ navigate open esc close