- JavaScript 100%
Run as a service instead of a per-request CGI: a foreground process that listens on the loopback interface, logs to stdout and shuts down on SIGTERM, so a service manager can supervise it and a reverse proxy can terminate TLS in front of it. Route 53 changes are submitted without polling for INSYNC, keeping the reply prompt enough for a router's update timeout; with a 60s TTL the record is live well before the next update. Accounts move to JSON (config.json) so a configuration-management system can render them, and addresses are validated as IPs before they reach Route 53. Replies use the DynDNS keywords (good/badauth/nochg/ dnserr) that routers report on. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|---|---|---|
| .gitignore | ||
| config-sample.json | ||
| index.js | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
Dynamic DNS for Route 53
A small HTTP service that publishes a router's dynamic address as an A/AAAA record in a Route 53 hosted zone. A FRITZ!Box (or anything else that speaks the DynDNS "update URL" convention) calls it whenever its WAN address changes.
The service listens on the loopback interface and expects a reverse proxy in front of it to terminate TLS — the update password travels in the query string.
Update URL
https://dyndns.example.com/update?host=<username>&password=<pass>&ipaddr=<ipaddr>&ip6addr=<ip6addr>
host names an account in the config file and password authenticates
it; the hostname and hosted zone that account writes to are fixed in the
config, so an update can only move its own record. ipaddr and
ip6addr are published as A and AAAA respectively, with a 60 second
TTL. Empty values and the placeholders 0.0.0.0 / :: are ignored; if
both are absent, the address the request arrived from is published
(taken from X-Forwarded-For, as set by the proxy).
The path carries no meaning — every path other than /healthz is an
update request, so the service can be mounted anywhere in a proxy's URL
space.
Replies use the DynDNS keywords: good <address> on success, badauth
for an unknown account or wrong password, nochg when the request
carried no usable address, dnserr when Route 53 rejected the change.
/healthz answers ok and is what a health check should poll.
Configuration
Accounts live in a JSON file, see config-sample.json. Environment:
| Variable | Default | Meaning |
|---|---|---|
DYNDNS53_CONFIG |
config.json |
path to the account file |
DYNDNS53_ADDRESS |
127.0.0.1 |
listen address |
DYNDNS53_PORT |
8731 |
listen port |
AWS credentials come from the environment through the standard SDK
provider chain (AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY /
AWS_REGION).
This is a low-security system: passwords are stored and transmitted in the clear.
IAM policy
The IAM user needs to change record sets in every zone named in the config:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"route53:ChangeResourceRecordSets",
"route53:GetChange"
],
"Resource": [
"arn:aws:route53:::hostedzone/XXXXXXXXXXXXXXXXXXXXX",
"arn:aws:route53:::change/*"
]
}
]
}
Deployment
netzhansa-infra (roles/dyndns53) installs this on outpost as a
systemd service behind Caddy, renders the account file to
/etc/dyndns53/config.json from the Ansible vault, and provisions the
IAM user via playbooks/aws-iam.yml.
Testing
DYNDNS53_CONFIG=config.json node index.js &
curl 'http://127.0.0.1:8731/update?host=bonnie&password=secret&ipaddr=127.0.0.1'
With a matching account in config.json this prints good 127.0.0.1.