DDNS Service

Dynamic DNS update service with CLI administration. Accepts HTTP(S) requests to update DNS A/AAAA records using RFC 2136 dynamic updates.

Features

  • HTTP(S) server for DynDNS-compatible updates
  • Multiple endpoints with configurable parameter aliases
  • Dual-stack IPv4/IPv6 support
  • SQLite or MariaDB database backend
  • Argon2 password hashing
  • Rate limiting (separate limits for good/bad requests)
  • TTL-based automatic record expiration
  • Email notifications on expiry
  • Syslog, file and stdout logging support
  • Plain text / JSON responses according to Accept-Header (application/json)
  • IDN/punycode hostname support
  • Proxy support

Installation

pip install git+https://git.ccc-rheintal.ch/spacefreak/ddns-service.git

# With MariaDB support:
pip install "ddns-service[mysql] @ git+https://git.ccc-rheintal.ch/spacefreak/ddns-service.git"

Requires Python 3.11+. Dependencies installed automatically: argon2-cffi, dnspython, jinja2, peewee (+ pymysql for mysql extra).

Service setup

  1. Create system user and group:
useradd -r -s /sbin/nologin ddns
  1. Create directories:
mkdir -p /etc/ddns-service /var/lib/ddns-service /var/log/ddns-service
chmod 750 /etc/ddns-service /var/lib/ddns-service
chmod 770 /var/log/ddns-service
chown ddns:ddns /etc/ddns-service /var/lib/ddns-service /var/log/ddns-service
  1. Config file and templates:
wget -O /etc/ddns-service/config.toml https://git.ccc-rheintal.ch/spacefreak/ddns-service/raw/branch/master/files/config.example.toml
wget -O /etc/ddns-service/config.toml https://git.ccc-rheintal.ch/spacefreak/ddns-service/raw/branch/master/files/change_notification.j2
wget -O /etc/ddns-service/config.toml https://git.ccc-rheintal.ch/spacefreak/ddns-service/raw/branch/master/files/expiry_notification.j2

# The config file must not be world readable!
chmod 640 /etc/ddns-service/config.toml
chown -R ddns:ddns /etc/ddns-service/
  1. Service autostart
# Systemd setup
wget -O /etc/systemd/systemd/ddns-service.service https://git.ccc-rheintal.ch/spacefreak/ddns-service/raw/branch/master/files/ddns-service.service
systemctl daemon-reload
systemctl enable ddns-service

# OpenRC setup
wget -O /etc/init.d/ddns-service https://git.ccc-rheintal.ch/spacefreak/ddns-service/raw/branch/master/files/ddns-service-openrc.initd
wget -O /etc/conf.d/ddns-service https://git.ccc-rheintal.ch/spacefreak/ddns-service/raw/branch/master/files/ddns-service-openrc.confd
rc-update add ddns-service default

Configuration

Example:

[daemon]
# host = "localhost"  # default: "localhost" (use reverse proxy for public access)
# port = 8443  # default: 8443
# log_level = "INFO"  # default: "INFO"
# log_target = "stdout"  # default: "stdout", or "syslog", "file"
# syslog_socket = "/dev/log"  # default: "/dev/log"
# syslog_facility = "daemon"  # default: "daemon"
# log_file = "/var/log/ddns-service/ddns-service.log"  # default, used if log_target = "file"
# log_file_size = 52428800  # default: 52428800 (50 MB in bytes)
# log_versions = 5  # default: 5 backup files
# log_requests = false  # default: false
# ssl = false  # default: false
ssl_cert_file = "/etc/ddns-service/cert.pem"  # required if ssl = true
ssl_key_file = "/etc/ddns-service/key.pem"  # required if ssl = true
# proxy_header = ""  # default: "" (disabled), e.g. "X-Forwarded-For"
# trusted_proxies = []  # default: [], e.g. ["127.0.0.1", "10.0.0.0/8"]

[database]
# backend = "sqlite"  # default: "sqlite", or "mariadb"
path = "/var/lib/ddns-service/ddns.db"  # required for sqlite

[dns_service]
# dns_server = "127.0.0.1"  # default: "127.0.0.1" (must be IP address)
# dns_port = 53  # default: 53
# dns_timeout = 5  # default: 5 (seconds)
# ddns_default_key_file = "/etc/ddns-service/ddns.key"  # optional, BIND TSIG key file
# cleanup_interval = 60  # default: 60 (seconds, expired records cleanup)

# Per-zone TSIG key overrides (optional)
# [dns_service.zone_keys]
# "dyn.example.com" = "/etc/ddns-service/dyn-example.key"

