Primer: multi-domain dynamic DNS on Vultr (no ddclient) — hardened & audited
Solving dynamic DNS on Vultr for multiple domains
I run several domains on Vultr’s DNS from a single OpenBSD host and need A/AAAA records to track my WAN IP. ddclient doesn’t support Vultr’s DNS API, and most DIY snippets only touch one zone. This post is the hardened version of my updater: safe WAN IP detection, strict file perms, stable logging with rotation, cron wiring that actually works, plus built‑in tests so you can prove it’s doing the right thing.
Targets: OpenBSD, Vultr DNS v2 API, repeated labels across zones (e.g., @, mail, obsd1), GitHub Pages apex protection. IPv4-only by default; IPv6 supported with a flag.
Why not ddclient?
- Vultr’s managed DNS isn’t supported by ddclient.
- I need multiple zones with identical labels, reproducible paths & perms, logging in
/var/log/, and a GitHub Pages apex guard.
Prereqs
# OpenBSD
doas pkg_add python%3 py3-requests
# (or ensure python3 + requests are available)
Configuration
Two shell‑style .env files live under ~/.config/vultr/ for the user who runs the job (below I show foo). Quotes are OK.
~/.config/vultr/api.env
# ~/.config/vultr/api.env
# Required
VULTR_API_KEY="REDACTED_YOUR_VULTR_BEARER_TOKEN"
VULTR_API_URL="https://api.vultr.com/v2"
# Email summary (optional; via /usr/sbin/sendmail)
MAIL_NOTIFY="ops@example.io"
MAIL_FROM="ops@example.io"
# Optional: skip apex '@' for these domains (e.g., GitHub Pages)
GITHUB_PAGES_DOMAINS="unattributed.blog"
# Optional: only accept IPv4 from these CIDRs (space- or comma-separated).
# Leave empty to accept any public IPv4.
ALLOWED_IPV4_CIDRS="124.121.0.0/16"
# Default TTL (seconds)
DEFAULT_TTL="300"
# Optional explicit pointers (handy if cron runs as root)
VULTR_API_ENV=/home/foo/.config/vultr/api.env
VULTR_DDNS_ENV=/home/foo/.config/vultr/ddns.env
~/.config/vultr/ddns.env
# Zones to update (space-separated)
VULTR_DOMAINS="example1.com example2.com example3.ca example4.energy example5.com unattributed.blog example6.com"
# Host labels per domain (space or comma separated). Use "@" for apex.
VULTR_HOSTS_example1.com="@ mail obsd1"
VULTR_HOSTS_example2.com="@ mail obsd1"
VULTR_HOSTS_example3.ca="@ mail obsd1"
VULTR_HOSTS_example4.energy="@ mail obsd1"
VULTR_HOSTS_example5.com="@ mail obsd1"
VULTR_HOSTS_unattributed.blog="mail obsd1" # no apex; GH Pages guard also protects
VULTR_HOSTS_example6.com="@ mail obsd1"
# TTLs (optional). Underscore or dot forms both work.
VULTR_TTL_DEFAULT=300
#VULTR_TTL_blackbagsecurity_com=180
Permissions: lock them down.
chmod 700 ~/.config/vultr
chmod 600 ~/.config/vultr/*.env
If you run from root’s cron, you can move these to /etc/vultr/ (root:wheel, 600) or keep them under /home/foo but make them root‑owned.
The updater
Installed as /usr/local/sbin/vultr_ddns_multi.py. Highlights:
- Default: IPv4 (A) only; add
--ipv6-onlyfor AAAA. - Consensus IP across multiple echo services; public‑only.
- Create or update each requested label in each domain; per‑domain TTL.
- Apex guard: skips
@for domains listed inGITHUB_PAGES_DOMAINS. - Cache in
~/.cache/vultr-ddns/to skip no‑op runs. - Logs to
/var/log/vultr_ddns.log; fallback to~/.cache/vultr-ddns/vultr_ddns.log(forced0600). - Email summary when
MAIL_FROM+MAIL_NOTIFYare valid. - API pinning to
api.vultr.comunless--allow-non-vultr-apiis set. - Diagnostics:
--self-test,--diagnose-ipv4,--diagnose-ipv6.
Install & smoke test:
doas install -o root -g wheel -m 0755 vultr_ddns_multi.py /usr/local/sbin/vultr_ddns_multi.py
# Built-in unit checks (consensus/email/API pin)
/usr/local/sbin/vultr_ddns_multi.py --self-test # expect: SELF-TEST: OK
# See echo-site votes and consensus (no DNS writes)
/usr/local/sbin/vultr_ddns_multi.py --diagnose-ipv4
Dry run (no writes), then real run:
/usr/local/sbin/vultr_ddns_multi.py --force -n -v
/usr/local/sbin/vultr_ddns_multi.py -q
Logging & rotation
The script appends to /var/log/vultr_ddns.log. On OpenBSD, rotate daily and keep 7 gzips:
# /etc/newsyslog.conf
# logfile owner:group mode count size when flags
/var/log/vultr_ddns.log root:wheel 640 7 * @T00 Z
If you run as a non‑root user and can’t write /var/log, the script falls back to ~/.cache/vultr-ddns/vultr_ddns.log with perms 0600.
Cron that works (PATH / shebang)
On OpenBSD, python3 is in /usr/local/bin. If your script uses #!/usr/bin/env python3, make sure cron’s PATH includes /usr/local/bin.
Option A (edit root’s crontab PATH):
PATH=/usr/local/bin:/bin:/sbin:/usr/bin:/usr/sbin
*/5 * * * * /usr/local/sbin/vultr_ddns_multi.py -q
Option B (pin the shebang to absolute Python):
doas sed -i.bak '1s|/usr/bin/env python3|/usr/local/bin/python3|' /usr/local/sbin/vultr_ddns_multi.py
Simulate cron’s environment once (sanity check):
doas env -i SHELL=/bin/sh PATH=/usr/local/bin:/bin:/sbin:/usr/bin:/usr/sbin HOME=/var/log /usr/local/sbin/vultr_ddns_multi.py -q
Restart cron only if you want to be sure:
doas rcctl restart cron
doas rcctl status cron
Verification (auth NS, not cache)
Query Vultr’s authoritative nameserver directly:
for d in example1.com example2.com example3.ca example4.energy example5.com unattributed.blog example6.com; do
echo "=== $d"
drill -t -Q @ns1.vultr.com A $d 2>/dev/null || true
drill -t -Q @ns1.vultr.com A mail.$d 2>/dev/null || true
drill -t -Q @ns1.vultr.com A obsd1.$d 2>/dev/null || true
done
Built‑in tests & operations
- Self‑test (offline logic: consensus, email regex, API pin):
/usr/local/sbin/vultr_ddns_multi.py --self-test # expect OK - Diagnose IP (show raw votes + the chosen IP):
vultr_ddns_multi.py --diagnose-ipv4 # vultr_ddns_multi.py --diagnose-ipv6 - Status / cache control:
vultr_ddns_multi.py --status vultr_ddns_multi.py --touch -v # refresh cache only (no API) vultr_ddns_multi.py --clear-cache - Safety rails:
--allow-non-vultr-api(only if you truly need to override API URL pinning)--no-gh-guard(temporarily allow apex updates on a GH Pages domain)--ipv6-only(operate on AAAA records only)
Threat model & mitigations (concise)
-
Stolen API key → DNS takeover
Mitigate: env files600, owner‑only; (optionally) move to/etc/vultr/root‑owned. -
Tampered IP‑echo → site hijack
Mitigate: consensus across multiple services; only accept public IPs; optional CIDR allowlist. -
Config tampering (when run as root)
Mitigate: store configs root‑owned or run from the owning user’s cron. -
API URL exfiltration
Mitigate: pin toapi.vultr.comby default; require--allow-non-vultr-apito override. -
Email header/newline shenanigans
Mitigate: validate addresses before callingsendmail. -
Log snooping
Mitigate: fallback log forced to0600;/var/log/vultr_ddns.logis640root:wheel; rotate withnewsyslog.
Flags cheat sheet
-n, --dry-run— print intended actions, no API writes-q, --quiet/-v, --verbose— console verbosity-d, --domain DOMAIN— restrict to one or more zones--ipv6-only— AAAA only--force— ignore IP cache; always check/update--status— show cached IPs--clear-cache— delete cache files--touch— refresh caches from current public IP(s), no API calls--diagnose-ipv4,--diagnose-ipv6— show echo votes + consensus--self-test— offline unit checks (consensus/email/API pin)--no-gh-guard— allow apex updates for GH Pages‑listed domains--allow-non-vultr-api— opt‑out of API URL pinning (not recommended)
Attribution: Andy J Smith’s ddns.py informed this work: https://github.com/andyjsmith/Vultr-Dynamic-DNS.