feat(dns-updater): always-on node client (systemd timer self-report)

Reusable dyndns client for always-on, region-mobile nodes (the Prospector PWA
on lime): install-client.sh drops dyndns-update.sh + a systemd oneshot/timer
that self-reports the node public IP to dns.ct.uvlava.com on boot and every
5 min, so prospector.ct.uvlava.com tracks the node across region moves while
the node stays up. Token + host in /etc/dyndns-updater (0600).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-29 14:00:57 -04:00
parent 25f58cdc3c
commit e89cca3dc9
5 changed files with 118 additions and 3 deletions

View file

@ -101,11 +101,21 @@ curl -fsS -H "Authorization: Bearer $DNS_UPDATER_TOKEN" \
"https://dns.ct.uvlava.com/nic/update?hostname=live.ct.uvlava.com&myip=$DROPLET_IP"
```
**Prospector node (on boot / region move)** — a systemd oneshot self-reports its
public IP (`myip` omitted → caller IP):
**Prospector node (always-on, on boot + every 5 min)** — 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:
```bash
curl -fsS -H "Authorization: Bearer $DNS_UPDATER_TOKEN" \
# on the node, as root:
DNS_HOSTNAME=prospector.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:
```bash
curl -fsS -H "Authorization: Bearer $TOKEN" \
"https://dns.ct.uvlava.com/nic/update?hostname=prospector.ct.uvlava.com"
```

View file

@ -0,0 +1,36 @@
#!/usr/bin/env bash
# Self-report this node's public IP to the dns-updater (dyndns2), repointing its
# own A record. For always-on, region-mobile nodes (e.g. the Prospector PWA on
# lime): run on boot and on a timer so the record tracks the node across moves.
#
# Config: /etc/dyndns-updater/dyndns.conf
# DNS_UPDATER_URL=https://dns.ct.uvlava.com
# DNS_HOSTNAME=prospector.ct.uvlava.com
# DNS_TOKEN_FILE=/etc/dyndns-updater/token # file containing the node token
#
# myip is omitted, so the updater uses the caller's observed public IP.
set -euo pipefail
CONF="${DYNDNS_CONF:-/etc/dyndns-updater/dyndns.conf}"
[[ -f "$CONF" ]] || { echo "missing config: $CONF" >&2; exit 1; }
# shellcheck disable=SC1090
source "$CONF"
: "${DNS_UPDATER_URL:?DNS_UPDATER_URL not set in $CONF}"
: "${DNS_HOSTNAME:?DNS_HOSTNAME not set in $CONF}"
TOKEN_FILE="${DNS_TOKEN_FILE:-/etc/dyndns-updater/token}"
[[ -f "$TOKEN_FILE" ]] || { echo "missing token file: $TOKEN_FILE" >&2; exit 1; }
TOKEN="$(tr -d '[:space:]' < "$TOKEN_FILE")"
[[ -n "$TOKEN" ]] || { echo "empty token in $TOKEN_FILE" >&2; exit 1; }
resp="$(curl -fsS --retry 3 --retry-delay 2 --max-time 20 \
-H "Authorization: Bearer ${TOKEN}" \
"${DNS_UPDATER_URL}/nic/update?hostname=${DNS_HOSTNAME}")"
echo "dyndns ${DNS_HOSTNAME}: ${resp}"
# dyndns2 success bodies start with "good" or "nochg"; anything else is a failure.
case "$resp" in
good*|nochg*) exit 0 ;;
*) echo "unexpected response" >&2; exit 1 ;;
esac

View file

@ -0,0 +1,14 @@
[Unit]
Description=Self-report public IP to dns.ct.uvlava.com (dyndns2)
Documentation=https://forge.ct.uvlava.com/quinn/uvlava/src/branch/main/services/dns-updater
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/bin/dyndns-update.sh
# Don't let a transient DNS/API failure mark the node degraded; the timer retries.
SuccessExitStatus=0 1
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,14 @@
[Unit]
Description=Periodically refresh this node's A record via dns.ct.uvlava.com
Documentation=https://forge.ct.uvlava.com/quinn/uvlava/src/branch/main/services/dns-updater
[Timer]
# Fire shortly after boot, then every 5 minutes so the record tracks the node
# across IP changes / region moves.
OnBootSec=30s
OnUnitActiveSec=5min
AccuracySec=30s
Persistent=true
[Install]
WantedBy=timers.target

View file

@ -0,0 +1,41 @@
#!/usr/bin/env bash
# Install the dyndns self-report client on an always-on, region-mobile node
# (e.g. the Prospector PWA node on lime). Run as root ON THE NODE.
#
# Usage:
# DNS_HOSTNAME=prospector.ct.uvlava.com NODE_TOKEN=<token> ./install-client.sh
#
# After install the node refreshes its A record on boot and every 5 minutes.
set -euo pipefail
DNS_UPDATER_URL="${DNS_UPDATER_URL:-https://dns.ct.uvlava.com}"
DNS_HOSTNAME="${DNS_HOSTNAME:?set DNS_HOSTNAME, e.g. prospector.ct.uvlava.com}"
NODE_TOKEN="${NODE_TOKEN:?set NODE_TOKEN to this node dyndns token}"
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
install -m 0755 "${HERE}/dyndns-update.sh" /usr/local/bin/dyndns-update.sh
install -d -m 0700 /etc/dyndns-updater
cat >/etc/dyndns-updater/dyndns.conf <<CONF
DNS_UPDATER_URL=${DNS_UPDATER_URL}
DNS_HOSTNAME=${DNS_HOSTNAME}
DNS_TOKEN_FILE=/etc/dyndns-updater/token
CONF
chmod 0600 /etc/dyndns-updater/dyndns.conf
printf '%s' "${NODE_TOKEN}" >/etc/dyndns-updater/token
chmod 0600 /etc/dyndns-updater/token
install -m 0644 "${HERE}/dyndns-updater.service" /etc/systemd/system/dyndns-updater.service
install -m 0644 "${HERE}/dyndns-updater.timer" /etc/systemd/system/dyndns-updater.timer
systemctl daemon-reload
systemctl enable --now dyndns-updater.timer
# Fire once now so the record is correct immediately.
systemctl start dyndns-updater.service || true
echo "Installed. Status:"
systemctl --no-pager status dyndns-updater.service | tail -n 5 || true
echo "Timer:"
systemctl list-timers dyndns-updater.timer --no-pager || true