Remove dependency on dns-manager and use dnspython directly instead

This commit is contained in:
2026-01-18 17:54:58 +01:00
parent 5570bab736
commit 6c8a1999eb
5 changed files with 323 additions and 87 deletions

View File

@@ -1,6 +1,6 @@
# DDNS Service # DDNS Service
Dynamic DNS update service with CLI administration. Accepts HTTP(S) requests to update DNS A/AAAA records using the dns-manager library. Dynamic DNS update service with CLI administration. Accepts HTTP(S) requests to update DNS A/AAAA records using RFC 2136 dynamic updates.
## Features ## Features
@@ -49,7 +49,7 @@ pip install -r requirements.txt
### Dependencies ### Dependencies
- Python 3.11+ - Python 3.11+
- dns-manager - dnspython
- peewee - peewee
- argon2-cffi - argon2-cffi
- pymysql (for MariaDB support) - pymysql (for MariaDB support)
@@ -81,9 +81,16 @@ ssl_key_file = "/etc/ddns-service/key.pem" # required if ssl = true
path = "/var/lib/ddns-service/ddns.db" # required for sqlite path = "/var/lib/ddns-service/ddns.db" # required for sqlite
[dns_service] [dns_service]
# manager_config_file = "/etc/dns-manager/config.yml" # default # dns_server = "localhost" # default: "localhost" (DNS server for RFC 2136 updates)
# 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) # 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] [defaults]
# dns_ttl = 60 # default: 60 # dns_ttl = 60 # default: 60
# expiry_ttl = 3600 # default: 3600 # expiry_ttl = 3600 # default: 3600
@@ -104,6 +111,44 @@ from_address = "ddns@example.com" # required if email.enabled
# cleanup_interval = 60 # default: 60 (seconds, rate limiter cleanup) # cleanup_interval = 60 # default: 60 (seconds, rate limiter cleanup)
``` ```
### TSIG Authentication
For secure DNS updates, configure TSIG authentication:
1. Generate key on BIND server:
```bash
tsig-keygen -a hmac-sha256 ddns-key > /etc/bind/ddns.key
```
2. 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;
};
};
```
3. Copy key file to ddns-service host and configure:
```toml
[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 ### Endpoints
Configure one or more HTTP endpoints. If no endpoints are defined, a default endpoint at `/update` is created with standard parameter names. Configure one or more HTTP endpoints. If no endpoints are defined, a default endpoint at `/update` is created with standard parameter names.

View File

@@ -25,8 +25,15 @@ path = "/var/lib/ddns-service/ddns.db" # required for sqlite
# database = "ddns" # required for mariadb # database = "ddns" # required for mariadb
[dns_service] [dns_service]
# manager_config_file = "/etc/dns-manager/config.yml" # default # dns_server = "localhost" # default
# cleanup_interval = 60 # default, interval in seconds # dns_port = 53 # default
# dns_timeout = 5 # default, seconds
# ddns_default_key_file = "/etc/ddns-service/ddns.key" # optional, BIND TSIG key
# cleanup_interval = 60 # default, seconds
# Per-zone TSIG key overrides (optional)
# [dns_service.zone_keys]
# "dyn.example.com" = "/etc/ddns-service/dyn-example.key"
[defaults] [defaults]
# dns_ttl = 60 # default, DNS record TTL in seconds # dns_ttl = 60 # default, DNS record TTL in seconds

View File

@@ -9,7 +9,7 @@ version = {attr = "ddns_service.__version__"}
name = "ddns_service" name = "ddns_service"
dynamic = ["version"] dynamic = ["version"]
dependencies = [ dependencies = [
"dns-manager @ git+https://git.ccc-rheintal.ch/spacefreak/dns-manager.git", "dnspython>=2.4.0",
"peewee>=3.17.0", "peewee>=3.17.0",
"argon2-cffi>=23.1.0", "argon2-cffi>=23.1.0",
] ]

View File

@@ -145,11 +145,32 @@ def load_config(config_path):
cfg["database"].setdefault("backend", "sqlite") cfg["database"].setdefault("backend", "sqlite")
cfg.setdefault("dns_service", {}) cfg.setdefault("dns_service", {})
cfg["dns_service"].setdefault("manager_config_file", "/etc/dns-manager/config.yml") cfg["dns_service"].setdefault("dns_server", "localhost")
cfg["dns_service"].setdefault("dns_port", 53)
cfg["dns_service"].setdefault("dns_timeout", 5)
cfg["dns_service"].setdefault("cleanup_interval", 60) cfg["dns_service"].setdefault("cleanup_interval", 60)
if cfg["dns_service"]["cleanup_interval"] < 1: if cfg["dns_service"]["cleanup_interval"] < 1:
cfg["dns_service"]["cleanup_interval"] = 1 cfg["dns_service"]["cleanup_interval"] = 1
# Validate dns_server
if not cfg["dns_service"]["dns_server"]:
raise ConfigError("dns_service.dns_server cannot be empty")
# Validate ddns_default_key_file if provided
default_key = cfg["dns_service"].get("ddns_default_key_file")
if default_key and not os.path.isfile(default_key):
raise ConfigError(f"ddns_default_key_file not found: {default_key}")
# Validate zone_keys if provided
zone_keys = cfg["dns_service"].get("zone_keys", {})
if not isinstance(zone_keys, dict):
raise ConfigError("dns_service.zone_keys must be a table")
for zone, key_path in zone_keys.items():
if not isinstance(key_path, str):
raise ConfigError(f"dns_service.zone_keys.{zone} must be a string path")
if not os.path.isfile(key_path):
raise ConfigError(f"Zone key file not found for {zone}: {key_path}")
cfg.setdefault("defaults", {}) cfg.setdefault("defaults", {})
cfg["defaults"].setdefault("dns_ttl", 60) cfg["defaults"].setdefault("dns_ttl", 60)
cfg["defaults"].setdefault("expiry_ttl", 3600) cfg["defaults"].setdefault("expiry_ttl", 3600)

View File

@@ -1,11 +1,17 @@
"""DNS operations using dns-manager library.""" """DNS operations using RFC 2136 dynamic updates."""
import ipaddress import ipaddress
import logging import logging
import os
import re
import stat
import dns.rdataset import dns.name
import dns.query
import dns.rcode
import dns.rdatatype import dns.rdatatype
from dnsmgr import DNSManager, name_from_text, rdata_from_text import dns.tsigkeyring
import dns.update
def detect_ip_type(ip): def detect_ip_type(ip):
@@ -27,8 +33,83 @@ class DNSError(Exception):
pass pass
# TSIG algorithm name mapping (BIND short names -> dnspython FQDN form)
TSIG_ALGORITHMS = {
"hmac-md5": "hmac-md5.sig-alg.reg.int.",
"hmac-sha1": "hmac-sha1.",
"hmac-sha224": "hmac-sha224.",
"hmac-sha256": "hmac-sha256.",
"hmac-sha384": "hmac-sha384.",
"hmac-sha512": "hmac-sha512.",
}
def parse_bind_key_file(path):
"""
Parse BIND TSIG key file.
Format: key "keyname" { algorithm hmac-sha256; secret "base64..."; };
Args:
path: Path to key file, or None.
Returns:
Tuple of (keyring, algorithm) or (None, None) if path is None.
Raises:
DNSError: If parsing fails.
"""
if not path:
return None, None
try:
# Check file permissions
file_stat = os.stat(path)
if file_stat.st_mode & stat.S_IROTH:
logging.warning(f"TSIG key file is world-readable: {path}")
with open(path, "r") as f:
content = f.read()
# Extract key name
name_match = re.search(r'key\s+"([^"]+)"', content)
if not name_match:
raise DNSError(f"Invalid key file {path}: no key name found")
key_name = name_match.group(1)
# Ensure key name ends with dot for FQDN
if not key_name.endswith("."):
key_name = key_name + "."
# Extract algorithm
algo_match = re.search(r'algorithm\s+([^;]+);', content)
if not algo_match:
raise DNSError(f"Invalid key file {path}: no algorithm found")
algo_str = algo_match.group(1).strip().lower()
# Map to dnspython algorithm name
algorithm = TSIG_ALGORITHMS.get(algo_str)
if not algorithm:
raise DNSError(f"Unsupported TSIG algorithm in {path}: {algo_str}")
# Extract secret
secret_match = re.search(r'secret\s+"([^"]+)"', content)
if not secret_match:
raise DNSError(f"Invalid key file {path}: no secret found")
secret = secret_match.group(1)
keyring = dns.tsigkeyring.from_text({key_name: secret})
return keyring, dns.name.from_text(algorithm)
except DNSError:
raise
except Exception as e:
raise DNSError(f"Failed to parse key file {path}: {e}")
class DNSService: class DNSService:
"""DNS service for managing DNS records.""" """DNS service for RFC 2136 dynamic updates."""
def __init__(self, config): def __init__(self, config):
""" """
@@ -41,75 +122,133 @@ class DNSService:
DNSError: If initialization fails. DNSError: If initialization fails.
""" """
try: try:
config_file = config["dns_service"]["manager_config_file"] dns_cfg = config["dns_service"]
self.manager = DNSManager(config_file) self.server = dns_cfg["dns_server"]
logging.debug(f"DNS manager initialized: config={config_file}") self.port = dns_cfg.get("dns_port", 53)
except Exception as e: self.timeout = dns_cfg.get("dns_timeout", 5)
raise DNSError(f"Failed to initialize DNS manager: {e}")
def _get_zone(self, zone): # Parse default TSIG key
"""Get zone object by name.""" default_key_file = dns_cfg.get("ddns_default_key_file")
zones = self.manager.get_zones(zone) self.default_keyring, self.default_algorithm = parse_bind_key_file(
if not zones: default_key_file
raise DNSError(f"Zone not found: {zone}") )
zone_obj = zones[0]
self.manager.get_zone_content(zone_obj) # Parse per-zone TSIG keys
return zone_obj self.zone_keys = {}
zone_keys_cfg = dns_cfg.get("zone_keys", {})
for zone, key_path in zone_keys_cfg.items():
keyring, algorithm = parse_bind_key_file(key_path)
# Normalize zone name
if not zone.endswith("."):
zone = zone + "."
self.zone_keys[zone] = (keyring, algorithm)
if self.default_keyring or self.zone_keys:
logging.debug(
f"DNS service initialized: server={self.server}:{self.port} "
f"tsig=enabled zones={len(self.zone_keys)}"
)
else:
logging.debug(
f"DNS service initialized: server={self.server}:{self.port} "
f"tsig=disabled"
)
except DNSError:
raise
except Exception as e:
raise DNSError(f"Failed to initialize DNS service: {e}")
def _get_key_for_zone(self, zone):
"""
Get TSIG key for a zone.
Args:
zone: Zone name (will be normalized to FQDN).
Returns:
Tuple of (keyring, algorithm) or (None, None) for unauthenticated.
"""
# Normalize zone name
if not zone.endswith("."):
zone = zone + "."
# Check zone-specific key first
if zone in self.zone_keys:
return self.zone_keys[zone]
# Fall back to default key
return self.default_keyring, self.default_algorithm
def _make_update(self, zone):
"""
Create DNS update message for zone.
Args:
zone: Zone name.
Returns:
dns.update.Update message object.
"""
# Normalize zone name
if not zone.endswith("."):
zone = zone + "."
keyring, algorithm = self._get_key_for_zone(zone)
if keyring:
return dns.update.Update(
zone,
keyring=keyring,
keyalgorithm=algorithm
)
else:
return dns.update.Update(zone)
def _send_update(self, update):
"""
Send DNS update message to server.
Args:
update: dns.update.Update message object.
Raises:
DNSError: If update fails.
"""
try:
response = dns.query.tcp(
update,
self.server,
port=self.port,
timeout=self.timeout
)
rcode = response.rcode()
if rcode != dns.rcode.NOERROR:
raise DNSError(f"DNS update failed: {dns.rcode.to_text(rcode)}")
except dns.exception.Timeout:
raise DNSError(f"DNS update timeout: {self.server}:{self.port}")
except DNSError:
raise
except Exception as e:
raise DNSError(f"DNS update failed: {e}")
def _get_relative_name(self, hostname, zone): def _get_relative_name(self, hostname, zone):
"""Get hostname relative to zone."""
if hostname.endswith("." + zone):
return hostname[:-len(zone) - 1]
return hostname
def _delete_record(self, zone_obj, name, rdtype):
"""Delete record if present."""
deleted = False
zone_obj.filter_by_name(name, zone_obj.origin)
node = zone_obj.get_node(name)
if not node:
return deleted
for rdataset in zone_obj.get_node(name):
if rdataset.rdtype == rdtype:
self.manager.delete_zone_record(zone_obj, name, rdataset)
deleted = True
return deleted
def delete_record(self, hostname, zone, record_type):
""" """
Delete DNS record(s) for the given hostname and record type. Get hostname relative to zone.
Args: Args:
hostname: Fully qualified hostname. hostname: Fully qualified hostname.
zone: DNS zone name. zone: Zone name.
record_type: Record type (A or AAAA).
Returns: Returns:
True if record was deleted. Relative name for use in DNS update.
Raises:
DNSError: If delete fails.
""" """
# Strip zone suffix to get relative name
try: zone_suffix = "." + zone
deleted = False if hostname.endswith(zone_suffix):
zone_obj = self._get_zone(zone) return hostname[:-len(zone_suffix)]
name = name_from_text(hostname, zone_obj.origin) return hostname
rdtype = dns.rdatatype.from_text(record_type)
if self._delete_record(zone_obj, name, rdtype):
logging.debug(
f"DNS record deleted: hostname={hostname} "
f"zone={zone_obj.origin} type={record_type}"
)
return deleted
except Exception as e:
raise DNSError(f"Failed to delete DNS record for {hostname}: {e}")
def update_record(self, hostname, zone, ip, ttl): def update_record(self, hostname, zone, ip, ttl):
""" """
@@ -126,27 +265,51 @@ class DNSService:
""" """
try: try:
record_type, normalized_ip = detect_ip_type(ip) record_type, normalized_ip = detect_ip_type(ip)
name = self._get_relative_name(hostname, zone)
zone_obj = self._get_zone(zone) update = self._make_update(zone)
name = name_from_text(hostname, zone_obj.origin) update.replace(name, ttl, record_type, normalized_ip)
rdtype = dns.rdatatype.from_text(record_type) self._send_update(update)
# Delete existing record if present
self._delete_record(zone_obj, name, rdtype)
# Create new rdata
rdata = rdata_from_text(rdtype, normalized_ip, zone_obj.origin)
# Create rdataset with TTL
rdataset = dns.rdataset.Rdataset(rdata.rdclass, rdata.rdtype)
rdataset.update_ttl(ttl)
rdataset.add(rdata)
# Add the record
self.manager.add_zone_record(zone_obj, name, rdataset)
logging.debug( logging.debug(
f"DNS record updated: hostname={hostname} zone={zone_obj.origin} " f"DNS record updated: hostname={hostname} zone={zone} "
f"type={record_type} ip={normalized_ip} ttl={ttl}" f"type={record_type} ip={normalized_ip} ttl={ttl}"
) )
except DNSError:
raise
except Exception as e: except Exception as e:
raise DNSError(f"Failed to update DNS record for {hostname}: {e}") raise DNSError(f"Failed to update DNS record for {hostname}: {e}")
def delete_record(self, hostname, zone, record_type):
"""
Delete DNS record(s) for the given hostname and record type.
Args:
hostname: Fully qualified hostname.
zone: DNS zone name.
record_type: Record type (A or AAAA).
Returns:
True (for compatibility).
Raises:
DNSError: If delete fails.
"""
try:
name = self._get_relative_name(hostname, zone)
update = self._make_update(zone)
update.delete(name, record_type)
self._send_update(update)
logging.debug(
f"DNS record deleted: hostname={hostname} zone={zone} "
f"type={record_type}"
)
return True
except DNSError:
raise
except Exception as e:
raise DNSError(f"Failed to delete DNS record for {hostname}: {e}")