Compare commits

...

10 Commits

8 changed files with 181 additions and 150 deletions
+16 -5
View File
@@ -7,7 +7,7 @@ Dynamic DNS update service with CLI administration. Accepts HTTP(S) requests to
- HTTP(S) server for DynDNS-compatible updates - HTTP(S) server for DynDNS-compatible updates
- Multiple endpoints with configurable parameter aliases - Multiple endpoints with configurable parameter aliases
- Dual-stack IPv4/IPv6 support - Dual-stack IPv4/IPv6 support
- SQLite or MariaDB database backend - SQLite or MariaDB/MySQL database backend
- Argon2 password hashing - Argon2 password hashing
- Rate limiting (separate limits for good/bad requests) - Rate limiting (separate limits for good/bad requests)
- TTL-based automatic record expiration - TTL-based automatic record expiration
@@ -22,10 +22,21 @@ Dynamic DNS update service with CLI administration. Accepts HTTP(S) requests to
```bash ```bash
pip install git+https://git.ccc-rheintal.ch/spacefreak/ddns-service.git pip install git+https://git.ccc-rheintal.ch/spacefreak/ddns-service.git
# With MariaDB support: # With MariaDB/MySQL support:
pip install "ddns-service[mysql] @ git+https://git.ccc-rheintal.ch/spacefreak/ddns-service.git" pip install "ddns-service[mysql] @ git+https://git.ccc-rheintal.ch/spacefreak/ddns-service.git"
``` ```
### Install a specific version
Append `@<git-tag>` to the repository URL to install a tagged release:
```bash
pip install git+https://git.ccc-rheintal.ch/spacefreak/ddns-service.git@v1.0.0
# With MariaDB/MySQL support:
pip install "ddns-service[mysql] @ git+https://git.ccc-rheintal.ch/spacefreak/ddns-service.git@v1.0.0"
```
Requires Python 3.11+. Dependencies installed automatically: argon2-cffi, dnspython, jinja2, peewee (+ pymysql for mysql extra). Requires Python 3.11+. Dependencies installed automatically: argon2-cffi, dnspython, jinja2, peewee (+ pymysql for mysql extra).
## Service setup ## Service setup
@@ -46,8 +57,8 @@ chown ddns:ddns /etc/ddns-service /var/lib/ddns-service /var/log/ddns-service
3. Config file and templates: 3. Config file and templates:
```bash ```bash
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/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/change_notification.j2 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 wget -O /etc/ddns-service/expiry_notification.j2 https://git.ccc-rheintal.ch/spacefreak/ddns-service/raw/branch/master/files/expiry_notification.j2
# The config file must not be world readable! # The config file must not be world readable!
chmod 640 /etc/ddns-service/config.toml chmod 640 /etc/ddns-service/config.toml
@@ -94,7 +105,7 @@ ssl_key_file = "/etc/ddns-service/key.pem" # required if ssl = true
[database] [database]
# backend = "sqlite" # default: "sqlite", or "mariadb" # backend = "sqlite" # default: "sqlite", or "mariadb"
path = "/var/lib/ddns-service/ddns.db" # required for sqlite path = "/var/lib/ddns-service/ddns.db" # required for sqlite
# pool_size = 5 # default: 5 (MariaDB connection pool size) # pool_size = 5 # default: 5 (MariaDB/MySQL connection pool size)
[dns_service] [dns_service]
# dns_server = "127.0.0.1" # default: "127.0.0.1" (must be IP address) # dns_server = "127.0.0.1" # default: "127.0.0.1" (must be IP address)
+1 -1
View File
@@ -26,7 +26,7 @@ readme = "README.md"
license = "GPL-3.0-only" license = "GPL-3.0-only"
keywords = ["dns", "ddns", "service", "http", "https"] keywords = ["dns", "ddns", "service", "http", "https"]
classifiers = [ classifiers = [
"Development Status :: 4 - Beta", "Development Status :: 5 - Stable",
"Topic :: Internet :: Name Service (DNS)", "Topic :: Internet :: Name Service (DNS)",
"Intended Audience :: System Administrators", "Intended Audience :: System Administrators",
"Programming Language :: Python :: 3" "Programming Language :: Python :: 3"
+1 -1
View File
@@ -23,7 +23,7 @@ __all__ = [
"logging", "logging",
"main", "main",
"models", "models",
"now_utc" "now_utc",
"ratelimit", "ratelimit",
"server", "server",
"STATUS_GOOD", "STATUS_GOOD",
+13 -5
View File
@@ -27,7 +27,8 @@ class Application:
config: Configuration dictionary from TOML file. config: Configuration dictionary from TOML file.
config_path: Path to configuration file (for reload). config_path: Path to configuration file (for reload).
""" """
self.config = config self._config = config
self._config_lock = threading.RLock()
self.config_path = config_path self.config_path = config_path
self.password_hasher = argon2.PasswordHasher() self.password_hasher = argon2.PasswordHasher()
self.shutdown_event = threading.Event() self.shutdown_event = threading.Event()
@@ -38,6 +39,12 @@ class Application:
self.good_limiter = None self.good_limiter = None
self.bad_limiter = None self.bad_limiter = None
@property
def config(self):
"""Thread-safe config access."""
with self._config_lock:
return self._config
def init_database(self): def init_database(self):
"""Initialize database connection and run migrations.""" """Initialize database connection and run migrations."""
init_database(self.config) init_database(self.config)
@@ -68,12 +75,13 @@ class Application:
""" """
new_config = load_config(self.config_path) new_config = load_config(self.config_path)
with self._config_lock:
# Preserve DB and bind settings # Preserve DB and bind settings
new_config["database"] = self.config["database"] new_config["database"] = self._config["database"]
new_config["daemon"]["host"] = self.config["daemon"]["host"] new_config["daemon"]["host"] = self._config["daemon"]["host"]
new_config["daemon"]["port"] = self.config["daemon"]["port"] new_config["daemon"]["port"] = self._config["daemon"]["port"]
self.config = new_config self._config = new_config
# Reconfigure logging # Reconfigure logging
setup_logging( setup_logging(
+15 -12
View File
@@ -17,6 +17,15 @@ from .models import (
) )
def validate_password(password, confirm):
"""Validate password and confirmation match with min length."""
if password != confirm:
return "Error: Passwords do not match."
if len(password) < 8:
return "Error: Password must be at least 8 characters."
return None
def cmd_user_list(args, app): def cmd_user_list(args, app):
"""List all users.""" """List all users."""
users = User.select().order_by(User.username) users = User.select().order_by(User.username)
@@ -53,12 +62,9 @@ def cmd_user_add(args, app):
password = getpass.getpass("Password: ") password = getpass.getpass("Password: ")
password_confirm = getpass.getpass("Confirm password: ") password_confirm = getpass.getpass("Confirm password: ")
if password != password_confirm: error = validate_password(password, password_confirm)
print("Error: Passwords do not match.") if error:
return 1 print(error)
if len(password) < 8:
print("Error: Password must be at least 8 characters.")
return 1 return 1
# Hash password and create user # Hash password and create user
@@ -102,12 +108,9 @@ def cmd_user_passwd(args, app):
password = getpass.getpass("New password: ") password = getpass.getpass("New password: ")
password_confirm = getpass.getpass("Confirm password: ") password_confirm = getpass.getpass("Confirm password: ")
if password != password_confirm: error = validate_password(password, password_confirm)
print("Error: Passwords do not match.") if error:
return 1 print(error)
if len(password) < 8:
print("Error: Password must be at least 8 characters.")
return 1 return 1
user.password_hash = app.password_hasher.hash(password) user.password_hash = app.password_hasher.hash(password)
+10 -5
View File
@@ -15,6 +15,10 @@ import dns.tsigkeyring
import dns.update import dns.update
# DNS name length limits (RFC 1035)
MAX_HOSTNAME_LENGTH = 253
MAX_LABEL_LENGTH = 63
# Valid hostname label pattern (after punycode encoding) # Valid hostname label pattern (after punycode encoding)
LABEL_PATTERN = re.compile( LABEL_PATTERN = re.compile(
r'^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$', re.IGNORECASE r'^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$', re.IGNORECASE
@@ -54,8 +58,9 @@ def encode_dnsname(hostname):
if hostname.endswith('.'): if hostname.endswith('.'):
hostname = hostname[:-1] hostname = hostname[:-1]
if len(hostname) > 253: if len(hostname) > MAX_HOSTNAME_LENGTH:
raise EncodingError("Hostname too long (max 253 characters)") raise EncodingError(
f"Hostname too long (max {MAX_HOSTNAME_LENGTH} characters)")
try: try:
# Encode each label using IDNA # Encode each label using IDNA
@@ -72,9 +77,9 @@ def encode_dnsname(hostname):
except UnicodeError as e: except UnicodeError as e:
raise EncodingError(f"Invalid label '{label}': {e}") raise EncodingError(f"Invalid label '{label}': {e}")
if len(encoded) > 63: if len(encoded) > MAX_LABEL_LENGTH:
raise EncodingError( raise EncodingError(
f"Label '{label}' too long (max 63 characters)" f"Label '{label}' too long (max {MAX_LABEL_LENGTH} characters)"
) )
if not LABEL_PATTERN.match(encoded): if not LABEL_PATTERN.match(encoded):
@@ -209,7 +214,7 @@ def parse_bind_key_file(path):
except DNSError: except DNSError:
raise raise
except Exception as e: except Exception as e:
raise DNSError(f"Failed to parse key file {path}: {e}") raise DNSError(f"Failed to parse key file {path}: {e}") from e
class DNSService: class DNSService:
+16 -8
View File
@@ -21,7 +21,7 @@ from peewee import (
from playhouse.pool import PooledMySQLDatabase from playhouse.pool import PooledMySQLDatabase
# Re-export PeeweeException as DatabseException, DoesNotExist and # Re-export PeeweeException as DatabaseError, DoesNotExist and
# EncodingError for convenience # EncodingError for convenience
__all__ = [ __all__ = [
'db', 'db',
@@ -36,6 +36,7 @@ __all__ = [
'get_hostname_for_user', 'get_hostname_for_user',
'get_permission', 'get_permission',
'get_user', 'get_user',
'close_database',
'DoesNotExist', 'DoesNotExist',
'EncodingError', 'EncodingError',
'DatabaseError', 'DatabaseError',
@@ -165,7 +166,7 @@ TABLE_TO_MODEL = {
} }
def init_database(config: dict): def init_database(config):
""" """
Initialize database connection based on config. Initialize database connection based on config.
@@ -210,7 +211,7 @@ def init_database(config: dict):
db.connect() db.connect()
def _migrate_table_sqlite(model_class, from_version: int, column_map: dict): def _migrate_table_sqlite(model_class, from_version, column_map):
""" """
Migrate a single SQLite table using Peewee model for schema. Migrate a single SQLite table using Peewee model for schema.
@@ -257,7 +258,7 @@ def _migrate_table_sqlite(model_class, from_version: int, column_map: dict):
db.execute_sql(f'DROP TABLE "{backup_name}"') db.execute_sql(f'DROP TABLE "{backup_name}"')
def _migrate_sqlite(from_version: int, to_version: int): def _migrate_sqlite(from_version, to_version):
"""Migrate SQLite from from_version to to_version.""" """Migrate SQLite from from_version to to_version."""
if to_version == 3: if to_version == 3:
_migrate_v3_create_permissions() _migrate_v3_create_permissions()
@@ -283,7 +284,7 @@ def _migrate_v3_create_permissions():
) )
def _migrate_mariadb(to_version: int): def _migrate_mariadb(to_version):
"""Migrate MariaDB to target version using ALTER TABLE.""" """Migrate MariaDB to target version using ALTER TABLE."""
if to_version == 2: if to_version == 2:
db.execute_sql('ALTER TABLE hostnames DROP INDEX hostnames_hostname') db.execute_sql('ALTER TABLE hostnames DROP INDEX hostnames_hostname')
@@ -332,7 +333,7 @@ def create_tables():
logging.debug("Database tables created") logging.debug("Database tables created")
def get_user(username: str) -> User: def get_user(username):
""" """
Get user by username. Get user by username.
@@ -399,7 +400,7 @@ def get_permission(user, fqdn):
raise DoesNotExist raise DoesNotExist
def get_hostname(hostname: str, zone: str) -> Hostname: def get_hostname(hostname, zone):
""" """
Get hostname by name and zone. Get hostname by name and zone.
@@ -428,7 +429,7 @@ def get_hostname(hostname: str, zone: str) -> Hostname:
def get_hostname_for_user( def get_hostname_for_user(
user: User, hostname: str, zone: str, dns_ttl: int, expiry_ttl: int): user: User, hostname: str, zone: str, dns_ttl: int, expiry_ttl: int):
""" """
Get hostname if it exists or create a new intance. Get hostname if it exists or create a new instance.
Args: Args:
user: User requesting access. user: User requesting access.
@@ -466,3 +467,10 @@ def get_hostname_for_user(
), ),
True True
) )
def close_database():
"""Close database connection."""
if not db.is_closed():
db.close()
logging.debug("Database connection closed")
+108 -112
View File
@@ -26,6 +26,7 @@ from .cleanup import ExpiredRecordsCleanupThread, RateLimitCleanupThread
from .dns import detect_ip_type from .dns import detect_ip_type
from .logging import clear_txn_id, set_txn_id from .logging import clear_txn_id, set_txn_id
from .models import ( from .models import (
close_database,
DatabaseError, DatabaseError,
DoesNotExist, DoesNotExist,
EncodingError, EncodingError,
@@ -38,6 +39,9 @@ from concurrent.futures import ThreadPoolExecutor
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlparse from urllib.parse import parse_qs, urlparse
# Graceful shutdown timeout (seconds)
SHUTDOWN_TIMEOUT = 5
def extract_param(params, aliases): def extract_param(params, aliases):
"""Extract first matching param from query params.""" """Extract first matching param from query params."""
@@ -240,6 +244,99 @@ class DDNSRequestHandler(BaseHTTPRequestHandler):
pass pass
return None, None return None, None
def _parse_ip_params(self, params, endpoint, client_ip, username,
hostname_param):
"""Parse and validate IP address parameters."""
ipv4 = None
ipv6 = None
# Process myip parameter
myip = extract_param(params, endpoint["params"]["ipv4"])
if myip:
try:
rtype, myip = detect_ip_type(myip)
if rtype == "A":
ipv4 = myip
else:
ipv6 = myip
except ValueError:
raise DDNSClientError(
"Bad IP address", 400, STATUS_BADIP,
client=client_ip, username=username,
hostname=hostname_param, ip=myip
)
# Process myip6 parameter
myip6 = extract_param(params, endpoint["params"]["ipv6"])
if myip6:
try:
rtype, myip6 = detect_ip_type(myip6)
if rtype != "AAAA":
raise ValueError
ipv6 = myip6
except ValueError:
raise DDNSClientError(
"Bad IPv6 address", 400, STATUS_BADIP,
client=client_ip, username=username,
hostname=hostname_param, ipv6=myip6
)
# Auto-detect from client IP if no params
if ipv4 is None and ipv6 is None:
rtype, ip = detect_ip_type(client_ip)
if rtype == "A":
ipv4 = ip
else:
ipv6 = ip
return ipv4, ipv6
def _parse_expiry_ttl(self, params, endpoint, client_ip, username,
hostname_param):
"""Parse and validate expiry_ttl parameter."""
expiry_ttl_param = extract_param(params, endpoint["params"]["expiry_ttl"])
if not expiry_ttl_param:
return None
try:
expiry_ttl = int(expiry_ttl_param)
if expiry_ttl < 0:
raise ValueError
except ValueError:
raise DDNSClientError(
"Invalid expiry_ttl", 400, STATUS_NOHOST,
client=client_ip, username=username,
hostname=hostname_param, expiry_ttl=expiry_ttl_param
)
# Validate bounds
defaults = self.app.config["defaults"]
if expiry_ttl == 0:
if not defaults["expiry_ttl_allow_zero"]:
raise DDNSClientError(
"Zero expiry_ttl not allowed", 400, STATUS_NOHOST,
client=client_ip, username=username,
hostname=hostname_param, expiry_ttl=expiry_ttl
)
else:
ttl_min = defaults["expiry_ttl_min"]
if ttl_min is not None and expiry_ttl < ttl_min:
raise DDNSClientError(
"expiry_ttl below minimum", 400, STATUS_NOHOST,
client=client_ip, username=username,
hostname=hostname_param, expiry_ttl=expiry_ttl, min=ttl_min
)
ttl_max = defaults["expiry_ttl_max"]
if ttl_max is not None and expiry_ttl > ttl_max:
raise DDNSClientError(
"expiry_ttl above maximum", 400, STATUS_NOHOST,
client=client_ip, username=username,
hostname=hostname_param, expiry_ttl=expiry_ttl, max=ttl_max
)
return expiry_ttl
def do_GET(self): def do_GET(self):
"""Handle GET requests.""" """Handle GET requests."""
set_txn_id() set_txn_id()
@@ -306,7 +403,7 @@ class DDNSRequestHandler(BaseHTTPRequestHandler):
"Auth failed", "Auth failed",
401, 401,
STATUS_BADAUTH, STATUS_BADAUTH,
client_ip client_ip=client_ip
) )
# Process hostname parameter # Process hostname parameter
@@ -320,55 +417,9 @@ class DDNSRequestHandler(BaseHTTPRequestHandler):
username=username username=username
) )
# Process myip parameter # Parse IP parameters
ipv4 = None ipv4, ipv6 = self._parse_ip_params(
myip = extract_param(params, endpoint["params"]["ipv4"]) params, endpoint, client_ip, username, hostname_param)
if myip:
try:
rtype, myip = detect_ip_type(myip)
if rtype == "A":
ipv4 = myip
else:
ipv6 = myip
except ValueError:
raise DDNSClientError(
"Bad IP address",
400,
STATUS_BADIP,
client=client_ip,
username=username,
hostname=hostname_param,
ip=myip
)
# Process myip6 parameter
ipv6 = None
myip6 = extract_param(params, endpoint["params"]["ipv6"])
if myip6:
try:
rtype, myip6 = detect_ip_type(myip6)
if rtype == "AAAA":
ipv6 = myip6
else:
raise ValueError
except ValueError:
raise DDNSClientError(
"Bad IPv6 address",
400,
STATUS_BADIP,
client=client_ip,
username=username,
hostname=hostname_param,
ipv6=myip6
)
# Auto-detect from client IP if no params
if ipv4 is None and ipv6 is None:
rtype, ip = detect_ip_type(client_ip)
if rtype == "A":
ipv4 = ip
else:
ipv6 = ip
# Process notify_change parameter # Process notify_change parameter
notify_change = extract_param( notify_change = extract_param(
@@ -377,65 +428,9 @@ class DDNSRequestHandler(BaseHTTPRequestHandler):
["1", "y", "yes", "on", "true"] ["1", "y", "yes", "on", "true"]
if notify_change else False) if notify_change else False)
# Process expiry_ttl parameter # Parse expiry_ttl parameter
expiry_ttl_param = extract_param( expiry_ttl = self._parse_expiry_ttl(
params, endpoint["params"]["expiry_ttl"]) params, endpoint, client_ip, username, hostname_param)
expiry_ttl = None
if expiry_ttl_param:
try:
expiry_ttl = int(expiry_ttl_param)
if expiry_ttl < 0:
raise ValueError
except ValueError:
raise DDNSClientError(
"Invalid expiry_ttl",
400,
STATUS_NOHOST,
client=client_ip,
username=username,
hostname=hostname_param,
expiry_ttl=expiry_ttl_param
)
# Validate bounds
defaults = self.app.config["defaults"]
if expiry_ttl == 0:
if not defaults["expiry_ttl_allow_zero"]:
raise DDNSClientError(
"Zero expiry_ttl not allowed",
400,
STATUS_NOHOST,
client=client_ip,
username=username,
hostname=hostname_param,
expiry_ttl=expiry_ttl
)
else:
ttl_min = defaults["expiry_ttl_min"]
if ttl_min is not None and expiry_ttl < ttl_min:
raise DDNSClientError(
"expiry_ttl below minimum",
400,
STATUS_NOHOST,
client=client_ip,
username=username,
hostname=hostname_param,
expiry_ttl=expiry_ttl,
min=ttl_min
)
ttl_max = defaults["expiry_ttl_max"]
if ttl_max is not None and expiry_ttl > ttl_max:
raise DDNSClientError(
"expiry_ttl above maximum",
400,
STATUS_NOHOST,
client=client_ip,
username=username,
hostname=hostname_param,
expiry_ttl=expiry_ttl,
max=ttl_max
)
# Validate credentials # Validate credentials
user = self._authenticate(client_ip, username, password) user = self._authenticate(client_ip, username, password)
@@ -753,12 +748,13 @@ def run_daemon(app):
server.handle_request() server.handle_request()
# Graceful shutdown - wait for active requests # Graceful shutdown - wait for active requests
server.wait_for_requests(5) server.wait_for_requests(SHUTDOWN_TIMEOUT)
# Cleanup # Cleanup
expired_cleanup_thread.stop() expired_cleanup_thread.stop()
ratelimit_cleanup_thread.stop() ratelimit_cleanup_thread.stop()
expired_cleanup_thread.join(timeout=5) expired_cleanup_thread.join(timeout=SHUTDOWN_TIMEOUT)
ratelimit_cleanup_thread.join(timeout=5) ratelimit_cleanup_thread.join(timeout=SHUTDOWN_TIMEOUT)
server.server_close() server.server_close()
close_database()
logging.info("Daemon stopped") logging.info("Daemon stopped")