Files
ddns-service/README.md

10 KiB

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: dnspython, peewee, argon2-cffi (+ 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:root /etc/ddns-service
chown ddns:ddns /var/lib/ddns-service /var/log/ddns-service
  1. Config file:
wget -O /etc/ddns-service/config.toml https://git.ccc-rheintal.ch/spacefreak/ddns-service/raw/branch/master/files/config.example.toml

# The config file must not be world readable!
chmod 640 /etc/ddns-service/config.toml
chown ddns:root /etc/ddns-service/config.toml
  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

[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.

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

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

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

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]
Authorization: Basic base64(username:password)

Authentication can also be provided as query parameters:

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

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"

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
  3. Records can be restored by sending a new update request

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