Compare commits

..

25 Commits

Author SHA1 Message Date
2d61ad11b3 Add DNS query function 2026-02-01 17:03:33 +01:00
e37a9e84a6 Add pragmas to SQLite DBs and add retry logic to BaseModel 2026-02-01 17:01:35 +01:00
21b5a4c553 Fix bug when responding with nochange or success 2026-02-01 05:09:09 +01:00
5d2b9c3ffb Cleanup: use max of last update and start time to determine expiry time 2026-02-01 04:57:22 +01:00
cd5979556c Refactor and further improve error handling / logging 2026-02-01 04:25:21 +01:00
60ebf4b387 Improve cleanup logic 2026-02-01 01:36:52 +01:00
105a9d4253 Handle naive datetime in database with custom database field 2026-02-01 01:27:48 +01:00
870a1b9f00 Improve error handling and logging 2026-02-01 00:31:16 +01:00
89e63858a0 Add reload function to OpenRC init script 2026-01-24 04:03:51 +01:00
e365fa7d77 Change README.md and config.example.toml 2026-01-24 04:03:26 +01:00
b97eb0404c Improve timezone handling, db models convert naive/timezone-aware 2026-01-24 02:27:57 +01:00
07e37e525c Use db model hostname validation in cli and improve exception handling 2026-01-24 00:46:48 +01:00
cde4b879c1 Add missing docstrings and examples 2026-01-23 23:07:25 +01:00
255c0ad1dd Shutdown daemon gracefully 2026-01-23 22:26:46 +01:00
a1e3ee1770 Add config reload via SIGHUP 2026-01-23 22:21:34 +01:00
bd0c930060 Add documentation about datetime handling 2026-01-23 22:15:25 +01:00
2123b5169b Change _process_ip_update to always return a set of three values 2026-01-23 22:11:43 +01:00
5bb37fde71 Remove duplicate config defaults in ratelimit.py 2026-01-23 22:08:20 +01:00
444db3f190 Add hostname validation to database model 2026-01-23 22:07:34 +01:00
8b186d6e95 Return status constants instead of strings 2026-01-23 21:25:33 +01:00
faa1e4afd5 Add thread_pool_size and request_timeout config settings 2026-01-23 21:20:57 +01:00
6c382ae60c Use MariaDB connection pooling instead of single connection 2026-01-23 21:15:42 +01:00
2381d2e1d2 Change to peewee DatabaseProxy to handle different DB types 2026-01-23 21:12:15 +01:00
feb4a67291 Use proper SQL concatenation 2026-01-23 19:41:37 +01:00
f0b924ea56 Use context manager for SMTP 2026-01-23 19:40:58 +01:00
14 changed files with 771 additions and 297 deletions

View File

@@ -73,6 +73,7 @@ Example:
```toml ```toml
[daemon] [daemon]
# host = "localhost" # default: "localhost" (use reverse proxy for public access) # host = "localhost" # default: "localhost" (use reverse proxy for public access)
# # Use "0.0.0.0" for IPv4-only, "::" for IPv6-only (dual-stack depends on OS)
# port = 8443 # default: 8443 # port = 8443 # default: 8443
# log_level = "INFO" # default: "INFO" # log_level = "INFO" # default: "INFO"
# log_target = "stdout" # default: "stdout", or "syslog", "file" # log_target = "stdout" # default: "stdout", or "syslog", "file"
@@ -87,10 +88,13 @@ ssl_cert_file = "/etc/ddns-service/cert.pem" # required if ssl = true
ssl_key_file = "/etc/ddns-service/key.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" # proxy_header = "" # default: "" (disabled), e.g. "X-Forwarded-For"
# trusted_proxies = [] # default: [], e.g. ["127.0.0.1", "10.0.0.0/8"] # trusted_proxies = [] # default: [], e.g. ["127.0.0.1", "10.0.0.0/8"]
# thread_pool_size = 10 # default: 10
# request_timeout = 10 # default: 10 (seconds)
[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)
[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)
@@ -276,6 +280,16 @@ ddns-service --daemon
ddns-service --daemon --debug ddns-service --daemon --debug
``` ```
### Signals
- **SIGHUP**: Reload configuration (all settings except database, host, port; SSL certs are reloaded)
- **SIGTERM/SIGINT**: Graceful shutdown (waits up to 5 seconds for active requests)
```bash
# Reload config
kill -HUP $(pidof ddns-service)
```
## HTTP API ## HTTP API
### Request ### Request

View File

@@ -1,5 +1,6 @@
[daemon] [daemon]
# host = "localhost" # default, use reverse proxy for public access! # host = "localhost" # default, use reverse proxy for public access!
# # Use "0.0.0.0" for IPv4-only, "::" for IPv6-only (dual-stack depends on OS)
# port = 8443 # default # port = 8443 # default
# log_level = "INFO" # default # log_level = "INFO" # default
# log_target = "stdout" # default, "stdout", "syslog" or "file" # log_target = "stdout" # default, "stdout", "syslog" or "file"
@@ -14,6 +15,8 @@ ssl_cert_file = "/etc/ddns-service/cert.pem" # required if ssl = true
ssl_key_file = "/etc/ddns-service/key.pem" # required if ssl = true ssl_key_file = "/etc/ddns-service/key.pem" # required if ssl = true
# proxy_header = "" # default (disabled), header name e.g. "X-Forwarded-For" # proxy_header = "" # default (disabled), header name e.g. "X-Forwarded-For"
# trusted_proxies = [] # default, list of trusted proxy IPs/CIDRs # trusted_proxies = [] # default, list of trusted proxy IPs/CIDRs
# thread_pool_size = 10 # default, max concurrent request handlers
# request_timeout = 10 # default, socket timeout in seconds
[database] [database]
# backend = "sqlite" # default, "sqlite", or "mariadb" # backend = "sqlite" # default, "sqlite", or "mariadb"
@@ -23,6 +26,7 @@ path = "/var/lib/ddns-service/ddns.db" # required for sqlite
# user = "ddns" # required for mariadb # user = "ddns" # required for mariadb
# password = "secret" # required for mariadb # password = "secret" # required for mariadb
# database = "ddns" # required for mariadb # database = "ddns" # required for mariadb
# pool_size = 5 # default, MariaDB connection pool size
[dns_service] [dns_service]
# dns_server = "127.0.0.1" # default, must be IP address # dns_server = "127.0.0.1" # default, must be IP address

View File