[defaults]
# dns_ttl = 60  # default: 60
# expiry_ttl = 3600  # default: 3600

[email]
# enabled = false  # default: false
smtp_host = "localhost"  # required if email.enabled
# smtp_port = 25  # default: 25
# smtp_starttls = false  # default: false
from_address = "ddns@example.com"  # required if email.enabled
# change_notification_template = "/etc/ddns-service/change_notification.j2"  # optional
# expiry_notification_template = "/etc/ddns-service/expiry_notification.j2"  # optional

[rate_limit]
# enabled = true  # default: true
# good_window_seconds = 60  # default: 60
# good_max_requests = 5  # default: 5
# bad_window_seconds = 60  # default: 60
# bad_max_requests = 3  # default: 3
# cleanup_interval = 60  # default: 60 (seconds, rate limiter cleanup)

TSIG Authentication

For secure DNS updates, configure TSIG authentication:

  1. Generate key on BIND server:
tsig-keygen -a hmac-sha256 ddns-key > /etc/bind/ddns.key
  1. Include in named.conf and configure zone:
include "/etc/bind/ddns.key";

zone "dyn.example.com" {
    type master;
    file "/var/lib/bind/dyn.example.com.zone";
    update-policy {
        grant ddns-key zonesub ANY;
    };
};
  1. Copy key file to ddns-service host and configure:
[dns_service]
ddns_default_key_file = "/etc/ddns-service/ddns.key"

Key file format (generated by tsig-keygen):

key "ddns-key" {
    algorithm hmac-sha256;
    secret "base64-encoded-secret";
};

Without TSIG authentication, the DNS server must allow updates based on IP address (via allow-update directive).

Endpoints

Configure one or more HTTP endpoints. If no endpoints are defined, a default endpoint at /update is created with standard parameter names. Set an empty list of names to disable a parameter.

[[endpoints]]
path = "/update"
[endpoints.params]
hostname = ["hostname", "host"]
ipv4 = ["myip", "ipv4", "ip4"]
ipv6 = ["myip6", "ipv6", "ip6"]
username = ["username", "user"]
password = ["password", "pass", "token"]
notify_change = ["notify_change"]

[[endpoints]]
path = "/nic/update"
[endpoints.params]
hostname = ["hostname"]
ipv4 = ["myip"]
ipv6 = ["myip6"]
username = ["username"]
password = ["password"]
notify_change = []

Default accepted parameter names (first match wins):

Value Accepted Names
hostname (FQDN) hostname, host
ipv4 (IPv4 address) myip, ipv4, ip4
ipv6 (IPv6 address) myip6, ipv6, ip6
username username, user
password password, pass, token
notify_change notify_change

CLI Usage

Initialize Database

ddns-service --init-db

The database is automatically created and migrated when the daemon starts or any CLI command is executed.

SQLite permissions: The first CLI invocation creates the database file. Run it as the service user (sudo -u ddns ddns-service --init-db) so the daemon can write to it. Root can use CLI afterwards without issues.

To allow other users CLI access (SQLite only):

usermod -aG ddns myuser        # add user to ddns group
chmod g+w /var/lib/ddns-service/ddns.db  # enable group write

User Management

# List users
ddns-service user list

# Add user (prompts for password)
ddns-service user add myuser user@example.com

# Delete user (fails if hostnames exist)
ddns-service user delete myuser

# Change password
ddns-service user passwd myuser

# Update email
ddns-service user email myuser new@example.com

Hostname Management

# List all hostnames
ddns-service hostname list

# List hostnames for specific user
ddns-service hostname list --user myuser

# Add hostname
ddns-service hostname add myuser mypc dyn.example.com

# Add hostname with custom TTLs
ddns-service hostname add myuser mypc dyn.example.com \
    --dns-ttl 60 --expiry-ttl 7200

# Modify hostname TTLs
ddns-service hostname modify mypc dyn.example.com --dns-ttl 120

# Delete hostname
ddns-service hostname delete mypc dyn.example.com

Manual Cleanup

# Manually cleanup expired hostnames (delete DNS records)
ddns-service cleanup

Run Daemon

ddns-service --daemon

# With debug logging
ddns-service --daemon --debug

HTTP API

Request

GET /update?hostname=mypc.dyn.example.com[&myip=1.2.3.4][&myip6=2001:db8::1][&notify_change=1]
Authorization: Basic base64(username:password)

Authentication can also be provided as query parameters:

