uvlava/services/dns-updater
Natalie 15aad2eabe uvlava: add ./run task runner for the services tier
One runner (cf. @applications/prospector/run): services/deploy/status/logs/
restart over the services/ tree (each a dir with deploy.sh + compose.yml,
shipped to /opt/<svc>), plus a `tf` passthrough to terraform/do with the vault
token. Auto-discovers services; target from services/<svc>/.target else the
forge droplet; health from services/<svc>/.health. Fleet SSH uses a dedicated
known_hosts and self-heals a changed host key after a droplet rebuild.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 23:12:47 -04:00
..
client refactor(dns-updater): rename prospector.ct -> sales.ct (public name) 2026-06-29 14:11:13 -04:00
src refactor(dns-updater): rename prospector.ct -> sales.ct (public name) 2026-06-29 14:11:13 -04:00
test refactor(dns-updater): rename prospector.ct -> sales.ct (public name) 2026-06-29 14:11:13 -04:00
.gitignore feat(dns-updater): self-hosted dyndns2 service for region-mobile nodes 2026-06-29 13:57:20 -04:00
.health uvlava: add ./run task runner for the services tier 2026-06-29 23:12:47 -04:00
bun.lock feat(dns-updater): self-hosted dyndns2 service for region-mobile nodes 2026-06-29 13:57:20 -04:00
compose.yml fix(dns-updater): target the live forge droplet's host Caddy, not a container 2026-06-29 15:13:03 -04:00
deploy.sh fix(dns-updater): target the live forge droplet's host Caddy, not a container 2026-06-29 15:13:03 -04:00
Dockerfile feat(dns-updater): self-hosted dyndns2 service for region-mobile nodes 2026-06-29 13:57:20 -04:00
env.example refactor(dns-updater): rename prospector.ct -> sales.ct (public name) 2026-06-29 14:11:13 -04:00
package.json refactor(dns-updater): rename prospector.ct -> sales.ct (public name) 2026-06-29 14:11:13 -04:00
README.md fix(dns-updater): target the live forge droplet's host Caddy, not a container 2026-06-29 15:13:03 -04:00
tsconfig.json feat(dns-updater): self-hosted dyndns2 service for region-mobile nodes 2026-06-29 13:57:20 -04:00

dns-updater (dns.ct.uvlava.com)

A tiny dyndns2-compatible DNS updater for the uvlava.com zone, backed by the DigitalOcean DNS API.

It exists so region-mobile nodes can repoint their own A record automatically when they move regions — no registrar GUI, no manual dig/edit:

FQDN (moving) Node Lifecycle
live.ct.uvlava.com broadcast relay droplet ephemeral — per show, nearest the broadcaster, torn down after
sales.ct.uvlava.com "sales" surface = Prospector PWA node always-on — follows the operator across regions, stays up

The pretty *.transquinnftw.com names are static CNAMEs onto these (set once, below), so the only records that ever move are inside the zone we control via the DO API. The transquinnftw.com zone at joker.com never changes again.

Why not joker.com DynDNS?

Joker offers dyndns2 too, but with per-host credentials at the registrar and no fleet-wide auth. Since uvlava.com is delegated to DO, we own that zone via the API — so a single self-hosted service gives us one auth model (token → hostname allowlist), keeps transquinnftw.com static, and is the foundation for DNS-01 ACME / multi-zone later.

Endpoints

  • GET /healthz{ "ok": true, "domain": "uvlava.com" }
  • GET|POST /nic/update?hostname=<fqdn>&myip=<ipv4> → dyndns2 response: good <ip> · nochg <ip> · nohost · notfqdn · badauth · 911

myip is optional — omit it and the caller's IP (left-most X-Forwarded-For from the forge Caddy) is used. A token may update only the hostnames in its grant; it cannot touch forge.ct/npm.ct/another node's record.

Auth

Authorization: Bearer <token> (scripts) or HTTP Basic with the token as the password (Authorization: Basic base64(any:<token>), what generic dyndns2 clients send).

Configuration (env / .env)

Var Required Meaning
DO_TOKEN yes DO API token (read/write DNS). Use a dedicated token, not the TF one.
DNS_UPDATER_TOKENS yes JSON: [{"token":"…","hosts":["live.ct.uvlava.com"]}]
DNS_DOMAIN no Zone (default uvlava.com)
PORT no Bind port (default 8090)
TRUST_PROXY no Trust X-Forwarded-For (default true; Caddy fronts it)

Generate node tokens with openssl rand -hex 24. See env.example.

Deploy

Runs on the ct-forge droplet (134.199.243.61). The droplet terminates TLS with a host Caddy (/etc/caddy/Caddyfile, systemd) that reverse-proxies to localhost ports. The container publishes 127.0.0.1:8090; Caddy proxies dns.ct.uvlava.com → it.

# one-time: put secrets on the droplet (use the IP; forge.ct won't resolve
# until uvlava.com DNSSEC is removed)
ssh root@134.199.243.61 'mkdir -p /opt/dns-updater'
scp env.example root@134.199.243.61:/opt/dns-updater/.env   # then edit + fill

./deploy.sh                      # rsync + build + start + wire host Caddy vhost + reload

deploy.sh is idempotent: builds/starts the container, appends the dns.ct.uvlava.com vhost to /etc/caddy/Caddyfile if missing, caddy validates before reloading, and health-checks on loopback. The LE cert is auto-issued by Caddy via HTTP-01 once dns.ct.uvlava.com resolves publicly (i.e. after the uvlava.com DNSSEC DS is removed at joker).

One-time registrar setup (manual, at joker.com)

transquinnftw.com lives at joker.com (login natily). After the DO records exist (terraform apply of the records below), add two static CNAMEs there — these never change again:

live   CNAME  live.ct.uvlava.com.
sales  CNAME  sales.ct.uvlava.com.

transquinnftw.com is NOT delegated to DO, so these CNAMEs are added in the joker.com DNS panel by hand, once. The existing *.transquinnftw.com LE wildcard covers TLS for both names (or the node's own Caddy issues per-name).

terraform records

terraform/do/dns.tf creates dns.ct (→ forge) and seeds live.ct / sales.ct with lifecycle { ignore_changes = [value] } — TF makes them exist; this service owns their value at runtime.

Node-side usage

Broadcast relay (per show) — after provisioning the droplet, point live.ct at it (see lilith-platform.livescripts/provision-stream-droplet.sh, which calls):

curl -fsS -H "Authorization: Bearer $DNS_UPDATER_TOKEN" \
  "https://dns.ct.uvlava.com/nic/update?hostname=live.ct.uvlava.com&myip=$DROPLET_IP"

Sales node (always-on, on boot + every 5 min) — the public "sales" surface is the Prospector PWA node. Install the reusable client (client/), which self-reports the node's public IP (myip omitted → caller IP) via a systemd timer so the record tracks the node across moves:

# on the node, as root:
DNS_HOSTNAME=sales.ct.uvlava.com NODE_TOKEN=<token> \
  ./client/install-client.sh

It installs dyndns-update.sh + a dyndns-updater.timer (OnBootSec + every 5 min). Under the hood each tick is:

curl -fsS -H "Authorization: Bearer $TOKEN" \
  "https://dns.ct.uvlava.com/nic/update?hostname=sales.ct.uvlava.com"

Local dev

bun install
DO_TOKEN=DNS_UPDATER_TOKENS='[{"token":"dev","hosts":["live.ct.uvlava.com"]}]' \
  bun run dev
curl -H 'Authorization: Bearer dev' \
  'http://127.0.0.1:8090/nic/update?hostname=live.ct.uvlava.com&myip=1.2.3.4'