@@ -20,3 +20,9 @@ depend() {
start_pre() { start_pre() {
checkpath --directory --owner ${USER}:${GROUP} --mode 0750 /var/lib/ddns-service checkpath --directory --owner ${USER}:${GROUP} --mode 0750 /var/lib/ddns-service
} }
reload() {
ebegin "Reloading ${RC_SVCNAME}"
start-stop-daemon --signal HUP --pidfile "${pidfile}"
eend $?
}

View File

@@ -15,20 +15,110 @@ __all__ = [
"cleanup", "cleanup",
"cli", "cli",
"config", "config",
"datetime_aware_utc",
"datetime_naive_utc",
"datetime_str", "datetime_str",
"dns", "dns",
"email", "email",
"logging", "logging",
"main", "main",
"models", "models",
"now_utc"
"ratelimit", "ratelimit",
"server", "server",
"STATUS_GOOD",
"STATUS_NOCHG",
"STATUS_BADAUTH",
"STATUS_NOHOST",
"STATUS_DNSERR",
"STATUS_ABUSE",
"STATUS_BADIP",
] ]
# DynDNS-compatible response statuses
STATUS_GOOD = "good"
STATUS_NOCHG = "nochg"
STATUS_BADAUTH = "badauth"
STATUS_NOHOST = "nohost"
STATUS_DNSERR = "dnserr"
STATUS_ABUSE = "abuse"
STATUS_BADIP = "badip"
DATETIME_FORMAT = "%Y-%m-%d %H:%M:%S %Z" DATETIME_FORMAT = "%Y-%m-%d %H:%M:%S %Z"
# Datetime convention:
# All datetime objects in this codebase are timezone-aware.
# - now_utc(): returns timezone-aware UTC datetime
# - datetime_str(): converts naive UTC (adds tzinfo for formatting)
# or timezone-aware datetime to display string
# - Database stores/returns naive datetimes (always UTC by convention)
# - Database models automatically convert between naive/timezone-aware datetimes
def now_utc():
"""
Get current date and time in UTC.
Returns:
Timezone-aware datetime object in UTC.
"""
return datetime.datetime.now(datetime.UTC)
def datetime_naive_utc(dt):
"""
Convert datetime to naive UTC datetime.
Args:
dt: Datetime object (naive UTC or timezone-aware or None).
Returns:
Naive datetime object in UTC or None if dt is not a datetime.
"""
if not isinstance(dt, datetime.datetime):
return None
if not dt.tzinfo:
return dt
return dt.astimezone(datetime.UTC).replace(tzinfo=None)
def datetime_aware_utc(dt):
"""
Convert datetime to UTC datetime.
Args:
dt: Datetime object (naive UTC or timezone-aware or None).
Returns:
Timzone-aware datetime object in UTC or None if dt is not a datetime.
"""
if not isinstance(dt, datetime.datetime):
return None
if not dt.tzinfo:
return dt.replace(tzinfo=datetime.UTC)
if dt.tzinfo == datetime.UTC:
return dt
return dt.astimezone(datetime.UTC)
def datetime_str(dt, utc=False): def datetime_str(dt, utc=False):
"""
Convert datetime to display string.
Assumes naive datetimes are UTC per codebase convention.
Args:
dt: Datetime object (naive UTC or timezone-aware).
utc: If True, display in UTC; otherwise convert to local timezone.
Returns:
Formatted datetime string, or "Never" if dt is not a datetime.
"""
if not isinstance(dt, datetime.datetime): if not isinstance(dt, datetime.datetime):
return "Never" return "Never"
@@ -38,7 +128,3 @@ def datetime_str(dt, utc=False):
return aware_dt.strftime(DATETIME_FORMAT) return aware_dt.strftime(DATETIME_FORMAT)
else: else:
return aware_dt.astimezone().strftime(DATETIME_FORMAT) return aware_dt.astimezone().strftime(DATETIME_FORMAT)
def utc_now():
return datetime.datetime.now(datetime.UTC).replace(tzinfo=None)

View File

@@ -1,12 +1,13 @@
"""Application class - central dependency holder.""" """Application class - central dependency holder."""
import argon2
import logging import logging
import threading import threading
import argon2 from .config import load_config
from .dns import DNSService from .dns import DNSService
from .email import EmailService from .email import EmailService
from .logging import setup_logging
from .models import create_tables, init_database from .models import create_tables, init_database
from .ratelimit import BadLimiter, GoodLimiter from .ratelimit import BadLimiter, GoodLimiter
@@ -18,14 +19,16 @@ class Application:
Holds configuration and all service instances. Holds configuration and all service instances.
""" """
def __init__(self, config): def __init__(self, config, config_path=None):
""" """
Initialize application with configuration. Initialize application with configuration.
Args: Args:
config: Configuration dictionary from TOML file. config: Configuration dictionary from TOML file.
config_path: Path to configuration file (for reload).
""" """
self.config = config self.config = config
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()
@@ -57,6 +60,39 @@ class Application:
self.bad_limiter = BadLimiter(self.config) self.bad_limiter = BadLimiter(self.config)
logging.info("Rate limiters initialized") logging.info("Rate limiters initialized")
def reload_config(self):
"""
Reload configuration from file.
Does not reload: database settings, host, port.
"""
new_config = load_config(self.config_path)
# Preserve DB and bind settings
new_config["database"] = self.config["database"]
new_config["daemon"]["host"] = self.config["daemon"]["host"]
new_config["daemon"]["port"] = self.config["daemon"]["port"]
self.config = new_config
# Reconfigure logging
setup_logging(
level=self.config["daemon"]["log_level"],
target=self.config["daemon"]["log_target"],
syslog_socket=self.config["daemon"]["syslog_socket"],
syslog_facility=self.config["daemon"]["syslog_facility"],
log_file=self.config["daemon"]["log_file"],
log_file_size=self.config["daemon"]["log_file_size"],
log_versions=self.config["daemon"]["log_versions"],
)
# Re-init services
self.init_dns()
self.init_email()
self.init_rate_limiters()
logging.info("Configuration reloaded")
def signal_shutdown(self): def signal_shutdown(self):
"""Signal the application to shut down.""" """Signal the application to shut down."""
logging.info("Shutdown signaled") logging.info("Shutdown signaled")

View File