GET /update?hostname=mypc.dyn.example.com&username=myuser&password=secret

Set notify_change=1 to receive an email notification when the IP address changes. Requires email to be enabled and a change notification template configured.

IP Detection

  • If myip and/or myip6 provided: use those values
  • If neither provided: use client's source IP
  • IPv4 addresses create A records
  • IPv6 addresses create AAAA records

Responses

Plain text (default, DynDNS-compatible):

  • good <ipv4> <ipv6> - update successful
  • nochg <ipv4> <ipv6> - no change needed
  • badauth - authentication failed
  • nohost - hostname not found or not authorized
  • dnserr - DNS update failed
  • abuse - rate limit exceeded

JSON (with Accept: application/json):

{"status": "good", "ipv4": "1.2.3.4", "ipv6": "2001:db8::1"}

Client Examples

curl

curl -u "username:password" "https://ddns.example.com/update?hostname=mypc.dyn.example.com"

With explicit IP:

curl -u "username:password" "https://ddns.example.com/update?hostname=mypc.dyn.example.com&myip=1.2.3.4"

With change notification:

curl -u "username:password" "https://ddns.example.com/update?hostname=mypc.dyn.example.com&notify_change=1"

wget

wget -qO- --user=username --password=password \
    "https://ddns.example.com/update?hostname=mypc.dyn.example.com"

Security Considerations

  • Do not expose directly to the internet - run behind a reverse proxy (e.g. nginx, caddy, apache) that handles TLS termination
  • By default the daemon binds to localhost only; configure your reverse proxy to forward requests
  • Passwords are hashed with Argon2 (memory-hard, resistant to GPU attacks)
  • Rate limiting protects against brute-force attacks
  • Database file should have restricted permissions
  • Consider fail2ban for additional protection
  • Database backups: Create recurring backups before upgrades. Migration errors require manual recovery from backup.

TTL Behavior

Each hostname has two TTL values:

  • dns_ttl: TTL value set on DNS records (default: 60 seconds)
  • expiry_ttl: Time without updates before record is removed (default: 3600 seconds)

Set expiry_ttl = 0 to disable expiration entirely for a hostname.

When a hostname expires:

  1. DNS records (A and/or AAAA) are deleted
  2. Email notification is sent to the user (if configured)
  3. Records can be restored by sending a new update request

Email Notifications

Two types of email notifications are supported:

  • Change notifications: Sent when IP address changes, if client includes notify_change=1 parameter
  • Expiry notifications: Sent automatically when hostname expires due to inactivity

Configuration

Enable email and configure templates in [email] section:

[email]
enabled = true
smtp_host = "localhost"
from_address = "ddns@example.com"
change_notification_template = "/etc/ddns-service/change_notification.j2"
expiry_notification_template = "/etc/ddns-service/expiry_notification.j2"

Example templates are provided in the files/ directory.

Template Variables

Templates use Jinja2 syntax. Available variables:

Change notification:

Variable Description
hostname Hostname (without zone)
zone DNS zone
fqdn FQDN (hostname.zone)
ipv4_changed Boolean, IPv4 changed
ipv4 Current IPv4 address
last_ipv4_update Last IPv4 update time
ipv6_changed Boolean, IPv6 changed
ipv6 Current IPv6 address
last_ipv6_update Last IPv6 update time
expiry_ttl Expiry TTL in seconds

Expiry notification:

Variable Description
hostname Hostname (without zone)
zone DNS zone
fqdn FQDN (hostname.zone)
ipv4_expired Boolean, IPv4 expired
last_ipv4 Last IPv4 address
last_ipv4_update Last IPv4 update time
ipv6_expired Boolean, IPv6 expired
last_ipv6 Last IPv6 address
last_ipv6_update Last IPv6 update time
expiry_ttl Expiry TTL in seconds

Logging

The daemon supports stdout, syslog, and file logging targets.

Configuration Options

Option Default Description
log_level INFO DEBUG, INFO, WARNING, ERROR
log_target stdout stdout, syslog, or file
syslog_socket /dev/log Path to syslog socket (syslog only)
syslog_facility daemon daemon, user, local0-7 (syslog only)
log_file /var/log/ddns-service/ddns-service.log Log file path (file only)
log_file_size 52428800 Max file size before rotation in bytes (file only)
log_versions 5 Number of backup files to keep (file only)
log_requests false Log HTTP request lines at INFO level
Description
Dynamic DNS update service with CLI administration.
Readme GPL-3.0 479 KiB
Languages
Python 97%
Shell 2.3%
Jinja 0.7%