Rename project to ddns-service
This commit is contained in:
24
src/ddns_service/__init__.py
Normal file
24
src/ddns_service/__init__.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""
|
||||
DDNS Service - Dynamic DNS update service.
|
||||
|
||||
A daemon that accepts HTTP(S) requests to dynamically update DNS entries.
|
||||
Includes CLI administration tools for user and hostname management.
|
||||
"""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
__author__ = "Thomas Oettli <spacefreak@noop.ch>"
|
||||
|
||||
__all__ = [
|
||||
"app",
|
||||
"cleanup",
|
||||
"cli",
|
||||
"config",
|
||||
"dns",
|
||||
"email",
|
||||
"logging",
|
||||
"main",
|
||||
"models",
|
||||
"ratelimit",
|
||||
"server",
|
||||
"validation"
|
||||
]
|
||||
64
src/ddns_service/app.py
Normal file
64
src/ddns_service/app.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""Application class - central dependency holder."""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
|
||||
import argon2
|
||||
|
||||
from .dns import DNSService
|
||||
from .email import EmailService
|
||||
from .models import init_database
|
||||
from .ratelimit import RateLimiter
|
||||
|
||||
|
||||
class Application:
|
||||
"""
|
||||
Central application state holder.
|
||||
|
||||
Holds configuration and all service instances.
|
||||
"""
|
||||
|
||||
def __init__(self, config):
|
||||
"""
|
||||
Initialize application with configuration.
|
||||
|
||||
Args:
|
||||
config: Configuration dictionary from TOML file.
|
||||
"""
|
||||
self.config = config
|
||||
self.password_hasher = argon2.PasswordHasher()
|
||||
self.shutdown_event = threading.Event()
|
||||
|
||||
# Service instances (initialized separately)
|
||||
self.dns_service = None
|
||||
self.email_service = None
|
||||
self.rate_limiter = None
|
||||
|
||||
def init_database(self):
|
||||
"""Initialize database connection."""
|
||||
init_database(self.config)
|
||||
logging.info("Database initialized")
|
||||
|
||||
def init_dns(self):
|
||||
"""Initialize DNS service."""
|
||||
self.dns_service = DNSService(self.config)
|
||||
logging.info("DNS service initialized")
|
||||
|
||||
def init_email(self):
|
||||
"""Initialize email service."""
|
||||
self.email_service = EmailService(self.config)
|
||||
logging.info("Email service initialized")
|
||||
|
||||
def init_rate_limiter(self):
|
||||
"""Initialize rate limiter."""
|
||||
self.rate_limiter = RateLimiter(self.config)
|
||||
logging.info("Rate limiter initialized")
|
||||
|
||||
def signal_shutdown(self):
|
||||
"""Signal the application to shut down."""
|
||||
logging.info("Shutdown signaled")
|
||||
self.shutdown_event.set()
|
||||
|
||||
def is_shutting_down(self):
|
||||
"""Check if shutdown has been signaled."""
|
||||
return self.shutdown_event.is_set()
|
||||
143
src/ddns_service/cleanup.py
Normal file
143
src/ddns_service/cleanup.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""TTL cleanup functionality."""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from .models import Hostname, User
|
||||
|
||||
|
||||
def cleanup_expired(app):
|
||||
"""
|
||||
Clean up expired hostnames and return count of cleaned entries.
|
||||
|
||||
Args:
|
||||
app: Application instance with dns_service and email_service.
|
||||
|
||||
Returns:
|
||||
Number of expired hostnames processed.
|
||||
"""
|
||||
now = datetime.now()
|
||||
expired_count = 0
|
||||
|
||||
for hostname in Hostname.select().join(User).where(
|
||||
(Hostname.expiry_ttl != 0) &
|
||||
((Hostname.last_ipv4.is_null(False) & Hostname.last_ipv4_update.is_null(False)) |
|
||||
(Hostname.last_ipv6.is_null(False) & Hostname.last_ipv6_update.is_null(False)))):
|
||||
|
||||
ipv4_expired = False
|
||||
ipv6_expired = False
|
||||
|
||||
if hostname.last_ipv4:
|
||||
expiry_time = hostname.last_ipv4_update + timedelta(seconds=hostname.expiry_ttl)
|
||||
if now > expiry_time:
|
||||
ipv4_expired = True
|
||||
|
||||
if hostname.last_ipv6:
|
||||
expiry_time = hostname.last_ipv6_update + timedelta(seconds=hostname.expiry_ttl)
|
||||
if now > expiry_time:
|
||||
ipv6_expired = True
|
||||
|
||||
if not ipv4_expired and not ipv6_expired:
|
||||
continue
|
||||
|
||||
if app.dns_service:
|
||||
if ipv4_expired:
|
||||
logging.info(
|
||||
f"Host expired: hostname={hostname.hostname} zone={hostname.zone} ip={hostname.last_ipv4}"
|
||||
)
|
||||
app.dns_service.delete_record(hostname.hostname, hostname.zone, "A")
|
||||
|
||||
if ipv6_expired:
|
||||
logging.info(
|
||||
f"Host expired: hostname={hostname.hostname} zone={hostname.zone} ip={hostname.last_ipv6}"
|
||||
)
|
||||
app.dns_service.delete_record(hostname.hostname, hostname.zone, "AAAA")
|
||||
|
||||
if app.email_service:
|
||||
last_ipv4 = (hostname.last_ipv4, hostname.last_ipv4_update) if ipv4_expired else None
|
||||
last_ipv6 = (hostname.last_ipv6, hostname.last_ipv6_update) if ipv6_expired else None
|
||||
|
||||
app.email_service.send_expiry_notification(
|
||||
hostname.user.email,
|
||||
f"{hostname.hostname}.{hostname.zone}",
|
||||
last_ipv4,
|
||||
last_ipv6,
|
||||
hostname.expiry_ttl
|
||||
)
|
||||
|
||||
# Clear IP addresses
|
||||
if ipv4_expired:
|
||||
hostname.last_ipv4 = None
|
||||
if ipv6_expired:
|
||||
hostname.last_ipv6 = None
|
||||
|
||||
if ipv4_expired or ipv6_expired:
|
||||
hostname.save()
|
||||
|
||||
expired_count += 1
|
||||
|
||||
return expired_count
|
||||
|
||||
|
||||
class ExpiredRecordsCleanupThread(threading.Thread):
|
||||
"""Background thread for periodic expired records cleanup."""
|
||||
|
||||
def __init__(self, app):
|
||||
"""
|
||||
Initialize expired records cleanup thread.
|
||||
|
||||
Args:
|
||||
app: Application instance.
|
||||
"""
|
||||
super().__init__(daemon=True)
|
||||
self.app = app
|
||||
self.interval = app.config["dns_service"]["cleanup_interval"]
|
||||
self.stop_event = threading.Event()
|
||||
|
||||
def run(self):
|
||||
"""Run the cleanup loop."""
|
||||
logging.info(f"Expired records cleanup thread started: interval={self.interval}s")
|
||||
|
||||
while not self.stop_event.wait(self.interval):
|
||||
try:
|
||||
count = cleanup_expired(self.app)
|
||||
if count > 0:
|
||||
logging.info(f"Expired records cleanup completed: count={count}")
|
||||
except Exception as e:
|
||||
logging.error(f"Expired records cleanup error: {e}")
|
||||
|
||||
def stop(self):
|
||||
"""Signal the thread to stop."""
|
||||
self.stop_event.set()
|
||||
|
||||
|
||||
class RateLimitCleanupThread(threading.Thread):
|
||||
"""Background thread for periodic rate limiter cleanup."""
|
||||
|
||||
def __init__(self, app):
|
||||
"""
|
||||
Initialize rate limiter cleanup thread.
|
||||
|
||||
Args:
|
||||
app: Application instance.
|
||||
"""
|
||||
super().__init__(daemon=True)
|
||||
self.app = app
|
||||
self.interval = app.config["rate_limit"]["cleanup_interval"]
|
||||
self.stop_event = threading.Event()
|
||||
|
||||
def run(self):
|
||||
"""Run the cleanup loop."""
|
||||
logging.info(f"Rate limit cleanup thread started: interval={self.interval}s")
|
||||
|
||||
while not self.stop_event.wait(self.interval):
|
||||
try:
|
||||
if self.app.rate_limiter:
|
||||
self.app.rate_limiter.cleanup()
|
||||
except Exception as e:
|
||||
logging.error(f"Rate limit cleanup error: {e}")
|
||||
|
||||
def stop(self):
|
||||
"""Signal the thread to stop."""
|
||||
self.stop_event.set()
|
||||
306
src/ddns_service/cli.py
Normal file
306
src/ddns_service/cli.py
Normal file
@@ -0,0 +1,306 @@
|
||||
"""CLI commands for user and hostname management."""
|
||||
|
||||
import getpass
|
||||
import logging
|
||||
|
||||
from .cleanup import cleanup_expired
|
||||
from .models import (
|
||||
create_tables,
|
||||
DoesNotExist,
|
||||
get_hostname,
|
||||
get_user,
|
||||
Hostname,
|
||||
User,
|
||||
)
|
||||
from .validation import encode_hostname, encode_zone, ValidationError
|
||||
|
||||
|
||||
def cmd_init_db(args, app):
|
||||
"""Initialize database tables."""
|
||||
create_tables()
|
||||
print("Database tables created.")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_user_list(args, app):
|
||||
"""List all users."""
|
||||
users = User.select()
|
||||
if not users:
|
||||
print("No users found.")
|
||||
return 0
|
||||
|
||||
print(f"\n{'Username':<20} {'Email':<30} {'Hostnames':<10} {'Created'}")
|
||||
print("-" * 82)
|
||||
for user in users:
|
||||
hostname_count = Hostname.select().where(
|
||||
Hostname.user == user
|
||||
).count()
|
||||
created_at = user.created_at.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
print(
|
||||
f"{user.username:<20} {user.email:<30} "
|
||||
f"{hostname_count:<10} {created_at}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_user_add(args, app):
|
||||
"""Add a new user."""
|
||||
username = args.username
|
||||
email = args.email
|
||||
|
||||
# Check if user exists
|
||||
if User.select().where(User.username == username).exists():
|
||||
print(f"Error: User '{username}' already exists.")
|
||||
return 1
|
||||
|
||||
# Get password
|
||||
password = getpass.getpass("Password: ")
|
||||
password_confirm = getpass.getpass("Confirm password: ")
|
||||
|
||||
if password != password_confirm:
|
||||
print("Error: Passwords do not match.")
|
||||
return 1
|
||||
|
||||
if len(password) < 8:
|
||||
print("Error: Password must be at least 8 characters.")
|
||||
return 1
|
||||
|
||||
# Hash password and create user
|
||||
password_hash = app.password_hasher.hash(password)
|
||||
User.create(username=username, email=email, password_hash=password_hash)
|
||||
print(f"User '{username}' created.")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_user_delete(args, app):
|
||||
"""Delete a user."""
|
||||
username = args.username
|
||||
|
||||
try:
|
||||
user = get_user(username)
|
||||
except DoesNotExist:
|
||||
print(f"Error: User '{username}' not found.")
|
||||
return 1
|
||||
|
||||
# Check for hostnames
|
||||
hostname_count = Hostname.select().where(Hostname.user == user).count()
|
||||
if hostname_count > 0:
|
||||
print(f"Error: User has {hostname_count} hostname(s). Delete them first.")
|
||||
return 1
|
||||
|
||||
user.delete_instance()
|
||||
print(f"User '{username}' deleted.")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_user_passwd(args, app):
|
||||
"""Change user password."""
|
||||
username = args.username
|
||||
|
||||
try:
|
||||
user = get_user(username)
|
||||
except DoesNotExist:
|
||||
print(f"Error: User '{username}' not found.")
|
||||
return 1
|
||||
|
||||
password = getpass.getpass("New password: ")
|
||||
password_confirm = getpass.getpass("Confirm password: ")
|
||||
|
||||
if password != password_confirm:
|
||||
print("Error: Passwords do not match.")
|
||||
return 1
|
||||
|
||||
if len(password) < 8:
|
||||
print("Error: Password must be at least 8 characters.")
|
||||
return 1
|
||||
|
||||
user.password_hash = app.password_hasher.hash(password)
|
||||
user.save()
|
||||
print(f"Password updated for '{username}'.")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_user_email(args, app):
|
||||
"""Update user email."""
|
||||
username = args.username
|
||||
email = args.email
|
||||
|
||||
try:
|
||||
user = get_user(username)
|
||||
except DoesNotExist:
|
||||
print(f"Error: User '{username}' not found.")
|
||||
return 1
|
||||
|
||||
user.email = email
|
||||
user.save()
|
||||
print(f"Email updated for '{username}'.")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_hostname_list(args, app):
|
||||
"""List hostnames."""
|
||||
query = Hostname.select().join(User)
|
||||
|
||||
if args.user:
|
||||
try:
|
||||
user = get_user(args.user)
|
||||
query = query.where(Hostname.user == user)
|
||||
except DoesNotExist:
|
||||
print(f"Error: User '{args.user}' not found.")
|
||||
return 1
|
||||
|
||||
hostnames = list(query)
|
||||
if not hostnames:
|
||||
print("No hostnames found.")
|
||||
return 0
|
||||
|
||||
print(
|
||||
f"{'Hostname':<35} {'User':<15} {'Zone':<20} "
|
||||
f"{'DNS-TTL':<8} {'Exp-TTL':<8} {'Last-Update IPv4':<21} {'Last-Update IPv6'}"
|
||||
)
|
||||
print("-" * 132)
|
||||
for h in hostnames:
|
||||
last_ipv4_update = h.last_ipv4_update.strftime("%Y-%m-%d %H:%M:%S") if h.last_ipv4_update else "Never"
|
||||
last_ipv6_update = h.last_ipv6_update.strftime("%Y-%m-%d %H:%M:%S") if h.last_ipv6_update else "Never"
|
||||
print(
|
||||
f"{h.hostname:<35} {h.user.username:<15} {h.zone:<20} "
|
||||
f"{h.dns_ttl:<8} {h.expiry_ttl:<8} {last_ipv4_update:<21} {last_ipv6_update}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_hostname_add(args, app):
|
||||
"""Add a hostname."""
|
||||
username = args.username
|
||||
config = app.config
|
||||
|
||||
# Validate and encode hostname/zone
|
||||
try:
|
||||
hostname_str = encode_hostname(args.hostname)
|
||||
zone = encode_zone(args.zone)
|
||||
except ValidationError 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
|
||||
try:
|
||||
user = get_user(username)
|
||||
except DoesNotExist:
|
||||
print(f"Error: User '{username}' not found.")
|
||||
return 1
|
||||
|
||||
# Check if hostname exists
|
||||
if Hostname.select().where(Hostname.hostname == hostname_str).exists():
|
||||
print(f"Error: Hostname '{hostname_str}' already exists.")
|
||||
return 1
|
||||
|
||||
# Create hostname
|
||||
Hostname.create(
|
||||
user=user,
|
||||
hostname=hostname_str,
|
||||
zone=zone,
|
||||
dns_ttl=dns_ttl,
|
||||
expiry_ttl=expiry_ttl
|
||||
)
|
||||
print(f"Hostname '{hostname_str}' added for user '{username}'.")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_hostname_delete(args, app):
|
||||
"""Delete a hostname."""
|
||||
# Validate and encode hostname
|
||||
try:
|
||||
hostname_str = encode_hostname(args.hostname)
|
||||
except ValidationError as e:
|
||||
print(f"Error: {e}")
|
||||
return 1
|
||||
|
||||
try:
|
||||
hostname = get_hostname(hostname_str)
|
||||
except DoesNotExist:
|
||||
print(f"Error: Hostname '{hostname_str}' not found.")
|
||||
return 1
|
||||
|
||||
# Delete DNS records if active
|
||||
if hostname.last_ipv4 or hostname.last_ipv6:
|
||||
# Initialize DNS service if not already
|
||||
if app.dns_service is None:
|
||||
try:
|
||||
app.init_dns()
|
||||
except Exception as e:
|
||||
logging.warning(f"DNS init failed: {e}")
|
||||
|
||||
if app.dns_service:
|
||||
if hostname.last_ipv4:
|
||||
try:
|
||||
app.dns_service.delete_record(
|
||||
hostname.hostname, hostname.zone, "A"
|
||||
)
|
||||
except Exception as e:
|
||||
logging.warning(f"DNS delete failed: type=A error={e}")
|
||||
if hostname.last_ipv6:
|
||||
try:
|
||||
app.dns_service.delete_record(
|
||||
hostname.hostname, hostname.zone, "AAAA"
|
||||
)
|
||||
except Exception as e:
|
||||
logging.warning(f"DNS delete failed: type=AAAA error={e}")
|
||||
|
||||
hostname.delete_instance()
|
||||
print(f"Hostname '{hostname_str}' deleted.")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_hostname_modify(args, app):
|
||||
"""Modify hostname settings."""
|
||||
# Validate and encode hostname
|
||||
try:
|
||||
hostname_str = encode_hostname(args.hostname)
|
||||
except ValidationError as e:
|
||||
print(f"Error: {e}")
|
||||
return 1
|
||||
|
||||
try:
|
||||
hostname = get_hostname(hostname_str)
|
||||
except DoesNotExist:
|
||||
print(f"Error: Hostname '{hostname_str}' not found.")
|
||||
return 1
|
||||
|
||||
# Get new TTLs
|
||||
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
|
||||
|
||||
hostname.dns_ttl = dns_ttl
|
||||
hostname.expiry_ttl = expiry_ttl
|
||||
hostname.save()
|
||||
print(
|
||||
f"Hostname '{hostname_str}' updated: "
|
||||
f"dns_ttl={dns_ttl}, expiry_ttl={expiry_ttl}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_cleanup(args, app):
|
||||
"""Run cleanup manually."""
|
||||
# Initialize services if not already
|
||||
if app.dns_service is None:
|
||||
try:
|
||||
app.init_dns()
|
||||
except Exception as e:
|
||||
logging.warning(f"DNS init failed: {e}")
|
||||
|
||||
if app.email_service is None:
|
||||
app.init_email()
|
||||
|
||||
count = cleanup_expired(app)
|
||||
print(f"Cleanup complete: {count} expired hostname(s) processed.")
|
||||
return 0
|
||||
192
src/ddns_service/config.py
Normal file
192
src/ddns_service/config.py
Normal file
@@ -0,0 +1,192 @@
|
||||
"""Configuration loading and management."""
|
||||
|
||||
import os
|
||||
|
||||
try:
|
||||
import tomllib
|
||||
except ImportError:
|
||||
import tomli as tomllib
|
||||
|
||||
|
||||
# Default config paths (searched in order)
|
||||
CONFIG_PATHS = [
|
||||
"/etc/ddns-service/config.toml",
|
||||
"./config.toml",
|
||||
]
|
||||
|
||||
# Default endpoint parameter aliases
|
||||
DEFAULT_ENDPOINT_PARAMS = {
|
||||
"hostname": ["hostname", "host"],
|
||||
"ipv4": ["myip", "ipv4", "ip4"],
|
||||
"ipv6": ["myip6", "ipv6", "ip6"],
|
||||
"username": ["username", "user"],
|
||||
"password": ["password", "pass", "token"],
|
||||
}
|
||||
|
||||
VALID_PARAM_KEYS = frozenset(DEFAULT_ENDPOINT_PARAMS.keys())
|
||||
|
||||
|
||||
class ConfigError(Exception):
|
||||
"""Raised when configuration loading fails."""
|
||||
pass
|
||||
|
||||
|
||||
def normalize_endpoint(endpoint):
|
||||
"""
|
||||
Normalize and validate endpoint configuration.
|
||||
|
||||
Args:
|
||||
endpoint: Raw endpoint dict from config.
|
||||
|
||||
Returns:
|
||||
Normalized endpoint dict with validated params.
|
||||
|
||||
Raises:
|
||||
ConfigError: If validation fails.
|
||||
"""
|
||||
if "path" not in endpoint:
|
||||
raise ConfigError("Endpoint missing path")
|
||||
|
||||
path = endpoint["path"]
|
||||
if not isinstance(path, str) or not path.startswith("/"):
|
||||
raise ConfigError("Endpoint path must start with /")
|
||||
|
||||
# Start with defaults
|
||||
params = {k: list(v) for k, v in DEFAULT_ENDPOINT_PARAMS.items()}
|
||||
|
||||
# Override with user-defined params
|
||||
if "params" in endpoint:
|
||||
user_params = endpoint["params"]
|
||||
if not isinstance(user_params, dict):
|
||||
raise ConfigError("Endpoint params must be a dict")
|
||||
|
||||
for key, aliases in user_params.items():
|
||||
if key not in VALID_PARAM_KEYS:
|
||||
raise ConfigError("Unknown endpoint param: " + key)
|
||||
|
||||
if not isinstance(aliases, list):
|
||||
raise ConfigError("Param " + key + " aliases must be list")
|
||||
|
||||
if not aliases:
|
||||
raise ConfigError("Param " + key + " must have at least one alias")
|
||||
|
||||
for alias in aliases:
|
||||
if not isinstance(alias, str) or not alias:
|
||||
raise ConfigError("Param " + key + " aliases must be non-empty strings")
|
||||
|
||||
params[key] = aliases
|
||||
|
||||
return {"path": path, "params": params}
|
||||
|
||||
|
||||
def find_config_file(custom_path=None):
|
||||
"""
|
||||
Find and return the config file path.
|
||||
|
||||
Args:
|
||||
custom_path: Optional custom path to config file.
|
||||
|
||||
Returns:
|
||||
Path to config file.
|
||||
|
||||
Raises:
|
||||
ConfigError: If config file not found.
|
||||
"""
|
||||
if custom_path:
|
||||
if os.path.isfile(custom_path):
|
||||
return custom_path
|
||||
raise ConfigError(f"Config file not found: {custom_path}")
|
||||
|
||||
for path in CONFIG_PATHS:
|
||||
if os.path.isfile(path):
|
||||
return path
|
||||
|
||||
raise ConfigError(
|
||||
f"Config file not found. Searched: {', '.join(CONFIG_PATHS)}"
|
||||
)
|
||||
|
||||
|
||||
def load_config(config_path):
|
||||
"""
|
||||
Load and validate configuration from TOML file.
|
||||
|
||||
Args:
|
||||
config_path: Path to config file.
|
||||
|
||||
Returns:
|
||||
Configuration dictionary with defaults applied.
|
||||
|
||||
Raises:
|
||||
ConfigError: If loading fails.
|
||||
"""
|
||||
try:
|
||||
with open(config_path, "rb") as f:
|
||||
cfg = tomllib.load(f)
|
||||
except Exception as e:
|
||||
raise ConfigError(f"Failed to load config: {e}")
|
||||
|
||||
# Set defaults for missing sections
|
||||
cfg.setdefault("daemon", {})
|
||||
cfg["daemon"].setdefault("host", "localhost")
|
||||
cfg["daemon"].setdefault("port", 8443)
|
||||
cfg["daemon"].setdefault("log_level", "INFO")
|
||||
cfg["daemon"].setdefault("log_target", "stdout")
|
||||
cfg["daemon"].setdefault("syslog_socket", "/dev/log")
|
||||
cfg["daemon"].setdefault("syslog_facility", "daemon")
|
||||
cfg["daemon"].setdefault("log_file", "/var/log/ddns-service.log")
|
||||
cfg["daemon"].setdefault("log_file_size", 52428800)
|
||||
cfg["daemon"].setdefault("log_versions", 5)
|
||||
cfg["daemon"].setdefault("log_requests", False)
|
||||
cfg["daemon"].setdefault("ssl", False)
|
||||
cfg["daemon"].setdefault("proxy_header", "")
|
||||
cfg["daemon"].setdefault("trusted_proxies", [])
|
||||
|
||||
cfg.setdefault("database", {})
|
||||
cfg["database"].setdefault("backend", "sqlite")
|
||||
|
||||
cfg.setdefault("dns_service", {})
|
||||
cfg["dns_service"].setdefault("manager_config_file", "/etc/dns-manager/config.yml")
|
||||
cfg["dns_service"].setdefault("cleanup_interval", 60)
|
||||
if cfg["dns_service"]["cleanup_interval"] < 1:
|
||||
cfg["dns_service"]["cleanup_interval"] = 1
|
||||
|
||||
cfg.setdefault("defaults", {})
|
||||
cfg["defaults"].setdefault("dns_ttl", 60)
|
||||
cfg["defaults"].setdefault("expiry_ttl", 3600)
|
||||
|
||||
cfg.setdefault("email", {})
|
||||
cfg["email"].setdefault("enabled", False)
|
||||
cfg["email"].setdefault("smtp_port", 25)
|
||||
|
||||
cfg.setdefault("rate_limit", {})
|
||||
cfg["rate_limit"].setdefault("enabled", True)
|
||||
cfg["rate_limit"].setdefault("good_window_seconds", 60)
|
||||
cfg["rate_limit"].setdefault("good_max_requests", 5)
|
||||
cfg["rate_limit"].setdefault("bad_window_seconds", 60)
|
||||
cfg["rate_limit"].setdefault("bad_max_requests", 3)
|
||||
cfg["rate_limit"].setdefault("cleanup_interval", 60)
|
||||
if cfg["rate_limit"]["cleanup_interval"] < 1:
|
||||
cfg["rate_limit"]["cleanup_interval"] = 1
|
||||
|
||||
# Process endpoints
|
||||
if "endpoints" not in cfg:
|
||||
# Create default endpoint
|
||||
cfg["endpoints"] = [{
|
||||
"path": "/update",
|
||||
"params": {k: list(v) for k, v in DEFAULT_ENDPOINT_PARAMS.items()},
|
||||
}]
|
||||
else:
|
||||
normalized = []
|
||||
paths_seen = set()
|
||||
for ep in cfg["endpoints"]:
|
||||
ep = normalize_endpoint(ep)
|
||||
if ep["path"] in paths_seen:
|
||||
raise ConfigError("Duplicate endpoint path: " + ep["path"])
|
||||
paths_seen.add(ep["path"])
|
||||
normalized.append(ep)
|
||||
cfg["endpoints"] = normalized
|
||||
|
||||
# Build path->endpoint lookup
|
||||
cfg["_endpoint_map"] = {ep["path"]: ep for ep in cfg["endpoints"]}
|
||||
|
||||
return cfg
|
||||
152
src/ddns_service/dns.py
Normal file
152
src/ddns_service/dns.py
Normal file
@@ -0,0 +1,152 @@
|
||||
"""DNS operations using dns-manager library."""
|
||||
|
||||
import ipaddress
|
||||
import logging
|
||||
|
||||
import dns.rdataset
|
||||
import dns.rdatatype
|
||||
from dnsmgr import DNSManager, name_from_text, rdata_from_text
|
||||
|
||||
|
||||
def detect_ip_type(ip):
|
||||
try:
|
||||
addr = ipaddress.ip_address(ip)
|
||||
if isinstance(addr, ipaddress.IPv4Address):
|
||||
rdtype = 'A'
|
||||
else:
|
||||
rdtype = 'AAAA'
|
||||
|
||||
return (rdtype, str(addr))
|
||||
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid IP address: {ip}")
|
||||
|
||||
|
||||
class DNSError(Exception):
|
||||
"""Raised when DNS operations fail."""
|
||||
pass
|
||||
|
||||
|
||||
class DNSService:
|
||||
"""DNS service for managing DNS records."""
|
||||
|
||||
def __init__(self, config):
|
||||
"""
|
||||
Initialize DNS service.
|
||||
|
||||
Args:
|
||||
config: Application configuration dictionary.
|
||||
|
||||
Raises:
|
||||
DNSError: If initialization fails.
|
||||
"""
|
||||
try:
|
||||
config_file = config["dns_service"]["manager_config_file"]
|
||||
self.manager = DNSManager(config_file)
|
||||
logging.debug(f"DNS manager initialized: config={config_file}")
|
||||
except Exception as e:
|
||||
raise DNSError(f"Failed to initialize DNS manager: {e}")
|
||||
|
||||
def _get_zone(self, zone):
|
||||
"""Get zone object by name."""
|
||||
zones = self.manager.get_zones(zone)
|
||||
if not zones:
|
||||
raise DNSError(f"Zone not found: {zone}")
|
||||
zone_obj = zones[0]
|
||||
self.manager.get_zone_content(zone_obj)
|
||||
return zone_obj
|
||||
|
||||
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.
|
||||
|
||||
Args:
|
||||
hostname: Fully qualified hostname.
|
||||
zone: DNS zone name.
|
||||
record_type: Record type (A or AAAA).
|
||||
|
||||
Returns:
|
||||
True if record was deleted.
|
||||
|
||||
Raises:
|
||||
DNSError: If delete fails.
|
||||
"""
|
||||
|
||||
try:
|
||||
deleted = False
|
||||
zone_obj = self._get_zone(zone)
|
||||
name = name_from_text(hostname, zone_obj.origin)
|
||||
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):
|
||||
"""
|
||||
Update a DNS record for the given hostname.
|
||||
|
||||
Args:
|
||||
hostname: Fully qualified hostname.
|
||||
zone: DNS zone name.
|
||||
ip: IP address to set.
|
||||
ttl: DNS record TTL.
|
||||
|
||||
Raises:
|
||||
DNSError: If update fails.
|
||||
"""
|
||||
try:
|
||||
record_type, normalized_ip = detect_ip_type(ip)
|
||||
|
||||
zone_obj = self._get_zone(zone)
|
||||
name = name_from_text(hostname, zone_obj.origin)
|
||||
rdtype = dns.rdatatype.from_text(record_type)
|
||||
|
||||
# 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(
|
||||
f"DNS record updated: hostname={hostname} zone={zone_obj.origin} "
|
||||
f"type={record_type} ip={normalized_ip} ttl={ttl}"
|
||||
)
|
||||
except Exception as e:
|
||||
raise DNSError(f"Failed to update DNS record for {hostname}: {e}")
|
||||
104
src/ddns_service/email.py
Normal file
104
src/ddns_service/email.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""Email notification functionality."""
|
||||
|
||||
import logging
|
||||
import smtplib
|
||||
from email.mime.text import MIMEText
|
||||
|
||||
|
||||
class EmailService:
|
||||
"""Email service for sending notifications."""
|
||||
|
||||
def __init__(self, config):
|
||||
"""
|
||||
Initialize email service.
|
||||
|
||||
Args:
|
||||
config: Application configuration dictionary.
|
||||
"""
|
||||
self.config = config.get("email", {})
|
||||
self.enabled = self.config.get("enabled", False)
|
||||
|
||||
def send(self, to, subject, body):
|
||||
"""
|
||||
Send email using configured SMTP server.
|
||||
|
||||
Args:
|
||||
to: Recipient email address.
|
||||
subject: Email subject.
|
||||
body: Email body text.
|
||||
|
||||
Returns:
|
||||
True if sent successfully, False otherwise.
|
||||
"""
|
||||
if not self.enabled:
|
||||
logging.debug("Email disabled, skipping")
|
||||
return False
|
||||
|
||||
try:
|
||||
msg = MIMEText(body)
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = self.config["from_address"]
|
||||
msg["To"] = to
|
||||
|
||||
smtp_host = self.config["smtp_host"]
|
||||
smtp_port = self.config["smtp_port"]
|
||||
|
||||
server = smtplib.SMTP(smtp_host, smtp_port)
|
||||
if self.config.get("smtp_starttls", False):
|
||||
server.starttls()
|
||||
|
||||
try:
|
||||
if self.config.get("smtp_user"):
|
||||
server.login(
|
||||
self.config["smtp_user"],
|
||||
self.config["smtp_password"]
|
||||
)
|
||||
server.sendmail(msg["From"], [to], msg.as_string())
|
||||
logging.info(f"Email sent: to={to} subject={subject}")
|
||||
return True
|
||||
finally:
|
||||
server.quit()
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Email send failed: to={to} error={e}")
|
||||
return False
|
||||
|
||||
def send_expiry_notification(
|
||||
self,
|
||||
email,
|
||||
hostname,
|
||||
last_ipv4,
|
||||
last_ipv6,
|
||||
expiry_ttl
|
||||
):
|
||||
"""
|
||||
Send hostname expiry notification email.
|
||||
|
||||
Args:
|
||||
email: Recipient email.
|
||||
hostname: Expired hostname.
|
||||
last_ipv4: Tuple containing last IPv4 address and last update timestamp.
|
||||
last_ipv6: Tuple containing last IPv6 address and last update timestamp.
|
||||
expiry_ttl: Expiry TTL in seconds.
|
||||
|
||||
Returns:
|
||||
True if sent successfully.
|
||||
"""
|
||||
subject = f"DDNS hostname expired: {hostname}"
|
||||
body = f"""Your dynamic DNS entry has expired due to inactivity.
|
||||
|
||||
Hostname: {hostname}
|
||||
"""
|
||||
if last_ipv4:
|
||||
ip = last_ipv4[0]
|
||||
last_update = last_ipv4[1].strftime("%Y-%m-%d %H:%M:%S")
|
||||
body += f"IPv4 address: {ip} (last update: {last_update})\n"
|
||||
if last_ipv6:
|
||||
ip = last_ipv6[0]
|
||||
last_update = last_ipv6[1].strftime("%Y-%m-%d %H:%M:%S")
|
||||
body += f"IPv6 address: {ip} (last update: {last_update})\n"
|
||||
body += f"""Expiry TTL: {expiry_ttl} seconds
|
||||
|
||||
The DNS records have been removed. Update your client to restore them.
|
||||
"""
|
||||
return self.send(email, subject, body)
|
||||
141
src/ddns_service/logging.py
Normal file
141
src/ddns_service/logging.py
Normal file
@@ -0,0 +1,141 @@
|
||||
"""Centralized logging configuration."""
|
||||
|
||||
import logging
|
||||
import logging.handlers
|
||||
import secrets
|
||||
from contextvars import ContextVar
|
||||
|
||||
# Transaction ID context (thread-safe per-request)
|
||||
_txn_id = ContextVar("txn_id", default="-")
|
||||
|
||||
# Syslog facility mapping
|
||||
SYSLOG_FACILITIES = {
|
||||
"daemon": logging.handlers.SysLogHandler.LOG_DAEMON,
|
||||
"user": logging.handlers.SysLogHandler.LOG_USER,
|
||||
"local0": logging.handlers.SysLogHandler.LOG_LOCAL0,
|
||||
"local1": logging.handlers.SysLogHandler.LOG_LOCAL1,
|
||||
"local2": logging.handlers.SysLogHandler.LOG_LOCAL2,
|
||||
"local3": logging.handlers.SysLogHandler.LOG_LOCAL3,
|
||||
"local4": logging.handlers.SysLogHandler.LOG_LOCAL4,
|
||||
"local5": logging.handlers.SysLogHandler.LOG_LOCAL5,
|
||||
"local6": logging.handlers.SysLogHandler.LOG_LOCAL6,
|
||||
"local7": logging.handlers.SysLogHandler.LOG_LOCAL7,
|
||||
}
|
||||
|
||||
|
||||
def get_txn_id():
|
||||
"""Get current transaction ID."""
|
||||
return _txn_id.get()
|
||||
|
||||
|
||||
def set_txn_id(txn_id=None):
|
||||
"""Set transaction ID, generate if not provided."""
|
||||
if txn_id is None:
|
||||
txn_id = secrets.token_hex(4)
|
||||
_txn_id.set(txn_id)
|
||||
return txn_id
|
||||
|
||||
|
||||
def clear_txn_id():
|
||||
"""Reset transaction ID to default."""
|
||||
_txn_id.set("-")
|
||||
|
||||
|
||||
class TxnIdFilter(logging.Filter):
|
||||
"""Inject txn_id into log records."""
|
||||
|
||||
def filter(self, record):
|
||||
record.txn_id = get_txn_id()
|
||||
return True
|
||||
|
||||
|
||||
class TxnIdFormatter(logging.Formatter):
|
||||
"""Formatter that conditionally includes transaction ID."""
|
||||
|
||||
def __init__(self, fmt_with_txn, fmt_without_txn, datefmt=None):
|
||||
super().__init__(fmt_with_txn, datefmt)
|
||||
self.fmt_with_txn = fmt_with_txn
|
||||
self.fmt_without_txn = fmt_without_txn
|
||||
|
||||
def format(self, record):
|
||||
if hasattr(record, "txn_id") and record.txn_id != "-":
|
||||
self._style._fmt = self.fmt_with_txn
|
||||
else:
|
||||
self._style._fmt = self.fmt_without_txn
|
||||
return super().format(record)
|
||||
|
||||
|
||||
def setup_logging(
|
||||
level="INFO",
|
||||
target="stdout",
|
||||
syslog_socket="/dev/log",
|
||||
syslog_facility="daemon",
|
||||
log_file="/var/log/ddns-service.log",
|
||||
log_file_size=52428800,
|
||||
log_versions=5,
|
||||
):
|
||||
"""
|
||||
Configure global logging.
|
||||
|
||||
Args:
|
||||
level: Log level name (DEBUG, INFO, WARNING, ERROR)
|
||||
target: "stdout", "syslog", or "file"
|
||||
syslog_socket: Path to syslog socket
|
||||
syslog_facility: Syslog facility name
|
||||
log_file: Path to log file (for target="file")
|
||||
log_file_size: Max log file size in bytes before rotation
|
||||
log_versions: Number of backup files to keep
|
||||
"""
|
||||
root = logging.getLogger()
|
||||
root.setLevel(getattr(logging, level.upper(), logging.INFO))
|
||||
|
||||
# Clear existing handlers
|
||||
root.handlers.clear()
|
||||
|
||||
txn_filter = TxnIdFilter()
|
||||
|
||||
if target == "syslog":
|
||||
facility = SYSLOG_FACILITIES.get(
|
||||
syslog_facility.lower(),
|
||||
logging.handlers.SysLogHandler.LOG_DAEMON,
|
||||
)
|
||||
handler = logging.handlers.SysLogHandler(
|
||||
address=syslog_socket,
|
||||
facility=facility,
|
||||
)
|
||||
formatter = TxnIdFormatter(
|
||||
"ddns-service[%(process)d]: [%(levelname)s] [%(txn_id)s] %(message)s",
|
||||
"ddns-service[%(process)d]: [%(levelname)s] %(message)s",
|
||||
)
|
||||
elif target == "file":
|
||||
handler = logging.handlers.RotatingFileHandler(
|
||||
log_file,
|
||||
maxBytes=log_file_size,
|
||||
backupCount=log_versions,
|
||||
)
|
||||
formatter = TxnIdFormatter(
|
||||
"%(asctime)s [%(levelname)s] [%(txn_id)s] %(message)s",
|
||||
"%(asctime)s [%(levelname)s] %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
else:
|
||||
handler = logging.StreamHandler()
|
||||
formatter = TxnIdFormatter(
|
||||
"%(asctime)s [%(levelname)s] [%(txn_id)s] %(message)s",
|
||||
"%(asctime)s [%(levelname)s] %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
|
||||
handler.addFilter(txn_filter)
|
||||
handler.setFormatter(formatter)
|
||||
root.addHandler(handler)
|
||||
|
||||
|
||||
def disable_logging():
|
||||
"""Disable all logging (for CLI quiet mode)."""
|
||||
logging.disable(logging.CRITICAL)
|
||||
|
||||
|
||||
def enable_logging():
|
||||
"""Re-enable logging."""
|
||||
logging.disable(logging.NOTSET)
|
||||
182
src/ddns_service/main.py
Normal file
182
src/ddns_service/main.py
Normal file
@@ -0,0 +1,182 @@
|
||||
"""
|
||||
DDNS Service - Dynamic DNS update service.
|
||||
|
||||
Main executable for CLI and daemon mode.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
from . import __version__
|
||||
from .app import Application
|
||||
from .cli import (
|
||||
cmd_cleanup,
|
||||
cmd_hostname_add,
|
||||
cmd_hostname_delete,
|
||||
cmd_hostname_list,
|
||||
cmd_hostname_modify,
|
||||
cmd_init_db,
|
||||
cmd_user_add,
|
||||
cmd_user_delete,
|
||||
cmd_user_email,
|
||||
cmd_user_list,
|
||||
cmd_user_passwd,
|
||||
)
|
||||
from .config import ConfigError, find_config_file, load_config
|
||||
from .logging import disable_logging, setup_logging
|
||||
from .server import run_daemon
|
||||
|
||||
|
||||
def build_parser():
|
||||
"""Build the argument parser."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="DDNS Service - Dynamic DNS update service",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--version", action="version", version=f"%(prog)s {__version__}"
|
||||
)
|
||||
parser.add_argument("-c", "--config", help="Path to config file")
|
||||
parser.add_argument(
|
||||
"-d", "--daemon", action="store_true", help="Run as daemon"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--init-db", action="store_true", help="Initialize database"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--debug", action="store_true", help="Enable debug logging"
|
||||
)
|
||||
|
||||
subparsers = parser.add_subparsers(dest="command", title="commands")
|
||||
|
||||
# User commands
|
||||
user_parser = subparsers.add_parser("user", help="User management")
|
||||
user_subparsers = user_parser.add_subparsers(dest="user_command")
|
||||
|
||||
user_list = user_subparsers.add_parser("list", help="List users")
|
||||
user_list.set_defaults(func=cmd_user_list)
|
||||
|
||||
user_add = user_subparsers.add_parser("add", help="Add user")
|
||||
user_add.add_argument("username", help="Username")
|
||||
user_add.add_argument("email", help="Email address")
|
||||
user_add.set_defaults(func=cmd_user_add)
|
||||
|
||||
user_delete = user_subparsers.add_parser("delete", help="Delete user")
|
||||
user_delete.add_argument("username", help="Username")
|
||||
user_delete.set_defaults(func=cmd_user_delete)
|
||||
|
||||
user_passwd = user_subparsers.add_parser("passwd", help="Change password")
|
||||
user_passwd.add_argument("username", help="Username")
|
||||
user_passwd.set_defaults(func=cmd_user_passwd)
|
||||
|
||||
user_email = user_subparsers.add_parser("email", help="Update email")
|
||||
user_email.add_argument("username", help="Username")
|
||||
user_email.add_argument("email", help="New email address")
|
||||
user_email.set_defaults(func=cmd_user_email)
|
||||
|
||||
# Hostname commands
|
||||
hostname_parser = subparsers.add_parser("hostname", help="Hostname management")
|
||||
hostname_subparsers = hostname_parser.add_subparsers(dest="hostname_command")
|
||||
|
||||
hostname_list = hostname_subparsers.add_parser("list", help="List hostnames")
|
||||
hostname_list.add_argument("--user", help="Filter by username")
|
||||
hostname_list.set_defaults(func=cmd_hostname_list)
|
||||
|
||||
hostname_add = hostname_subparsers.add_parser("add", help="Add hostname")
|
||||
hostname_add.add_argument("username", help="Username")
|
||||
hostname_add.add_argument("hostname", help="Hostname (FQDN)")
|
||||
hostname_add.add_argument("zone", help="DNS zone")
|
||||
hostname_add.add_argument("--dns-ttl", type=int, help="DNS record TTL")
|
||||
hostname_add.add_argument("--expiry-ttl", type=int, help="Expiry TTL")
|
||||
hostname_add.set_defaults(func=cmd_hostname_add)
|
||||
|
||||
hostname_delete = hostname_subparsers.add_parser(
|
||||
"delete", help="Delete hostname"
|
||||
)
|
||||
hostname_delete.add_argument("hostname", help="Hostname (FQDN)")
|
||||
hostname_delete.set_defaults(func=cmd_hostname_delete)
|
||||
|
||||
hostname_modify = hostname_subparsers.add_parser(
|
||||
"modify", help="Modify hostname"
|
||||
)
|
||||
hostname_modify.add_argument("hostname", help="Hostname (FQDN)")
|
||||
hostname_modify.add_argument("--dns-ttl", type=int, help="DNS record TTL")
|
||||
hostname_modify.add_argument("--expiry-ttl", type=int, help="Expiry TTL")
|
||||
hostname_modify.set_defaults(func=cmd_hostname_modify)
|
||||
|
||||
# Cleanup command
|
||||
cleanup_parser = subparsers.add_parser("cleanup", help="Run cleanup manually")
|
||||
cleanup_parser.set_defaults(func=cmd_cleanup)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
parser = build_parser()
|
||||
args = parser.parse_args()
|
||||
|
||||
# Load config
|
||||
try:
|
||||
config_path = find_config_file(args.config)
|
||||
config = load_config(config_path)
|
||||
except ConfigError as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Setup logging based on mode
|
||||
if args.daemon:
|
||||
log_level = "DEBUG" if args.debug else config["daemon"]["log_level"]
|
||||
setup_logging(
|
||||
level=log_level,
|
||||
target=config["daemon"]["log_target"],
|
||||
syslog_socket=config["daemon"]["syslog_socket"],
|
||||
syslog_facility=config["daemon"]["syslog_facility"],
|
||||
log_file=config["daemon"]["log_file"],
|
||||
log_file_size=config["daemon"]["log_file_size"],
|
||||
log_versions=config["daemon"]["log_versions"],
|
||||
)
|
||||
else:
|
||||
if args.debug:
|
||||
setup_logging(level="DEBUG", target="stdout")
|
||||
else:
|
||||
disable_logging()
|
||||
|
||||
# Create application instance
|
||||
app = Application(config)
|
||||
|
||||
# Initialize database
|
||||
try:
|
||||
app.init_database()
|
||||
except Exception as e:
|
||||
print(f"Error: Database initialization failed: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Handle --init-db
|
||||
if args.init_db:
|
||||
return cmd_init_db(args, app)
|
||||
|
||||
# Handle --daemon
|
||||
if args.daemon:
|
||||
try:
|
||||
# Initialize all services for daemon mode
|
||||
app.init_dns()
|
||||
app.init_email()
|
||||
app.init_rate_limiter()
|
||||
run_daemon(app)
|
||||
return 0
|
||||
except Exception as e:
|
||||
print(f"Error: Daemon error: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Handle subcommands
|
||||
if args.command and hasattr(args, "func"):
|
||||
return args.func(args, app)
|
||||
|
||||
# No command specified
|
||||
parser.print_help()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
172
src/ddns_service/models.py
Normal file
172
src/ddns_service/models.py
Normal file
@@ -0,0 +1,172 @@
|
||||
"""Database models and initialization."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
from peewee import (
|
||||
AutoField,
|
||||
CharField,
|
||||
DateTimeField,
|
||||
DoesNotExist,
|
||||
ForeignKeyField,
|
||||
IntegerField,
|
||||
Model,
|
||||
MySQLDatabase,
|
||||
SqliteDatabase,
|
||||
)
|
||||
|
||||
# Database instance (initialized later)
|
||||
db = SqliteDatabase(None)
|
||||
|
||||
|
||||
class BaseModel(Model):
|
||||
"""Base model with database binding."""
|
||||
|
||||
class Meta:
|
||||
database = db
|
||||
|
||||
|
||||
class User(BaseModel):
|
||||
"""User model for authentication."""
|
||||
|
||||
id = AutoField()
|
||||
username = CharField(max_length=64, unique=True)
|
||||
password_hash = CharField(max_length=128)
|
||||
email = CharField(max_length=255)
|
||||
created_at = DateTimeField(default=datetime.now)
|
||||
|
||||
class Meta:
|
||||
table_name = "users"
|
||||
|
||||
|
||||
class Hostname(BaseModel):
|
||||
"""Hostname model for DNS records."""
|
||||
|
||||
id = AutoField()
|
||||
user = ForeignKeyField(User, backref="hostnames", on_delete="RESTRICT")
|
||||
hostname = CharField(max_length=255, unique=True)
|
||||
zone = CharField(max_length=255)
|
||||
dns_ttl = IntegerField()
|
||||
expiry_ttl = IntegerField()
|
||||
last_ipv4 = CharField(max_length=15, null=True)
|
||||
last_ipv4_update = DateTimeField(null=True)
|
||||
last_ipv6 = CharField(max_length=45, null=True)
|
||||
last_ipv6_update = DateTimeField(null=True)
|
||||
|
||||
class Meta:
|
||||
table_name = "hostnames"
|
||||
|
||||
|
||||
def init_database(config: dict):
|
||||
"""
|
||||
Initialize database connection based on config.
|
||||
|
||||
Args:
|
||||
config: Configuration dictionary.
|
||||
|
||||
Raises:
|
||||
ValueError: If unknown database backend.
|
||||
"""
|
||||
global db
|
||||
|
||||
backend = config["database"].get("backend", "sqlite")
|
||||
|
||||
if backend == "sqlite":
|
||||
db_path = config["database"].get("path", "./ddns.db")
|
||||
db_dir = os.path.dirname(db_path)
|
||||
if db_dir:
|
||||
os.makedirs(db_dir, exist_ok=True)
|
||||
db.init(db_path)
|
||||
logging.info(f"Database backend: SQLite path={db_path}")
|
||||
|
||||
elif backend == "mariadb":
|
||||
new_db = MySQLDatabase(
|
||||
config["database"]["database"],
|
||||
host=config["database"].get("host", "localhost"),
|
||||
port=config["database"].get("port", 3306),
|
||||
user=config["database"]["user"],
|
||||
password=config["database"]["password"],
|
||||
)
|
||||
# Re-bind models to new database
|
||||
User._meta.database = new_db
|
||||
Hostname._meta.database = new_db
|
||||
# Replace global db reference
|
||||
db = new_db
|
||||
logging.info(f"Database backend: MariaDB db={config['database']['database']}")
|
||||
|
||||
else:
|
||||
raise ValueError(f"Unknown database backend: {backend}")
|
||||
|
||||
db.connect()
|
||||
|
||||
|
||||
def create_tables():
|
||||
"""Create database tables if they don't exist."""
|
||||
db.create_tables([User, Hostname])
|
||||
logging.info("Database tables created")
|
||||
|
||||
|
||||
def get_user(username: str):
|
||||
"""
|
||||
Get user by username.
|
||||
|
||||
Args:
|
||||
username: Username to look up.
|
||||
|
||||
Returns:
|
||||
User instance.
|
||||
|
||||
Raises:
|
||||
DoesNotExist: If user not found.
|
||||
"""
|
||||
return User.get(User.username == username)
|
||||
|
||||
|
||||
def get_hostname(hostname: str):
|
||||
"""
|
||||
Get hostname by name.
|
||||
|
||||
Args:
|
||||
hostname: Hostname to look up.
|
||||
|
||||
Returns:
|
||||
Hostname instance.
|
||||
|
||||
Raises:
|
||||
DoesNotExist: If hostname not found.
|
||||
"""
|
||||
return Hostname.get(Hostname.hostname == hostname)
|
||||
|
||||
|
||||
def get_hostname_for_user(hostname: str, user: User):
|
||||
"""
|
||||
Get hostname owned by specific user.
|
||||
|
||||
Args:
|
||||
hostname: Hostname to look up.
|
||||
user: User who should own the hostname.
|
||||
|
||||
Returns:
|
||||
Hostname instance.
|
||||
|
||||
Raises:
|
||||
DoesNotExist: If hostname not found or not owned by user.
|
||||
"""
|
||||
return Hostname.get(
|
||||
((Hostname.hostname + '.' + Hostname.zone) == hostname) & (Hostname.user == user)
|
||||
)
|
||||
|
||||
|
||||
# Re-export DoesNotExist for convenience
|
||||
__all__ = [
|
||||
'db',
|
||||
'User',
|
||||
'Hostname',
|
||||
'init_database',
|
||||
'create_tables',
|
||||
'get_user',
|
||||
'get_hostname',
|
||||
'get_hostname_for_user',
|
||||
'DoesNotExist',
|
||||
]
|
||||
129
src/ddns_service/ratelimit.py
Normal file
129
src/ddns_service/ratelimit.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""Rate limiting with sliding window."""
|
||||
|
||||
import threading
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
|
||||
class RateLimiter:
|
||||
"""Sliding window rate limiter with separate good/bad request tracking."""
|
||||
|
||||
def __init__(self, config):
|
||||
"""
|
||||
Initialize rate limiter from config.
|
||||
|
||||
Args:
|
||||
config: Full configuration dictionary.
|
||||
"""
|
||||
rl_config = config.get("rate_limit", {})
|
||||
self.enabled = rl_config.get("enabled", False)
|
||||
self.good_window = rl_config.get("good_window_seconds", 60)
|
||||
self.good_max = rl_config.get("good_max_requests", 30)
|
||||
self.bad_window = rl_config.get("bad_window_seconds", 60)
|
||||
self.bad_max = rl_config.get("bad_max_requests", 5)
|
||||
|
||||
self.bad_requests = defaultdict(list)
|
||||
self.good_requests = defaultdict(list)
|
||||
self.bad_lock = threading.Lock()
|
||||
self.good_lock = threading.Lock()
|
||||
|
||||
def _cleanup_old(self, timestamps, window):
|
||||
"""Remove timestamps older than window."""
|
||||
cutoff = time.time() - window
|
||||
return [t for t in timestamps if t > cutoff]
|
||||
|
||||
def is_blocked_bad(self, ip):
|
||||
"""
|
||||
Check if IP is blocked by the bad request rate limiter with recording
|
||||
when IP is already limited.
|
||||
|
||||
Args:
|
||||
ip: Client IP address.
|
||||
|
||||
Returns:
|
||||
Tuple of (blocked, retry_after_seconds).
|
||||
"""
|
||||
if not self.enabled:
|
||||
return False, 0
|
||||
|
||||
now = time.time()
|
||||
|
||||
with self.bad_lock:
|
||||
# Check bad requests
|
||||
self.bad_requests[ip] = self._cleanup_old(
|
||||
self.bad_requests[ip], self.bad_window
|
||||
)
|
||||
if len(self.bad_requests[ip]) >= self.bad_max:
|
||||
self.bad_requests[ip].append(time.time())
|
||||
oldest = min(self.bad_requests[ip][-self.bad_max:])
|
||||
retry_after = int(oldest + self.bad_window - now) + 1
|
||||
return True, max(1, retry_after)
|
||||
|
||||
return False, 0
|
||||
|
||||
def is_blocked_good(self, ip):
|
||||
"""
|
||||
Check if IP is blocked by the good request rate limiter without recording.
|
||||
|
||||
Args:
|
||||
ip: Client IP address.
|
||||
|
||||
Returns:
|
||||
Tuple of (blocked, retry_after_seconds).
|
||||
"""
|
||||
if not self.enabled:
|
||||
return False, 0
|
||||
|
||||
now = time.time()
|
||||
|
||||
with self.good_lock:
|
||||
# Check good requests
|
||||
self.good_requests[ip] = self._cleanup_old(
|
||||
self.good_requests[ip], self.good_window
|
||||
)
|
||||
if len(self.good_requests[ip]) >= self.good_max:
|
||||
oldest = min(self.good_requests[ip])
|
||||
retry_after = int(oldest + self.good_window - now) + 1
|
||||
return True, max(1, retry_after)
|
||||
|
||||
return False, 0
|
||||
|
||||
def record_bad(self, ip):
|
||||
"""Record a bad request (without checking)."""
|
||||
if not self.enabled:
|
||||
return
|
||||
|
||||
with self.bad_lock:
|
||||
self.bad_requests[ip] = self._cleanup_old(
|
||||
self.bad_requests[ip], self.bad_window
|
||||
)
|
||||
self.bad_requests[ip].append(time.time())
|
||||
|
||||
def record_good(self, ip):
|
||||
"""Record a good request (without checking)."""
|
||||
if not self.enabled:
|
||||
return
|
||||
|
||||
with self.good_lock:
|
||||
self.good_requests[ip] = self._cleanup_old(
|
||||
self.good_requests[ip], self.good_window
|
||||
)
|
||||
self.good_requests[ip].append(time.time())
|
||||
|
||||
def cleanup(self):
|
||||
"""Remove stale entries to prevent memory leak."""
|
||||
with self.good_lock:
|
||||
for ip in list(self.good_requests.keys()):
|
||||
self.good_requests[ip] = self._cleanup_old(
|
||||
self.good_requests[ip], self.good_window
|
||||
)
|
||||
if not self.good_requests[ip]:
|
||||
del self.good_requests[ip]
|
||||
|
||||
with self.bad_lock:
|
||||
for ip in list(self.bad_requests.keys()):
|
||||
self.bad_requests[ip] = self._cleanup_old(
|
||||
self.bad_requests[ip], self.bad_window
|
||||
)
|
||||
if not self.bad_requests[ip]:
|
||||
del self.bad_requests[ip]
|
||||
447
src/ddns_service/server.py
Normal file
447
src/ddns_service/server.py
Normal file
@@ -0,0 +1,447 @@
|
||||
"""HTTP(S) server for DDNS updates."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import ipaddress
|
||||
import json
|
||||
import logging
|
||||
import signal
|
||||
import ssl
|
||||
from datetime import datetime
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import argon2
|
||||
|
||||
from .cleanup import ExpiredRecordsCleanupThread, RateLimitCleanupThread
|
||||
from .dns import detect_ip_type
|
||||
from .logging import clear_txn_id, set_txn_id
|
||||
from .models import DoesNotExist, get_hostname_for_user, get_user
|
||||
from .validation import encode_hostname, ValidationError
|
||||
|
||||
|
||||
def extract_param(params, aliases):
|
||||
"""Extract first matching param from query params."""
|
||||
for alias in aliases:
|
||||
val = params.get(alias, [None])[0]
|
||||
if val is not None:
|
||||
return val
|
||||
return None
|
||||
|
||||
|
||||
class ProxyHeaderError(Exception):
|
||||
"""Raised when expected proxy header is missing."""
|
||||
pass
|
||||
|
||||
|
||||
def _parse_trusted_proxies(proxies):
|
||||
"""Parse list of IPs/networks into ip_network objects."""
|
||||
networks = []
|
||||
for proxy in proxies:
|
||||
try:
|
||||
if "/" not in proxy:
|
||||
addr = ipaddress.ip_address(proxy)
|
||||
if addr.version == 4:
|
||||
proxy = proxy + "/32"
|
||||
else:
|
||||
proxy = proxy + "/128"
|
||||
networks.append(ipaddress.ip_network(proxy, strict=False))
|
||||
except ValueError:
|
||||
logging.warning(f"Invalid trusted proxy: {proxy}")
|
||||
return networks
|
||||
|
||||
|
||||
def _is_trusted_proxy(client_ip, trusted_networks):
|
||||
"""Check if client IP is in trusted proxy networks."""
|
||||
try:
|
||||
addr = ipaddress.ip_address(client_ip)
|
||||
for network in trusted_networks:
|
||||
if addr in network:
|
||||
return True
|
||||
except ValueError:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
class DDNSServer(ThreadingHTTPServer):
|
||||
"""HTTP server with Application instance."""
|
||||
|
||||
def __init__(self, app, address):
|
||||
"""
|
||||
Initialize server with application.
|
||||
|
||||
Args:
|
||||
app: Application instance.
|
||||
address: (host, port) tuple.
|
||||
"""
|
||||
self.app = app
|
||||
self.proxy_header = app.config["daemon"].get("proxy_header", "")
|
||||
self.trusted_networks = _parse_trusted_proxies(
|
||||
app.config["daemon"].get("trusted_proxies", [])
|
||||
)
|
||||
super().__init__(address, DDNSRequestHandler)
|
||||
|
||||
|
||||
class DDNSRequestHandler(BaseHTTPRequestHandler):
|
||||
"""HTTP request handler for DDNS updates."""
|
||||
|
||||
@property
|
||||
def app(self):
|
||||
"""Get application instance from server."""
|
||||
return self.server.app
|
||||
|
||||
def log_message(self, format_str, *args):
|
||||
"""Override to use our logger."""
|
||||
msg = f"{self.address_string()} - {format_str % args}"
|
||||
if self.app.config["daemon"]["log_requests"]:
|
||||
logging.info(msg)
|
||||
else:
|
||||
logging.debug(msg)
|
||||
|
||||
def send_response_body(self, code, body, content_type="text/plain"):
|
||||
"""Send response with body."""
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", content_type)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body.encode())
|
||||
|
||||
def wants_json(self):
|
||||
"""Check if client wants JSON response."""
|
||||
accept = self.headers.get("Accept", "")
|
||||
return "application/json" in accept
|
||||
|
||||
def respond(self, code, status, **kwargs):
|
||||
"""Send response in appropriate format."""
|
||||
if self.wants_json():
|
||||
data = {"status": status, **kwargs}
|
||||
self.send_response_body(code, json.dumps(data), "application/json")
|
||||
else:
|
||||
# DynDNS-compatible plain text
|
||||
parts = [status]
|
||||
if "ipv4" in kwargs and kwargs["ipv4"]:
|
||||
parts.append(kwargs["ipv4"])
|
||||
if "ipv6" in kwargs and kwargs["ipv6"]:
|
||||
parts.append(kwargs["ipv6"])
|
||||
self.send_response_body(code, " ".join(parts))
|
||||
|
||||
def get_client_ip(self):
|
||||
"""Get client IP, considering configured proxy header if trusted."""
|
||||
direct_ip = self.client_address[0]
|
||||
|
||||
proxy_header = self.server.proxy_header
|
||||
if not proxy_header:
|
||||
return direct_ip
|
||||
|
||||
if not _is_trusted_proxy(direct_ip, self.server.trusted_networks):
|
||||
return direct_ip
|
||||
|
||||
forwarded = self.headers.get(proxy_header)
|
||||
if not forwarded:
|
||||
raise ProxyHeaderError(
|
||||
f"Missing {proxy_header} header from trusted proxy"
|
||||
)
|
||||
|
||||
return forwarded.split(",")[0].strip()
|
||||
|
||||
def parse_basic_auth(self):
|
||||
"""Parse Basic Auth header."""
|
||||
auth = self.headers.get("Authorization", "")
|
||||
if not auth.startswith("Basic "):
|
||||
return None, None
|
||||
try:
|
||||
decoded = base64.b64decode(auth[6:]).decode("utf-8")
|
||||
if ":" in decoded:
|
||||
username, password = decoded.split(":", 1)
|
||||
return username, password
|
||||
except Exception:
|
||||
pass
|
||||
return None, None
|
||||
|
||||
def do_GET(self):
|
||||
"""Handle GET requests."""
|
||||
set_txn_id()
|
||||
try:
|
||||
self._handle_get_request()
|
||||
finally:
|
||||
clear_txn_id()
|
||||
|
||||
def _handle_get_request(self):
|
||||
"""Handle GET request logic."""
|
||||
try:
|
||||
client_ip = self.get_client_ip()
|
||||
except ProxyHeaderError as e:
|
||||
logging.error(f"Proxy header error: {e}")
|
||||
self.send_response_body(400, "Bad Request")
|
||||
return
|
||||
|
||||
# Bad rate limit check
|
||||
if self.app.rate_limiter:
|
||||
blocked, retry = self.app.rate_limiter.is_blocked_bad(client_ip)
|
||||
if blocked:
|
||||
logging.warning(
|
||||
f"Rate limited (bad requests): client={client_ip}, "
|
||||
f"retry_after={retry}")
|
||||
self.respond(429, "abuse")
|
||||
return
|
||||
|
||||
# Parse URL
|
||||
parsed = urlparse(self.path)
|
||||
|
||||
# Find matching endpoint
|
||||
endpoint = self.app.config["_endpoint_map"].get(parsed.path)
|
||||
if endpoint is None:
|
||||
self.send_response_body(404, "Not Found")
|
||||
return
|
||||
|
||||
# Parse query parameters
|
||||
params = parse_qs(parsed.query)
|
||||
|
||||
# Get credentials
|
||||
username, password = self.parse_basic_auth()
|
||||
if username is None:
|
||||
username = extract_param(params, endpoint["params"]["username"])
|
||||
password = extract_param(params, endpoint["params"]["password"])
|
||||
|
||||
if not username or not password:
|
||||
logging.warning(f"Auth failed: client={client_ip} user=anonymous")
|
||||
self._handle_bad_request(client_ip, 401, "badauth")
|
||||
return
|
||||
|
||||
# Validate credentials
|
||||
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
|
||||
hostname_param = extract_param(params, endpoint["params"]["hostname"])
|
||||
if not hostname_param:
|
||||
logging.warning(f"Missing hostname: client={client_ip} user={username}")
|
||||
self._handle_bad_request(client_ip, 400, "nohost")
|
||||
return
|
||||
|
||||
# Validate and encode hostname
|
||||
try:
|
||||
hostname_param = encode_hostname(hostname_param)
|
||||
except ValidationError:
|
||||
logging.warning(
|
||||
f"Invalid hostname: client={client_ip}, "
|
||||
f"hostname={hostname_param}")
|
||||
self._handle_bad_request(client_ip, 400, "nohost")
|
||||
return
|
||||
|
||||
# Check hostname ownership
|
||||
try:
|
||||
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.rate_limiter:
|
||||
blocked, retry = self.app.rate_limiter.is_blocked_good(client_ip)
|
||||
if blocked:
|
||||
logging.warning(f"Rate limited: client={client_ip}, retry_after={retry}")
|
||||
self.respond(429, "abuse", retry_after=retry)
|
||||
return
|
||||
|
||||
# Record good request
|
||||
if self.app.rate_limiter:
|
||||
self.app.rate_limiter.record_good(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.rate_limiter:
|
||||
self.app.rate_limiter.record_bad(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
|
||||
if myip:
|
||||
try:
|
||||
rtype, myip = detect_ip_type(myip)
|
||||
if rtype == "A":
|
||||
ipv4 = myip
|
||||
else:
|
||||
ipv6 = myip
|
||||
except ValueError:
|
||||
return (400, "badip")
|
||||
|
||||
# Process myip6 parameter
|
||||
if myip6:
|
||||
try:
|
||||
rtype, myip6 = detect_ip_type(myip6)
|
||||
if rtype == "AAAA":
|
||||
ipv6 = myip6
|
||||
else:
|
||||
return (400, "badip")
|
||||
except ValueError:
|
||||
return (400, "badip")
|
||||
|
||||
# Auto-detect from client IP if no params
|
||||
if ipv4 is None and ipv6 is None:
|
||||
try:
|
||||
rtype, ip = detect_ip_type(client_ip)
|
||||
if rtype == "A":
|
||||
ipv4 = ip
|
||||
else:
|
||||
ipv6 = ip
|
||||
except ValueError:
|
||||
return (400, "badip")
|
||||
|
||||
now = datetime.now()
|
||||
|
||||
changed = False
|
||||
if ipv4:
|
||||
hostname.last_ipv4_update = now
|
||||
if ipv4 != hostname.last_ipv4:
|
||||
# Update DNS IPv4 record
|
||||
try:
|
||||
self.app.dns_service.update_record(
|
||||
hostname.hostname,
|
||||
hostname.zone,
|
||||
ipv4,
|
||||
hostname.dns_ttl
|
||||
)
|
||||
hostname.last_ipv4 = ipv4
|
||||
changed = True
|
||||
except Exception as e:
|
||||
hostname.save()
|
||||
logging.error(
|
||||
f"DNS update failed: client={client_ip} hostname={hostname.hostname} "
|
||||
f"zone={hostname.zone} ipv4={ipv4} error={e}"
|
||||
)
|
||||
return (500, "dnserr")
|
||||
|
||||
if ipv6:
|
||||
hostname.last_ipv6_update = now
|
||||
if ipv6 != hostname.last_ipv6:
|
||||
# Update DNS IPv6 record
|
||||
try:
|
||||
self.app.dns_service.update_record(
|
||||
hostname.hostname,
|
||||
hostname.zone,
|
||||
ipv6,
|
||||
hostname.dns_ttl
|
||||
)
|
||||
hostname.last_ipv6 = ipv6
|
||||
changed = True
|
||||
except Exception as e:
|
||||
hostname.save()
|
||||
logging.error(
|
||||
f"DNS update failed: client={client_ip} hostname={hostname.hostname} "
|
||||
f"zone={hostname.zone} ipv6={ipv6} error={e}"
|
||||
)
|
||||
return (500, "dnserr")
|
||||
|
||||
# Update database
|
||||
hostname.save()
|
||||
|
||||
changed_addrs = ""
|
||||
if ipv4:
|
||||
changed_addrs += f"ipv4={ipv4}"
|
||||
if ipv6:
|
||||
changed_addrs += f" ipv6={ipv6}"
|
||||
|
||||
if not changed:
|
||||
logging.info(
|
||||
f"No change: client={client_ip} hostname={hostname.hostname} "
|
||||
f"zone={hostname.zone} {changed_addrs}"
|
||||
)
|
||||
return (
|
||||
200, "nochg",
|
||||
{"ipv4": hostname.last_ipv4, "ipv6": hostname.last_ipv6}
|
||||
)
|
||||
|
||||
logging.info(
|
||||
f"Updated: client={client_ip} hostname={hostname.hostname} "
|
||||
f"zone={hostname.zone} {changed_addrs}"
|
||||
)
|
||||
return (
|
||||
200, "good",
|
||||
{"ipv4": hostname.last_ipv4, "ipv6": hostname.last_ipv6}
|
||||
)
|
||||
|
||||
|
||||
def run_daemon(app):
|
||||
"""
|
||||
Run the DDNS daemon.
|
||||
|
||||
Args:
|
||||
app: Application instance with initialized services.
|
||||
"""
|
||||
config = app.config
|
||||
|
||||
# Setup server
|
||||
host = config["daemon"]["host"]
|
||||
port = config["daemon"]["port"]
|
||||
|
||||
server = DDNSServer(app, (host, port))
|
||||
|
||||
# Setup TLS if enabled
|
||||
if config["daemon"]["ssl"]:
|
||||
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
context.load_cert_chain(
|
||||
config["daemon"]["ssl_cert_file"],
|
||||
config["daemon"]["ssl_key_file"]
|
||||
)
|
||||
server.socket = context.wrap_socket(server.socket, server_side=True)
|
||||
proto = "https"
|
||||
else:
|
||||
proto = "http"
|
||||
|
||||
# Start cleanup threads
|
||||
expired_cleanup_thread = ExpiredRecordsCleanupThread(app)
|
||||
expired_cleanup_thread.start()
|
||||
|
||||
ratelimit_cleanup_thread = RateLimitCleanupThread(app)
|
||||
ratelimit_cleanup_thread.start()
|
||||
|
||||
# Setup signal handlers
|
||||
def signal_handler(signum, frame):
|
||||
logging.info(f"Signal received: {signum}, shutting down")
|
||||
app.signal_shutdown()
|
||||
|
||||
signal.signal(signal.SIGTERM, signal_handler)
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
|
||||
paths = ", ".join(ep["path"] for ep in config["endpoints"])
|
||||
logging.info(f"Daemon started: {proto}://{host}:{port} endpoints=[{paths}]")
|
||||
|
||||
# Run server
|
||||
server.timeout = 1.0
|
||||
while not app.is_shutting_down():
|
||||
server.handle_request()
|
||||
|
||||
# Cleanup
|
||||
expired_cleanup_thread.stop()
|
||||
ratelimit_cleanup_thread.stop()
|
||||
expired_cleanup_thread.join(timeout=5)
|
||||
ratelimit_cleanup_thread.join(timeout=5)
|
||||
server.server_close()
|
||||
logging.info("Daemon stopped")
|
||||
138
src/ddns_service/validation.py
Normal file
138
src/ddns_service/validation.py
Normal file
@@ -0,0 +1,138 @@
|
||||
"""Hostname and zone validation with punycode support."""
|
||||
|
||||
import re
|
||||
|
||||
|
||||
# Valid hostname label pattern (after punycode encoding)
|
||||
LABEL_PATTERN = re.compile(r'^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$', re.IGNORECASE)
|
||||
|
||||
|
||||
class ValidationError(Exception):
|
||||
"""Raised when validation fails."""
|
||||
pass
|
||||
|
||||
|
||||
def encode_hostname(hostname):
|
||||
"""
|
||||
Encode hostname to ASCII using punycode (IDNA).
|
||||
|
||||
Args:
|
||||
hostname: Hostname string, possibly with unicode characters.
|
||||
|
||||
Returns:
|
||||
ASCII-encoded hostname.
|
||||
|
||||
Raises:
|
||||
ValidationError: If hostname is invalid.
|
||||
"""
|
||||
hostname = hostname.lower().strip()
|
||||
|
||||
if not hostname:
|
||||
raise ValidationError("Hostname cannot be empty")
|
||||
|
||||
# Remove trailing dot if present
|
||||
if hostname.endswith('.'):
|
||||
hostname = hostname[:-1]
|
||||
|
||||
if len(hostname) > 253:
|
||||
raise ValidationError("Hostname too long (max 253 characters)")
|
||||
|
||||
try:
|
||||
# Encode each label using IDNA
|
||||
labels = hostname.split('.')
|
||||
encoded_labels = []
|
||||
|
||||
for label in labels:
|
||||
if not label:
|
||||
raise ValidationError("Empty label in hostname")
|
||||
|
||||
# Encode to punycode if needed
|
||||
try:
|
||||
encoded = label.encode('idna').decode('ascii')
|
||||
except UnicodeError as e:
|
||||
raise ValidationError(f"Invalid label '{label}': {e}")
|
||||
|
||||
if len(encoded) > 63:
|
||||
raise ValidationError(
|
||||
f"Label '{label}' too long (max 63 characters)"
|
||||
)
|
||||
|
||||
if not LABEL_PATTERN.match(encoded):
|
||||
raise ValidationError(f"Invalid label format: '{label}'")
|
||||
|
||||
encoded_labels.append(encoded)
|
||||
|
||||
return '.'.join(encoded_labels)
|
||||
|
||||
except ValidationError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise ValidationError(f"Invalid hostname '{hostname}': {e}")
|
||||
|
||||
|
||||
def encode_zone(zone):
|
||||
"""
|
||||
Encode zone name to ASCII using punycode (IDNA).
|
||||
|
||||
Args:
|
||||
zone: Zone name string, possibly with unicode characters.
|
||||
|
||||
Returns:
|
||||
ASCII-encoded zone name.
|
||||
|
||||
Raises:
|
||||
ValidationError: If zone is invalid.
|
||||
"""
|
||||
if not zone:
|
||||
raise ValidationError("Zone cannot be empty")
|
||||
|
||||
# Zone validation is same as hostname
|
||||
return encode_hostname(zone)
|
||||
|
||||
|
||||
def validate_hostname_in_zone(hostname, zone):
|
||||
"""
|
||||
Validate and encode hostname and zone, ensuring hostname is in zone.
|
||||
|
||||
Args:
|
||||
hostname: Hostname string.
|
||||
zone: Zone string.
|
||||
|
||||
Returns:
|
||||
Tuple of (encoded_hostname, encoded_zone).
|
||||
|
||||
Raises:
|
||||
ValidationError: If validation fails.
|
||||
"""
|
||||
encoded_hostname = encode_hostname(hostname)
|
||||
encoded_zone = encode_zone(zone)
|
||||
|
||||
# Check hostname ends with zone
|
||||
if not (encoded_hostname == encoded_zone or
|
||||
encoded_hostname.endswith('.' + encoded_zone)):
|
||||
raise ValidationError(
|
||||
f"Hostname '{hostname}' is not in zone '{zone}'"
|
||||
)
|
||||
|
||||
return encoded_hostname, encoded_zone
|
||||
|
||||
|
||||
def get_relative_name(hostname, zone):
|
||||
"""
|
||||
Get the relative name (hostname without zone suffix).
|
||||
|
||||
Args:
|
||||
hostname: Encoded hostname.
|
||||
zone: Encoded zone.
|
||||
|
||||
Returns:
|
||||
Relative name (e.g., 'mypc' from 'mypc.dyn.example.com').
|
||||
"""
|
||||
if hostname == zone:
|
||||
return '@'
|
||||
|
||||
suffix = '.' + zone
|
||||
if hostname.endswith(suffix):
|
||||
return hostname[:-len(suffix)]
|
||||
|
||||
return hostname
|
||||
Reference in New Issue
Block a user