CallMeTechie
DE Login
Home Products Blog About Contact

Domains & DNS

v1.x · Updated 1 month ago

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), >0x7e are 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 strictHostnameAssert are skipped (warn log) — they simply don't resolve until an admin/agent corrects the name.

rebuildNow():

  1. renderHostsContent() → full content string.
  2. atomicWrite(hostsFile, content) — temp + rename, never a half-written file.
  3. 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:

  1. Source check — only 'admin' or 'agent' ('stale' is set only internally during restore).
  2. Clear case (null or '') — if source='agent' AND previousSource='admin': no-op (sticky admin). Otherwise: hostname to NULL, activity log, DNS rebuild.
  3. Sticky-admin blocksource='agent' + previousSource='admin' → debug log, return changed:false.
  4. Normalize + strict assert — throws on violation (becomes 400 in the API layer).
  5. No-op check — hostname case-insensitively equal, do nothing.
  6. reserveUniqueHostname — within the transaction, determine the final name and write it into the peer row.
  7. Activity log peer_hostname_changed with old/new/source.
  8. 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
  • admin entries 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

Source files

  • 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): columns + UNIQUE index.
  • config/default.jsdns.enabled, dns.domain, dns.hostsFile, dns.rebuildDebounceMs.
  • entrypoint.sh — dnsmasq setup, static host records.

Cookie Settings

We use cookies to improve your experience. Essential cookies are always active.

Privacy Policy
ESC
↑↓ navigate open esc close