@@ -3,22 +3,23 @@
import logging import logging
import threading import threading
from . import utc_now from . import now_utc
from .models import Hostname, User from .models import Hostname, User
from datetime import timedelta from datetime import timedelta
def cleanup_expired(app): def cleanup_expired(app, start_time=None):
""" """
Clean up expired hostnames and return count of cleaned entries. Clean up expired hostnames and return count of cleaned entries.
Args: Args:
app: Application instance with dns_service and email_service. app: Application instance with dns_service and email_service.
start_time: Timezone aware datetime object containg the start time of the cleanup thread.
Returns: Returns:
Number of expired hostnames processed. Number of expired hostnames processed.
""" """
now = utc_now() now = now_utc()
expired_count = 0 expired_count = 0
for hostname in Hostname.select().join(User).where( for hostname in Hostname.select().join(User).where(
@@ -30,12 +31,14 @@ def cleanup_expired(app):
ipv6_expired = False ipv6_expired = False
if hostname.last_ipv4: if hostname.last_ipv4:
expiry_time = hostname.last_ipv4_update + timedelta(seconds=hostname.expiry_ttl) last_update = max(hostname.last_ipv4_update, start_time)
expiry_time = last_update + timedelta(seconds=hostname.expiry_ttl)
if now > expiry_time: if now > expiry_time:
ipv4_expired = True ipv4_expired = True
if hostname.last_ipv6: if hostname.last_ipv6:
expiry_time = hostname.last_ipv6_update + timedelta(seconds=hostname.expiry_ttl) last_update = max(hostname.last_ipv6_update, start_time)
expiry_time = last_update + timedelta(seconds=hostname.expiry_ttl)
if now > expiry_time: if now > expiry_time:
ipv6_expired = True ipv6_expired = True
@@ -48,38 +51,43 @@ def cleanup_expired(app):
if app.dns_service: if app.dns_service:
if ipv4_expired: if ipv4_expired:
logging.info( logging.info(
f"Host expired: hostname={hostname.hostname} zone={hostname.zone} " f"Cleanup: Host expired: hostname={hostname.hostname} zone={hostname.zone} "
f"ip={hostname.last_ipv4}" f"ipv4={hostname.last_ipv4}"
) )
try: try:
app.dns_service.delete_record(hostname.hostname, hostname.zone, "A") app.dns_service.delete_record(hostname.hostname, hostname.zone, "A")
ipv4_deleted = True ipv4_deleted = True
except Exception as e: except Exception as e:
logging.error(f"DNS error: {e}")
logging.error( logging.error(
f"DNS delete failed: hostname={hostname.hostname} " f"Cleanup failed: hostname={hostname.hostname} "
f"zone={hostname.zone} type=A error={e}" f"zone={hostname.zone} type=A"
) )
if ipv6_expired: if ipv6_expired:
logging.info( logging.info(
f"Host expired: hostname={hostname.hostname} zone={hostname.zone} " f"Cleanup: Host expired: hostname={hostname.hostname} zone={hostname.zone} "
f"ip={hostname.last_ipv6}" f"ipv6={hostname.last_ipv6}"
) )
try: try:
app.dns_service.delete_record(hostname.hostname, hostname.zone, "AAAA") app.dns_service.delete_record(hostname.hostname, hostname.zone, "AAAA")
ipv6_deleted = True ipv6_deleted = True
except Exception as e: except Exception as e:
logging.error(f"DNS error: {e}")
logging.error( logging.error(
f"DNS delete failed: hostname={hostname.hostname} " f"Cleanup failed: hostname={hostname.hostname} "
f"zone={hostname.zone} type=AAAA error={e}" f"zone={hostname.zone} type=AAAA"
) )
if not (ipv4_deleted or ipv6_deleted):
continue
if app.email_service: if app.email_service:
app.email_service.send_expiry_notification( app.email_service.send_expiry_notification(
hostname.user.email, hostname.user.email,
hostname, hostname,
ipv4_expired, ipv4_deleted,
ipv6_expired ipv6_deleted
) )
# Clear IP addresses only if DNS delete succeeded # Clear IP addresses only if DNS delete succeeded
@@ -88,7 +96,6 @@ def cleanup_expired(app):
if ipv6_deleted: if ipv6_deleted:
hostname.last_ipv6 = None hostname.last_ipv6 = None
if ipv4_deleted or ipv6_deleted:
hostname.save() hostname.save()
expired_count += 1 expired_count += 1
@@ -113,10 +120,11 @@ class ExpiredRecordsCleanupThread(threading.Thread):
def run(self): def run(self):
"""Run the cleanup loop.""" """Run the cleanup loop."""
logging.info(f"Expired records cleanup thread started: interval={self.interval}s") logging.info(f"Expired records cleanup thread started: interval={self.interval}s")
start_time = now_utc()
while not self.stop_event.wait(self.interval): while not self.stop_event.wait(self.interval):
try: try:
count = cleanup_expired(self.app) count = cleanup_expired(self.app, start_time)
if count > 0: if count > 0:
logging.info(f"Expired records cleanup completed: count={count}") logging.info(f"Expired records cleanup completed: count={count}")
except Exception as e: except Exception as e:

View File

@@ -1,18 +1,19 @@
"""CLI commands for user and hostname management.""" """CLI commands for user and hostname management."""
import getpass import getpass
import logging
from . import datetime_str from . import datetime_str
from .cleanup import cleanup_expired from .cleanup import cleanup_expired
from .dns import encode_dnsname
from .models import ( from .models import (
DatabaseError,
DoesNotExist, DoesNotExist,
EncodingError,
get_hostname, get_hostname,
get_user, get_user,
Hostname, Hostname,
User, User,
) )
from .dns import encode_dnsname, EncodingError
def cmd_user_list(args, app): def cmd_user_list(args, app):
@@ -166,24 +167,8 @@ def cmd_hostname_list(args, app):
def cmd_hostname_add(args, app): def cmd_hostname_add(args, app):
"""Add a hostname.""" """Add a hostname."""
username = args.username username = args.username
config = app.config
# Validate and encode hostname/zone
try: try:
hostname_str = encode_dnsname(args.hostname)
zone = encode_dnsname(args.zone)
except EncodingError as e:
print(f"Error: {e}")
return 1
# Get TTLs from args or config defaults
dns_ttl = args.dns_ttl
if dns_ttl is None:
dns_ttl = config["defaults"]["dns_ttl"]
expiry_ttl = args.expiry_ttl
if expiry_ttl is None:
expiry_ttl = config["defaults"]["expiry_ttl"]
# Get user # Get user
try: try:
user = get_user(username) user = get_user(username)
@@ -192,40 +177,59 @@ def cmd_hostname_add(args, app):
return 1 return 1
# Check if hostname+zone exists # Check if hostname+zone exists
if Hostname.select().where( try:
(Hostname.hostname == hostname_str) & (Hostname.zone == zone) hostname = get_hostname(args.hostname, args.zone)
).exists(): print(f"Error: Hostname '{hostname.hostname}' in zone '{hostname.zone}' exists.")
print(f"Error: Hostname '{hostname_str}' in zone '{zone}' exists.")
return 1 return 1
except EncodingError as e:
print(f"Error: {e}")
return 1
except DoesNotExist:
pass
# Get TTLs from args or config defaults
config = app.config
dns_ttl = args.dns_ttl
if dns_ttl is None:
dns_ttl = config["defaults"]["dns_ttl"]
expiry_ttl = args.expiry_ttl
if expiry_ttl is None:
expiry_ttl = config["defaults"]["expiry_ttl"]
# Create hostname # Create hostname
Hostname.create( hostname = Hostname.create(
user=user, user=user,
hostname=hostname_str, hostname=args.hostname,
zone=zone, zone=args.zone,
dns_ttl=dns_ttl, dns_ttl=dns_ttl,
expiry_ttl=expiry_ttl expiry_ttl=expiry_ttl
) )
print(f"Hostname '{hostname_str}' added for user '{username}'.") print(
f"Hostname '{hostname.hostname}' in zone '{hostname.zone}' added "
f"for user '{username}'."
)
except DatabaseError as e:
print(f"Database error: {e}")
return 1
return 0 return 0
def cmd_hostname_delete(args, app): def cmd_hostname_delete(args, app):
"""Delete a hostname.""" """Delete a hostname."""
# Validate and encode hostname and zone
try: try:
hostname_str = encode_dnsname(args.hostname) try:
hostname = get_hostname(args.hostname, args.zone)
except DoesNotExist:
hostname = encode_dnsname(args.hostname)
zone = encode_dnsname(args.zone) zone = encode_dnsname(args.zone)
print(f"Error: Hostname '{hostname}' in zone '{zone}' not found.")
return 1
except EncodingError as e: except EncodingError as e:
print(f"Error: {e}") print(f"Error: {e}")
return 1 return 1
try:
hostname = get_hostname(hostname_str, zone)
except DoesNotExist:
print(f"Error: Hostname '{hostname_str}' in zone '{zone}' not found.")
return 1
# Delete DNS records if active # Delete DNS records if active
if hostname.last_ipv4 or hostname.last_ipv6: if hostname.last_ipv4 or hostname.last_ipv6:
# Initialize DNS service if not already # Initialize DNS service if not already
@@ -233,45 +237,50 @@ def cmd_hostname_delete(args, app):
try: try:
app.init_dns() app.init_dns()
except Exception as e: except Exception as e:
logging.warning(f"DNS init failed: {e}") print(f"DNS init failed: {e}")
return 1
if app.dns_service:
if hostname.last_ipv4: if hostname.last_ipv4:
try: try:
app.dns_service.delete_record( app.dns_service.delete_record(
hostname.hostname, hostname.zone, "A" hostname.hostname, hostname.zone, "A"
) )
except Exception as e: except Exception as e:
logging.warning(f"DNS delete failed: type=A error={e}") print(f"DNS delete failed: type=A error={e}")
return 1
if hostname.last_ipv6: if hostname.last_ipv6:
try: try:
app.dns_service.delete_record( app.dns_service.delete_record(
hostname.hostname, hostname.zone, "AAAA" hostname.hostname, hostname.zone, "AAAA"
) )
except Exception as e: except Exception as e:
logging.warning(f"DNS delete failed: type=AAAA error={e}") print(f"DNS delete failed: type=AAAA error={e}")
return 1
hostname.delete_instance() hostname.delete_instance()
print(f"Hostname '{hostname_str}' in zone '{zone}' deleted.") print(f"Hostname '{hostname.hostname}' in zone '{hostname.zone}' deleted.")
except DatabaseError as e:
print(f"Database error: {e}")
return 1
return 0 return 0
def cmd_hostname_modify(args, app): def cmd_hostname_modify(args, app):
"""Modify hostname settings.""" """Modify hostname settings."""
# Validate and encode hostname and zone
try: try:
hostname_str = encode_dnsname(args.hostname) try:
hostname = get_hostname(args.hostname, args.zone)
except DoesNotExist:
hostname = encode_dnsname(args.hostname)
zone = encode_dnsname(args.zone) zone = encode_dnsname(args.zone)
print(f"Error: Hostname '{hostname}' in zone '{zone}' not found.")
return 1
except EncodingError as e: except EncodingError as e:
print(f"Error: {e}") print(f"Error: {e}")
return 1 return 1
try:
hostname = get_hostname(hostname_str, zone)
except DoesNotExist:
print(f"Error: Hostname '{hostname_str}' in zone '{zone}' not found.")
return 1
# Get new TTLs # Get new TTLs
dns_ttl = args.dns_ttl if args.dns_ttl is not None else hostname.dns_ttl dns_ttl = args.dns_ttl if args.dns_ttl is not None else hostname.dns_ttl
expiry_ttl = args.expiry_ttl if args.expiry_ttl is not None else hostname.expiry_ttl expiry_ttl = args.expiry_ttl if args.expiry_ttl is not None else hostname.expiry_ttl
@@ -280,9 +289,13 @@ def cmd_hostname_modify(args, app):
hostname.expiry_ttl = expiry_ttl hostname.expiry_ttl = expiry_ttl
hostname.save() hostname.save()
print( print(
f"Hostname '{hostname_str}' updated: " f"Hostname '{hostname.hostname}' in zone '{hostname.zone}' updated: "
f"dns_ttl={dns_ttl}, expiry_ttl={expiry_ttl}" f"dns_ttl={dns_ttl}, expiry_ttl={expiry_ttl}"
) )
except DatabaseError as e:
print(f"Database error: {e}")
return 1
return 0 return 0
@@ -293,11 +306,20 @@ def cmd_cleanup(args, app):
try: try:
app.init_dns() app.init_dns()
except Exception as e: except Exception as e:
logging.warning(f"DNS init failed: {e}") print(f"DNS init failed: {e}")
return 1
if app.email_service is None: if app.email_service is None:
app.init_email() app.init_email()
try:
count = cleanup_expired(app) count = cleanup_expired(app)
print(f"Cleanup complete: {count} expired hostname(s) processed.") print(f"Cleanup complete: {count} expired hostname(s) processed.")
except DatabaseError as e:
print(f"Database error: {e}")
return 1
except Exception as e:
print(f"Error: {e}")
return 1
return 0 return 0

View File

@@ -141,9 +141,12 @@ def load_config(config_path):
cfg["daemon"].setdefault("ssl", False) cfg["daemon"].setdefault("ssl", False)
cfg["daemon"].setdefault("proxy_header", "") cfg["daemon"].setdefault("proxy_header", "")
cfg["daemon"].setdefault("trusted_proxies", []) cfg["daemon"].setdefault("trusted_proxies", [])
cfg["daemon"].setdefault("thread_pool_size", 10)
cfg["daemon"].setdefault("request_timeout", 10)
cfg.setdefault("database", {}) cfg.setdefault("database", {})
cfg["database"].setdefault("backend", "sqlite") cfg["database"].setdefault("backend", "sqlite")
cfg["database"].setdefault("pool_size", 5)
cfg.setdefault("dns_service", {}) cfg.setdefault("dns_service", {})
cfg["dns_service"].setdefault("dns_server", "127.0.0.1") cfg["dns_service"].setdefault("dns_server", "127.0.0.1")

View File

@@ -10,6 +10,7 @@ import dns.name
import dns.query import dns.query
import dns.rcode import dns.rcode
import dns.rdatatype import dns.rdatatype
import dns.resolver
import dns.tsigkeyring import dns.tsigkeyring
import dns.update import dns.update
@@ -37,6 +38,12 @@ def encode_dnsname(hostname):
Raises: Raises:
EncodingError: If hostname is invalid. EncodingError: If hostname is invalid.
Example:
>>> encode_dnsname("münchen")
'xn--mnchen-3ya'
>>> encode_dnsname("example.com.")
'example.com'
""" """
hostname = hostname.lower().strip() hostname = hostname.lower().strip()
@@ -84,6 +91,24 @@ def encode_dnsname(hostname):
def detect_ip_type(ip): def detect_ip_type(ip):
"""
Detect IP address type and normalize.
Args:
ip: IP address string.
Returns:
Tuple of (record_type, normalized_ip).
Raises:
ValueError: If IP address is invalid.
Example:
>>> detect_ip_type("192.168.1.1")
('A', '192.168.1.1')
>>> detect_ip_type("2001:db8::1")
('AAAA', '2001:db8::1')
"""
try: try:
addr = ipaddress.ip_address(ip) addr = ipaddress.ip_address(ip)
if isinstance(addr, ipaddress.IPv4Address): if isinstance(addr, ipaddress.IPv4Address):
@@ -127,6 +152,16 @@ def parse_bind_key_file(path):
Raises: Raises:
DNSError: If parsing fails. DNSError: If parsing fails.
Example:
Key file contents::
key "ddns-key." {
algorithm hmac-sha256;
secret "base64secret==";
};
>>> keyring, algo = parse_bind_key_file("/etc/bind/ddns.key")
""" """
if not path: if not path:
return None, None return None, None
@@ -319,18 +354,54 @@ class DNSService:
return hostname[:-len(zone_suffix)] return hostname[:-len(zone_suffix)]
return hostname return hostname
def query_record(self, hostname, zone, record_type):
"""
Check if DNS record exists.
Args:
hostname: Hostname (without zone suffix).
zone: DNS zone name.
record_type: Record type string (A or AAAA).
Returns:
IP address string if record exists, None otherwise.
"""
fqdn = f"{self._get_relative_name(hostname, zone)}.{zone}"
if not fqdn.endswith("."):
fqdn += "."
try:
resolver = dns.resolver.Resolver()
resolver.nameservers = [self.server]
resolver.port = self.port
resolver.lifetime = self.timeout
answers = resolver.resolve(fqdn, record_type)
return str(answers[0]) if answers else None
except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer,
dns.resolver.NoNameservers):
return None
except Exception as e:
logging.warning(
f"DNS query failed: hostname={hostname} zone={zone} "
f"type={record_type}: {e}"
)
return None
def update_record(self, hostname, zone, ip, ttl): def update_record(self, hostname, zone, ip, ttl):
""" """
Update a DNS record for the given hostname. Update a DNS record for the given hostname.
Args: Args:
hostname: Fully qualified hostname. hostname: Hostname (without zone suffix).
zone: DNS zone name. zone: DNS zone name.
ip: IP address to set. ip: IP address to set.
ttl: DNS record TTL. ttl: DNS record TTL.
Raises: Raises:
DNSError: If update fails. DNSError: If update fails.
Example:
>>> dns_service.update_record("myhost", "example.com", "192.168.1.1", 60)
>>> dns_service.update_record("myhost", "example.com", "2001:db8::1", 60)
""" """
try: try:
record_type, normalized_ip = detect_ip_type(ip) record_type, normalized_ip = detect_ip_type(ip)

View File

@@ -76,11 +76,10 @@ class EmailService:
smtp_host = self.config["smtp_host"] smtp_host = self.config["smtp_host"]
smtp_port = self.config["smtp_port"] smtp_port = self.config["smtp_port"]
server = smtplib.SMTP(smtp_host, smtp_port) with smtplib.SMTP(smtp_host, smtp_port) as server:
if self.config.get("smtp_starttls", False): if self.config.get("smtp_starttls", False):
server.starttls() server.starttls()
try:
if self.config.get("smtp_user"): if self.config.get("smtp_user"):
server.login( server.login(
self.config["smtp_user"], self.config["smtp_user"],
@@ -89,8 +88,6 @@ class EmailService:
server.sendmail(msg["From"], [to], msg.as_string()) server.sendmail(msg["From"], [to], msg.as_string())
logging.info(f"Email sent: to={to} subject={subject}") logging.info(f"Email sent: to={to} subject={subject}")
return True return True
finally:
server.quit()
except Exception as e: except Exception as e:
logging.error(f"Email send failed: to={to} error={e}") logging.error(f"Email send failed: to={to} error={e}")

View File

@@ -153,7 +153,7 @@ def main():
) )
# Create application instance # Create application instance
app = Application(config) app = Application(config, config_path)
# Initialize database # Initialize database
try: try:

View File

@@ -2,22 +2,46 @@
import logging import logging
import os import os
import time
from . import utc_now from . import datetime_naive_utc, datetime_aware_utc, now_utc
from .dns import encode_dnsname, EncodingError
from peewee import ( from peewee import (
AutoField, AutoField,
CharField, CharField,
DatabaseProxy,
Model,
DateTimeField, DateTimeField,
DoesNotExist, DoesNotExist,
fn,
ForeignKeyField, ForeignKeyField,
IntegerField, IntegerField,
Model,
MySQLDatabase,
SqliteDatabase, SqliteDatabase,
PeeweeException as DatabaseError,
) )
from playhouse.pool import PooledMySQLDatabase
# Database instance (initialized later)
db = SqliteDatabase(None) # Re-export PeeweeException as DatabseException, DoesNotExist and
# EncodingError for convenience
__all__ = [
'db',
'DATABASE_VERSION',
'User',
'Hostname',
'Version',
'init_database',
'create_tables',
'get_user',
'get_hostname',
'get_hostname_for_user',
'DoesNotExist',
'EncodingError',
'DatabaseError',
]
# Database proxy (initialized later with actual backend)
db = DatabaseProxy()
# Current database schema version # Current database schema version
DATABASE_VERSION = 2 DATABASE_VERSION = 2
@@ -42,12 +66,34 @@ MIGRATION_COLUMN_MAPS = {
} }
class DateTimeFieldUTC(DateTimeField):
def db_value(self, value):
if value:
value = datetime_naive_utc(value)
return super().db_value(value)
def python_value(self, value):
value = super().python_value(value)
if value:
return datetime_aware_utc(value)
class BaseModel(Model): class BaseModel(Model):
"""Base model with database binding.""" """Base model with database binding and save retry."""
class Meta: class Meta:
database = db database = db
def save(self, *args, max_retries=3, retry_delay=0.1, **kwargs):
"""Save with retry on DatabaseError (exponential backoff)."""
for attempt in range(max_retries):
try:
return super().save(*args, **kwargs)
except DatabaseError:
if attempt == max_retries - 1:
raise
time.sleep(retry_delay * (2 ** attempt))
class User(BaseModel): class User(BaseModel):
"""User model for authentication.""" """User model for authentication."""
@@ -56,7 +102,7 @@ class User(BaseModel):
username = CharField(max_length=64, unique=True) username = CharField(max_length=64, unique=True)
password_hash = CharField(max_length=128) password_hash = CharField(max_length=128)
email = CharField(max_length=255) email = CharField(max_length=255)
created_at = DateTimeField(default=utc_now) created_at = DateTimeFieldUTC(default=now_utc)
class Meta: class Meta:
table_name = "users" table_name = "users"
@@ -72,9 +118,9 @@ class Hostname(BaseModel):
dns_ttl = IntegerField() dns_ttl = IntegerField()
expiry_ttl = IntegerField() expiry_ttl = IntegerField()
last_ipv4 = CharField(max_length=15, null=True) last_ipv4 = CharField(max_length=15, null=True)
last_ipv4_update = DateTimeField(null=True) last_ipv4_update = DateTimeFieldUTC(null=True)
last_ipv6 = CharField(max_length=45, null=True) last_ipv6 = CharField(max_length=45, null=True)
last_ipv6_update = DateTimeField(null=True) last_ipv6_update = DateTimeFieldUTC(null=True)
class Meta: class Meta:
table_name = "hostnames" table_name = "hostnames"
@@ -109,7 +155,6 @@ def init_database(config: dict):
Raises: Raises:
ValueError: If unknown database backend. ValueError: If unknown database backend.
""" """
global db
backend = config["database"].get("backend", "sqlite") backend = config["database"].get("backend", "sqlite")
@@ -118,21 +163,23 @@ def init_database(config: dict):
db_dir = os.path.dirname(db_path) db_dir = os.path.dirname(db_path)
if db_dir: if db_dir:
os.makedirs(db_dir, exist_ok=True) os.makedirs(db_dir, exist_ok=True)
db.init(db_path) actual_db = SqliteDatabase(db_path, pragmas={
'journal_mode': 'wal',
'busy_timeout': 5000,
})
db.initialize(actual_db)
logging.debug(f"Database backend: SQLite path={db_path}") logging.debug(f"Database backend: SQLite path={db_path}")
elif backend == "mariadb": elif backend == "mariadb":
db = MySQLDatabase( actual_db = PooledMySQLDatabase(
config["database"]["database"], config["database"]["database"],
host=config["database"].get("host", "localhost"), host=config["database"].get("host", "localhost"),
port=config["database"].get("port", 3306), port=config["database"].get("port", 3306),
user=config["database"]["user"], user=config["database"]["user"],
password=config["database"]["password"], password=config["database"]["password"],
max_connections=config["database"].get("pool_size", 5),
) )
# Re-bind models to new database db.initialize(actual_db)
User._meta.database = db
Hostname._meta.database = db
Version._meta.database = db
db_name = config['database']['database'] db_name = config['database']['database']
logging.debug(f"Database backend: MariaDB db={db_name}") logging.debug(f"Database backend: MariaDB db={db_name}")
@@ -248,7 +295,7 @@ def create_tables():
logging.debug("Database tables created") logging.debug("Database tables created")
def get_user(username: str): def get_user(username: str) -> User:
""" """
Get user by username. Get user by username.
@@ -260,11 +307,16 @@ def get_user(username: str):
Raises: Raises:
DoesNotExist: If user not found. DoesNotExist: If user not found.
Example:
>>> user = get_user("alice")
>>> print(user.email)
'alice@example.com'
""" """
return User.get(User.username == username) return User.get(User.username == username)
def get_hostname(hostname, zone): def get_hostname(hostname: str, zone: str) -> Hostname:
""" """
Get hostname by name and zone. Get hostname by name and zone.
@@ -277,9 +329,16 @@ def get_hostname(hostname, zone):
Raises: Raises:
DoesNotExist: If hostname not found. DoesNotExist: If hostname not found.
EncodingError: If hostname or zone is invalid.
Example:
>>> host = get_hostname("myhost", "example.com")
>>> print(host.last_ipv4)
'192.168.1.1'
""" """
return Hostname.get( return Hostname.get(
(Hostname.hostname == hostname) & (Hostname.zone == zone) (Hostname.hostname == encode_dnsname(hostname)) &
(Hostname.zone == encode_dnsname(zone))
) )
@@ -288,7 +347,7 @@ def get_hostname_for_user(hostname: str, user: User):
Get hostname owned by specific user. Get hostname owned by specific user.
Args: Args:
hostname: Hostname to look up. hostname: Hostname to look up (FQDN).
user: User who should own the hostname. user: User who should own the hostname.
Returns: Returns:
@@ -296,22 +355,14 @@ def get_hostname_for_user(hostname: str, user: User):
Raises: Raises:
DoesNotExist: If hostname not found or not owned by user. DoesNotExist: If hostname not found or not owned by user.
EncodingError: If hostname is invalid.
Example:
>>> user = get_user("alice")
>>> host = get_hostname_for_user("myhost.example.com", user)
""" """
fqdn = Hostname.hostname + '.' + Hostname.zone fqdn = fn.Concat(Hostname.hostname, '.', Hostname.zone)
return Hostname.get((fqdn == hostname) & (Hostname.user == user)) return Hostname.get(
(fqdn == encode_dnsname(hostname)) &
(Hostname.user == user)
# Re-export DoesNotExist for convenience )
__all__ = [
'db',
'DATABASE_VERSION',
'User',
'Hostname',
'Version',
'init_database',
'create_tables',
'get_user',
'get_hostname',
'get_hostname_for_user',
'DoesNotExist',
]

View File

@@ -85,11 +85,11 @@ class GoodLimiter(BaseLimiter):
Args: Args:
config: Full configuration dictionary. config: Full configuration dictionary.
""" """
rl = config.get("rate_limit", {}) rl = config["rate_limit"]
super().__init__( super().__init__(
rl.get("good_window_seconds", 60), rl["good_window_seconds"],
rl.get("good_max_requests", 30), rl["good_max_requests"],
rl.get("enabled", False), rl["enabled"],
False, False,
) )
@@ -104,10 +104,10 @@ class BadLimiter(BaseLimiter):
Args: Args:
config: Full configuration dictionary. config: Full configuration dictionary.
""" """
rl = config.get("rate_limit", {}) rl = config["rate_limit"]
super().__init__( super().__init__(
rl.get("bad_window_seconds", 60), rl["bad_window_seconds"],
rl.get("bad_max_requests", 5), rl["bad_max_requests"],
rl.get("enabled", False), rl["enabled"],
True, True,
) )

View File

@@ -8,17 +8,34 @@ import json
import logging import logging
import signal import signal
import ssl import ssl
import threading
from . import (
now_utc,
datetime_str,
STATUS_GOOD,
STATUS_NOCHG,
STATUS_BADAUTH,
STATUS_NOHOST,
STATUS_DNSERR,
STATUS_ABUSE,
STATUS_BADIP,
)
from .cleanup import ExpiredRecordsCleanupThread, RateLimitCleanupThread
from .dns import detect_ip_type
from .logging import clear_txn_id, set_txn_id
from .models import (
DatabaseError,
DoesNotExist,
EncodingError,
get_hostname_for_user,
get_user
)
from argon2.exceptions import VerifyMismatchError
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
import argon2
from . import datetime_str, utc_now
from .cleanup import ExpiredRecordsCleanupThread, RateLimitCleanupThread
from .logging import clear_txn_id, set_txn_id
from .models import DoesNotExist, get_hostname_for_user, get_user
from .dns import detect_ip_type, encode_dnsname, EncodingError
def extract_param(params, aliases): def extract_param(params, aliases):
"""Extract first matching param from query params.""" """Extract first matching param from query params."""
@@ -64,7 +81,7 @@ def _is_trusted_proxy(client_ip, trusted_networks):
class DDNSServer(ThreadingHTTPServer): class DDNSServer(ThreadingHTTPServer):
"""HTTP server with Application instance.""" """HTTP server with Application instance and thread pool."""
def __init__(self, app, address): def __init__(self, app, address):
""" """
@@ -79,8 +96,71 @@ class DDNSServer(ThreadingHTTPServer):
self.trusted_networks = _parse_trusted_proxies( self.trusted_networks = _parse_trusted_proxies(
app.config["daemon"].get("trusted_proxies", []) app.config["daemon"].get("trusted_proxies", [])
) )
self.pool_size = app.config["daemon"]["thread_pool_size"]
self.request_timeout = app.config["daemon"]["request_timeout"]
self.executor = ThreadPoolExecutor(max_workers=self.pool_size)
self.active_requests = 0
self.requests_lock = threading.Lock()
self.requests_done = threading.Condition(self.requests_lock)
super().__init__(address, DDNSRequestHandler) super().__init__(address, DDNSRequestHandler)
def process_request(self, request, client_address):
"""Submit request to thread pool."""
with self.requests_lock:
self.active_requests += 1
request.settimeout(self.request_timeout)
self.executor.submit(self._handle_request_wrapper, request, client_address)
def _handle_request_wrapper(self, request, client_address):
"""Wrap request handling to track active requests."""
try:
self.process_request_thread(request, client_address)
finally:
with self.requests_lock:
self.active_requests -= 1
if self.active_requests == 0:
self.requests_done.notify_all()
def wait_for_requests(self, timeout=5):
"""Wait for active requests to complete."""
with self.requests_lock:
if self.active_requests > 0:
logging.info(f"Waiting for {self.active_requests} active request(s)")
self.requests_done.wait(timeout=timeout)
if self.active_requests > 0:
logging.warning(
f"Shutdown timeout, {self.active_requests} request(s) still active"
)
def server_close(self):
"""Shutdown thread pool and close server."""
self.executor.shutdown(wait=True)
super().server_close()
class DDNSError(Exception):
def __init__(self, message, status, **kwargs):
super().__init__(self, message)
self.message = message
self.status = status
self.kwargs = kwargs
def __str__(self):
if not self.kwargs:
return self.message
string = f"{self.message}:"
for key, value in self.kwargs.items():
string += f" {key}={value}"
return string
class DDNSClientError(DDNSError):
def __init__(self, message, code, status, **kwargs):
super().__init__(message, status, **kwargs)
self.code = code
class DDNSRequestHandler(BaseHTTPRequestHandler): class DDNSRequestHandler(BaseHTTPRequestHandler):
"""HTTP request handler for DDNS updates.""" """HTTP request handler for DDNS updates."""
@@ -161,39 +241,55 @@ class DDNSRequestHandler(BaseHTTPRequestHandler):
def do_GET(self): def do_GET(self):
"""Handle GET requests.""" """Handle GET requests."""
set_txn_id() set_txn_id()
try:
self._handle_get_request()
finally:
clear_txn_id()
def _handle_get_request(self):
"""Handle GET request logic."""
try: try:
client_ip = self.get_client_ip() client_ip = self.get_client_ip()
except ProxyHeaderError as e: except ProxyHeaderError as e:
logging.error(f"Proxy header error: {e}") logging.error(f"Proxy header error: {e}")
self.send_response_body(400, "Bad Request") self.respond(400, "Bad Request")
return return
# Bad rate limit check try:
self._handle_get_request(client_ip)
except DDNSClientError as e:
if self.app.bad_limiter: if self.app.bad_limiter:
blocked, retry_at = self.app.bad_limiter.is_blocked(client_ip) self.app.bad_limiter.record(client_ip)
if blocked: logging.warning(e)
logging.warning( self.respond(e.code, e.status)
f"Rate limited (bad): client={client_ip}, " except DDNSError as e:
f"retry_at={datetime_str(retry_at)}") logging.error(e)
self.respond(429, "abuse") self.respond(500, e.status)
return except DatabaseError as e:
logging.error(f"Database error: {e}")
self.respond(500, "Internal Server Error")
except Exception as e:
logging.exception(f"Uncaught exception: {e}")
self.respond(500, "Internal Server Error")
finally:
clear_txn_id()
def _handle_get_request(self, client_ip):
"""Handle GET request logic."""
# Parse URL # Parse URL
parsed = urlparse(self.path) parsed = urlparse(self.path)
# Find matching endpoint # Find matching endpoint
endpoint = self.app.config["_endpoint_map"].get(parsed.path) endpoint = self.app.config["_endpoint_map"].get(parsed.path)
if endpoint is None: if endpoint is None:
self.send_response_body(404, "Not Found") self.respond(404, "Not Found")
return return
# Bad rate limit check
if self.app.bad_limiter:
blocked, retry_at = self.app.bad_limiter.is_blocked(client_ip)
if blocked:
raise DDNSClientError(
"Rate limited (bad requests)",
429,
STATUS_ABUSE,
client=client_ip,
retry_at=datetime_str(retry_at)
)
# Parse query parameters # Parse query parameters
params = parse_qs(parsed.query) params = parse_qs(parsed.query)
@@ -204,85 +300,33 @@ class DDNSRequestHandler(BaseHTTPRequestHandler):
password = extract_param(params, endpoint["params"]["password"]) password = extract_param(params, endpoint["params"]["password"])
if not username or not password: if not username or not password:
logging.warning(f"Auth failed: client={client_ip} user=anonymous") raise DDNSClientError(
self._handle_bad_request(client_ip, 401, "badauth") "Auth failed",
return 401,
STATUS_BADAUTH,
# Validate credentials client_ip
try: )
user = get_user(username)
self.app.password_hasher.verify(user.password_hash, password)
except (DoesNotExist, argon2.exceptions.VerifyMismatchError):
logging.warning(f"Auth failed: client={client_ip} user={username}")
self._handle_bad_request(client_ip, 401, "badauth")
return
# Get hostname parameter # Get hostname parameter
hostname_param = extract_param(params, endpoint["params"]["hostname"]) hostname_param = extract_param(params, endpoint["params"]["hostname"])
if not hostname_param: if not hostname_param:
logging.warning(f"Missing hostname: client={client_ip} user={username}") raise DDNSClientError(
self._handle_bad_request(client_ip, 400, "nohost") "Missing hostname",
return 400,
STATUS_NOHOST,
client=client_ip,
username=username
)
# Validate and encode hostname # Validate credentials
try: user = self._authenticate(client_ip, username, password)
hostname_param = encode_dnsname(hostname_param)
except EncodingError:
logging.warning(
f"Invalid hostname: client={client_ip}, "
f"hostname={hostname_param}")
self._handle_bad_request(client_ip, 400, "nohost")
return
# Check hostname ownership # Check hostname ownership
try: hostname = self._check_permissions(client_ip, user, hostname_param)
hostname = get_hostname_for_user(hostname_param, user)
except DoesNotExist:
logging.warning(
f"Access denied: client={client_ip} user={username} "
f"hostname={hostname_param}"
)
self._handle_bad_request(client_ip, 403, "nohost")
return
# Good rate limit check
if self.app.good_limiter:
blocked, retry_at = self.app.good_limiter.is_blocked(client_ip)
if blocked:
logging.warning(
f"Rate limited: client={client_ip}, "
f"retry_at={datetime_str(retry_at)}")
self.respond(429, "abuse")
return
# Record good request
if self.app.good_limiter:
self.app.good_limiter.record(client_ip)
# Determine IPs to update
result = self._process_ip_update(hostname, params, endpoint, client_ip)
if result:
code, status, *kwargs = result
if kwargs:
self.respond(code, status, **kwargs[0])
else:
self.respond(code, status)
def _handle_bad_request(self, client_ip, code, status):
"""Handle bad request and record in rate limiter."""
if self.app.bad_limiter:
self.app.bad_limiter.record(client_ip)
self.respond(code, status)
def _process_ip_update(self, hostname, params, endpoint, client_ip):
"""Process IP update for hostname."""
myip = extract_param(params, endpoint["params"]["ipv4"])
myip6 = extract_param(params, endpoint["params"]["ipv6"])
ipv4 = None
ipv6 = None
# Process myip parameter # Process myip parameter
ipv4 = None
myip = extract_param(params, endpoint["params"]["ipv4"])
if myip: if myip:
try: try:
rtype, myip = detect_ip_type(myip) rtype, myip = detect_ip_type(myip)
@@ -291,31 +335,126 @@ class DDNSRequestHandler(BaseHTTPRequestHandler):
else: else:
ipv6 = myip ipv6 = myip
except ValueError: except ValueError:
return (400, "badip") raise DDNSClientError(
"Bad IP address",
400,
STATUS_BADIP,
client=client_ip,
username=username,
hostname=hostname.hostname,
zone=hostname.zone,
ip=myip
)
# Process myip6 parameter # Process myip6 parameter
ipv6 = None
myip6 = extract_param(params, endpoint["params"]["ipv6"])
if myip6: if myip6:
try: try:
rtype, myip6 = detect_ip_type(myip6) rtype, myip6 = detect_ip_type(myip6)
if rtype == "AAAA": if rtype == "AAAA":
ipv6 = myip6 ipv6 = myip6
else: else:
return (400, "badip") raise ValueError
except ValueError: except ValueError:
return (400, "badip") raise DDNSClientError(
"Bad IP address",
400,
STATUS_BADIP,
client=client_ip,
username=username,
hostname=hostname.hostname,
zone=hostname.zone,
ipv6=myip6
)
# Auto-detect from client IP if no params # Auto-detect from client IP if no params
if ipv4 is None and ipv6 is None: if ipv4 is None and ipv6 is None:
try:
rtype, ip = detect_ip_type(client_ip) rtype, ip = detect_ip_type(client_ip)
if rtype == "A": if rtype == "A":
ipv4 = ip ipv4 = ip
else: else:
ipv6 = ip ipv6 = ip
except ValueError:
return (400, "badip")
now = utc_now() # Process notify_change parameter
notify_change = extract_param(params, endpoint["params"]["notify_change"])
notify_change = notify_change.lower() in ["1", "y", "yes", "on", "true"] \
if notify_change else False
# Good rate limit check
if self.app.good_limiter:
blocked, retry_at = self.app.good_limiter.is_blocked(client_ip)
if blocked:
raise DDNSClientError(
"Rate limited (good requests)",
429,
STATUS_ABUSE,
client=client_ip,
username=username,
retry_at=datetime_str(retry_at)
)
# Record good request
if self.app.good_limiter:
self.app.good_limiter.record(client_ip)
# Process update request
self._process_ip_update(
client_ip,
user,
hostname,
ipv4,
ipv6,
notify_change
)
def _authenticate(self, client_ip, username, password):
try:
try:
user = get_user(username)
except DoesNotExist:
# User does not exist, Hash fake password to prevent time-based attacks
self.app.password_hasher.hash("FAKE-PASSWORD")
raise DoesNotExist
self.app.password_hasher.verify(user.password_hash, password)
except (DoesNotExist, VerifyMismatchError):
raise DDNSClientError(
"Auth failed",
401,
STATUS_BADAUTH,
client=client_ip,
username=username
)
return user
def _check_permissions(self, client_ip, user, hostname_param):
# Check hostname ownership
code = None
try:
hostname = get_hostname_for_user(hostname_param, user)
except DoesNotExist:
code = 403
except EncodingError:
code = 400
if code:
raise DDNSClientError(
"Access denied",
code,
STATUS_NOHOST,
client=client_ip,
username=user.username,
hostname=hostname_param
)
return hostname
def _process_ip_update(self, client_ip, user, hostname, ipv4, ipv6, notify_change):
"""Process IP update for hostname."""
now = now_utc()
ipv4_changed = False ipv4_changed = False
ipv6_changed = False ipv6_changed = False
@@ -334,11 +473,15 @@ class DDNSRequestHandler(BaseHTTPRequestHandler):
ipv4_changed = True ipv4_changed = True
except Exception as e: except Exception as e:
hostname.save() hostname.save()
logging.error( logging.error(f"DNS error: {e}")
f"DNS update failed: client={client_ip} hostname={hostname.hostname} " raise DDNSError(
f"zone={hostname.zone} ipv4={ipv4} error={e}" "Update failed",
STATUS_DNSERR,
client=client_ip,
hostname=hostname.hostname,
zone=hostname.zone,
ipv4=ipv4
) )
return (500, "dnserr")
if ipv6: if ipv6:
hostname.last_ipv6_update = now hostname.last_ipv6_update = now
@@ -355,19 +498,19 @@ class DDNSRequestHandler(BaseHTTPRequestHandler):
ipv6_changed = True ipv6_changed = True
except Exception as e: except Exception as e:
hostname.save() hostname.save()
logging.error( logging.error(f"DNS error: {e}")
f"DNS update failed: client={client_ip} hostname={hostname.hostname} " raise DDNSError(
f"zone={hostname.zone} ipv6={ipv6} error={e}" "Update failed",
STATUS_DNSERR,
client=client_ip,
hostname=hostname.hostname,
zone=hostname.zone,
ipv6=ipv6
) )
return (500, "dnserr")
# Update database # Update database
hostname.save() hostname.save()
notify_change_val = extract_param(params, endpoint["params"]["notify_change"])
notify_change = notify_change_val.lower() not in ["0", "n", "no", "off"] \
if notify_change_val else False
changed_addrs = "" changed_addrs = ""
if ipv4_changed: if ipv4_changed:
changed_addrs += f" ipv4={ipv4}" changed_addrs += f" ipv4={ipv4}"
@@ -379,10 +522,13 @@ class DDNSRequestHandler(BaseHTTPRequestHandler):
f"No change: client={client_ip} hostname={hostname.hostname} " f"No change: client={client_ip} hostname={hostname.hostname} "
f"zone={hostname.zone}{changed_addrs} notify_change={str(notify_change).lower()}" f"zone={hostname.zone}{changed_addrs} notify_change={str(notify_change).lower()}"
) )
return ( self.respond(
200, "nochg", 200,
{"ipv4": hostname.last_ipv4, "ipv6": hostname.last_ipv6} STATUS_NOCHG,
ipv4=hostname.last_ipv4,
ipv6=hostname.last_ipv6
) )
return
logging.info( logging.info(
f"Updated: client={client_ip} hostname={hostname.hostname} " f"Updated: client={client_ip} hostname={hostname.hostname} "
@@ -400,9 +546,11 @@ class DDNSRequestHandler(BaseHTTPRequestHandler):
except Exception as e: except Exception as e:
logging.error(f"Sending change notification error: {e}") logging.error(f"Sending change notification error: {e}")
return ( self.respond(
200, "good", 200,
{"ipv4": hostname.last_ipv4, "ipv6": hostname.last_ipv6} STATUS_GOOD,
ipv4=hostname.last_ipv4,
ipv6=hostname.last_ipv6
) )
@@ -440,14 +588,39 @@ def run_daemon(app):
expired_cleanup_thread = ExpiredRecordsCleanupThread(app) expired_cleanup_thread = ExpiredRecordsCleanupThread(app)
expired_cleanup_thread.start() expired_cleanup_thread.start()
# Setup signal handlers # Setup signal handlers
def signal_handler(signum, frame): def signal_handler(signum, frame):
logging.info(f"Signal received: {signum}, shutting down") logging.info(f"Signal received: {signum}, shutting down")
app.signal_shutdown() app.signal_shutdown()
def sighup_handler(signum, frame):
logging.info("SIGHUP received, reloading configuration")
try:
app.reload_config()
# Update server attributes
server.proxy_header = app.config["daemon"].get("proxy_header", "")
server.trusted_networks = _parse_trusted_proxies(
app.config["daemon"].get("trusted_proxies", [])
)
# Reload SSL if enabled
if app.config["daemon"]["ssl"]:
new_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
new_context.load_cert_chain(
app.config["daemon"]["ssl_cert_file"],
app.config["daemon"]["ssl_key_file"]
)
# Note: existing connections use old cert, new connections use new
server.socket = new_context.wrap_socket(
server.socket.detach(), server_side=True
)
except Exception as e:
logging.error(f"Config reload failed: {e}")
signal.signal(signal.SIGTERM, signal_handler) signal.signal(signal.SIGTERM, signal_handler)
signal.signal(signal.SIGINT, signal_handler) signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGHUP, sighup_handler)
paths = ", ".join(ep["path"] for ep in config["endpoints"]) paths = ", ".join(ep["path"] for ep in config["endpoints"])
logging.info(f"Daemon started: {proto}://{host}:{port} endpoints=[{paths}]") logging.info(f"Daemon started: {proto}://{host}:{port} endpoints=[{paths}]")
@@ -457,6 +630,9 @@ def run_daemon(app):
while not app.is_shutting_down(): while not app.is_shutting_down():
server.handle_request() server.handle_request()
# Graceful shutdown - wait for active requests
server.wait_for_requests(5)
# Cleanup # Cleanup
expired_cleanup_thread.stop() expired_cleanup_thread.stop()
ratelimit_cleanup_thread.stop() ratelimit_cleanup_thread.stop()