Domains & DNS
Overview
Peers in the GateControl VPN should be able to reach each other and the administration by name, not just by IP. An admin types nas1.domaincaster.lan instead of 10.8.0.8, an RDP client uses the registered FQDN instead of a tunnel IP, and desktop clients can automatically report their own OS hostname. Behind this there is no dynamic DNS zone in the sense of nsupdate, but rather a simple hosts-file pipeline: GateControl writes /data/dns/peers.hosts, dnsmasq loads it on SIGHUP, done.
The interesting problems are not the mechanics, but the policy: what happens when an admin and the desktop agent want to name the same peer differently? What if two agents claim the same name simultaneously? How do you prevent a backup restore from flattening current hostnames? These policies are implemented in the peers service and are explained here.
The feature is gated behind the license flag internal_dns. Without a license, the column peers.hostname remains empty and the endpoints respond with 403.
Zone and records
Every peer with a hostname ends up in the zone <hostname>.<config.dns.domain>. The default domain can be overridden via GC_DNS_DOMAIN; common values are something like domaincaster.lan or gc.internal.
Static records
src/routes/api/system.js (GET /api/v1/system/dns/records) supplies these three additionally:
gateway.<domain> → 10.8.0.1
server.<domain> → 10.8.0.1
gc-server.<domain> → 10.8.0.1
All point to the server-side tunnel IP (config.wireguard.gatewayIp). These three names are marked reserved in the RFC-1123 validator and cannot be assigned as a peer hostname.
Dynamic records
Per peer:
<ip> <hostname>.<domain> <hostname>
The short form is for clients that run with search domain=… in resolv.conf and don't want to type an FQDN.
Data model
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;
The three sources
| Source | Meaning | Priority |
|---|---|---|
admin |
Set explicitly in the admin UI or via PATCH /api/v1/peers/:id/hostname |
Highest — nothing overwrites this |
agent |
Desktop client or gateway companion reported the OS hostname | Can only be overwritten by admin |
stale |
After backup restore: old agent values, until the agent reports again | Overwritten by agent and admin |
Sticky-admin policy: as soon as an admin sets a hostname, all agent reports for this peer are silently ignored.
Validation
normalizeHostname(input)
Strips whitespace, lowercases, checks non-empty and <= 63 characters. Throws on violation. No policy checks (reserved list, uniqueness) — those are separate layers.
strictHostnameAssert(hostname)
Second line of defense, called at the API boundary AND at the renderer. Checks:
- Non-empty,
<= 63 characters. - Regex
/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/— RFC-1123, no leading/trailing hyphen, no uppercase. - Not in
RESERVED_HOSTNAMES(lowercase set):localhost, local, host, broadcasthost, gateway, server, gc-server, admin, root, router, dns, vpn, api, auth, mail, ns, ns1, ns2 - Per-byte check: control chars (
<0x21),#(dnsmasq comment character),>0x7eare explicitly rejected. This does repeat the regex guarantee, but is deliberately redundant: if the regex is ever loosened, the byte check still blocks injections.
The renderer calls strictHostnameAssert before every write to the hosts file. If a bug in the API ever pushes a malformed value into the DB, it is stopped here and the peer is simply skipped (with warn log), rather than leaving it as a potentially injected line in the dnsmasq config.
Uniqueness — reserveUniqueHostname
Two peers must not have the same hostname (case-insensitive). On collision, -2, -3, … is appended until free. The whole sequence runs in a 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
The transaction ensures that two agents reporting simultaneously don't both get the same suffix. The 63-character limit is maintained when appending the suffix (we truncate the candidate accordingly).
Hosts-file rendering
renderHostsContent() returns the full file content:
# 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— deterministic, so that unnecessary file rewrites due to sort jitter don't occur.- Peers without a valid IPv4 (e.g. IPv6-only) are skipped (debug log).
- Peers with a hostname that doesn't survive
strictHostnameAssertare skipped (warn log) — they simply don't resolve until an admin/agent corrects the name.
rebuildNow():
renderHostsContent()→ full content string.atomicWrite(hostsFile, content)— temp + rename, never a half-written file.execFile('pkill', ['-HUP', 'dnsmasq'])— reload dnsmasq. Exit code 1 (no process found) is not propagated, so test environments without dnsmasq don't crash.
Debouncing
Burst mutations (e.g. bulk peer create) would naively trigger N rebuilds + N SIGHUPs. scheduleRebuild() debounces trailing-edge with config.dns.rebuildDebounceMs (default 500ms):
call N+1 within window → reset timer, keep pending promise
timer fires → rebuildNow(), resolve pending promise
The function returns a promise that tests or flush paths can wait on. flushPendingRebuild() forces an immediate rebuild.
The peer service calls dns.scheduleRebuild() after every hostname-relevant write. The gateway heartbeat handler and the client heartbeat handler do the same.
setHostname — the policy function
src/services/peers.js:setHostname(peerId, rawHostname, source) is the only mutation path. Sequence:
- Source check — only
'admin'or'agent'('stale'is set only internally during restore). - Clear case (
nullor'') — ifsource='agent'ANDpreviousSource='admin': no-op (sticky admin). Otherwise: hostname to NULL, activity log, DNS rebuild. - Sticky-admin block —
source='agent'+previousSource='admin'→ debug log, returnchanged:false. - Normalize + strict assert — throws on violation (becomes 400 in the API layer).
- No-op check — hostname case-insensitively equal, do nothing.
reserveUniqueHostname— within the transaction, determine the final name and write it into the peer row.- Activity log
peer_hostname_changedwith old/new/source. dns.scheduleRebuild().
API
| Endpoint | Feature gate | Rate limit | Purpose |
|---|---|---|---|
GET /api/v1/system/dns/status |
internal_dns |
— | Statistics: peer count per source, hosts-file metadata |
GET /api/v1/system/dns/records |
internal_dns |
— | Full zone: static + dynamic, for admin UI list |
PATCH /api/v1/peers/:id/hostname |
internal_dns |
— | Admin sets hostname (source='admin') |
POST /api/v1/client/peer/hostname |
internal_dns |
3/min | Desktop agent reports os.hostname() |
The client endpoint is token-bound: tokenPeerId from the API token determines the peer, not a body field — so a token cannot set hostnames for foreign peers.
Opportunistic captures
Two additional paths write agent hostnames without the client having to make an extra call:
Client heartbeat
POST /api/v1/client/peer/heartbeat contains {connected, rxBytes, txBytes, uptime, hostname}. If hostname is set and internal_dns is licensed: peers.setHostname(peer.id, hostname, 'agent') with sticky-admin check. Errors are only debug-logged, the heartbeat itself returns 200.
Gateway heartbeat
POST /api/v1/gateway/heartbeat — same mechanism for Home Gateways. This way the gateway also shows up as nas1.<domain> when such a hostname is communicated.
Goal: a newly installed client or gateway appears in the DNS zone within one heartbeat iteration, without an explicit setup action.
Backup-restore behavior
After a restore flow from an older backup, the restored peers.hostname values could be outdated — the peer on a machine whose hostname has changed in the meantime would resolve under the old name until the agent reports again.
Solution: 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
adminentries remain untouched (they are explicitly set, not from the agent side).- Agent entries become
stale— they still resolve (the name might still be correct), but the first agent report overwrites them without a sticky block.
Status and debugging
GET /api/v1/system/dns/status returns:
{
"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
}
}
The mtime is the simplest debug hint: if it doesn't match the expected last write, either atomicWrite failed, pkill -HUP didn't go through, or dnsmasq isn't running.
License gating
All endpoints have requireFeature('internal_dns'). The opportunistic captures in the heartbeat check hasFeature('internal_dns') and skip otherwise. Without a license, the DB column stays empty, the dns/records endpoint responds 403, and dns.enabled can also be deactivated via config flag (config.dns.enabled).
See also
- home-gateway.md — gateway heartbeat also reports hostname.
- licensing.md —
internal_dnsfeature flag. - rdp-routes.md — RDP client flow uses peer FQDN for cert validation on CredSSP.
- ../API.md — endpoint schemas.
Source files
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): columns + UNIQUE index.config/default.js—dns.enabled,dns.domain,dns.hostsFile,dns.rebuildDebounceMs.entrypoint.sh— dnsmasq setup, static host records.