No description
  • JavaScript 100%
Find a file
Hans Hübner 70de285ffe Serve updates from a long-lived HTTP server
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>
2026-07-22 10:38:11 +02:00
.gitignore Serve updates from a long-lived HTTP server 2026-07-22 10:38:11 +02:00
config-sample.json Serve updates from a long-lived HTTP server 2026-07-22 10:38:11 +02:00
index.js Serve updates from a long-lived HTTP server 2026-07-22 10:38:11 +02:00
package-lock.json Serve updates from a long-lived HTTP server 2026-07-22 10:38:11 +02:00
package.json Serve updates from a long-lived HTTP server 2026-07-22 10:38:11 +02:00
README.md Serve updates from a long-lived HTTP server 2026-07-22 10:38:11 +02:00

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.