Compare commits
23 Commits
feb4a67291
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
2d61ad11b3
|
|||
|
e37a9e84a6
|
|||
|
21b5a4c553
|
|||
|
5d2b9c3ffb
|
|||
|
cd5979556c
|
|||
|
60ebf4b387
|
|||
|
105a9d4253
|
|||
|
870a1b9f00
|
|||
|
89e63858a0
|
|||
|
e365fa7d77
|
|||
|
b97eb0404c
|
|||
|
07e37e525c
|
|||
|
cde4b879c1
|
|||
|
255c0ad1dd
|
|||
|
a1e3ee1770
|
|||
|
bd0c930060
|
|||
|
2123b5169b
|
|||
|
5bb37fde71
|
|||
|
444db3f190
|
|||
|
8b186d6e95
|
|||
|
faa1e4afd5
|
|||
|
6c382ae60c
|
|||
|
2381d2e1d2
|
14
README.md
14
README.md
@@ -73,6 +73,7 @@ Example:
|
||||
```toml
|
||||
[daemon]
|
||||
# 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
|
||||
# log_level = "INFO" # default: "INFO"
|
||||
# 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
|
||||
# proxy_header = "" # default: "" (disabled), e.g. "X-Forwarded-For"
|
||||
# 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]
|
||||
# backend = "sqlite" # default: "sqlite", or "mariadb"
|
||||
path = "/var/lib/ddns-service/ddns.db" # required for sqlite
|
||||
# pool_size = 5 # default: 5 (MariaDB connection pool size)
|
||||
|
||||
[dns_service]
|
||||
# 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
|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
### Request
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
[daemon]
|
||||
# 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
|
||||
# log_level = "INFO" # default
|
||||
# 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
|
||||
# proxy_header = "" # default (disabled), header name e.g. "X-Forwarded-For"
|
||||
# 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]
|
||||
# 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
|
||||
# password = "secret" # required for mariadb
|
||||
# database = "ddns" # required for mariadb
|
||||
# pool_size = 5 # default, MariaDB connection pool size
|
||||
|
||||
[dns_service]
|
||||
# dns_server = "127.0.0.1" # default, must be IP address
|
||||
|
||||
@@ -20,3 +20,9 @@ depend() {
|
||||
start_pre() {
|
||||
checkpath --directory --owner ${USER}:${GROUP} --mode 0750 /var/lib/ddns-service
|
||||
}
|
||||
|
||||
reload() {
|
||||
ebegin "Reloading ${RC_SVCNAME}"
|
||||
start-stop-daemon --signal HUP --pidfile "${pidfile}"
|
||||
eend $?
|
||||
}
|
||||
|
||||
@@ -15,20 +15,110 @@ __all__ = [
|
||||
"cleanup",
|
||||
"cli",
|
||||
"config",
|
||||
"datetime_aware_utc",
|
||||
"datetime_naive_utc",
|
||||
"datetime_str",
|
||||
"dns",
|
||||
"email",
|
||||
"logging",
|
||||
"main",
|
||||
"models",
|
||||
"now_utc"
|
||||
"ratelimit",
|
||||
"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 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):
|
||||
"""
|
||||
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):
|
||||
return "Never"
|
||||
|
||||
@@ -38,7 +128,3 @@ def datetime_str(dt, utc=False):
|
||||
return aware_dt.strftime(DATETIME_FORMAT)
|
||||
else:
|
||||
return aware_dt.astimezone().strftime(DATETIME_FORMAT)
|
||||
|
||||
|
||||
def utc_now():
|
||||
return datetime.datetime.now(datetime.UTC).replace(tzinfo=None)
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"""Application class - central dependency holder."""
|
||||
|
||||
import argon2
|
||||
import logging
|
||||
import threading
|
||||
|
||||
import argon2
|
||||
|
||||
from .config import load_config
|
||||
from .dns import DNSService
|
||||
from .email import EmailService
|
||||
from .logging import setup_logging
|
||||
from .models import create_tables, init_database
|
||||
from .ratelimit import BadLimiter, GoodLimiter
|
||||
|
||||
@@ -18,14 +19,16 @@ class Application:
|
||||
Holds configuration and all service instances.
|
||||
"""
|
||||
|
||||
def __init__(self, config):
|
||||
def __init__(self, config, config_path=None):
|
||||
"""
|
||||
Initialize application with configuration.
|
||||
|
||||
Args:
|
||||
config: Configuration dictionary from TOML file.
|
||||
config_path: Path to configuration file (for reload).
|
||||
"""
|
||||
self.config = config
|
||||
self.config_path = config_path
|
||||
self.password_hasher = argon2.PasswordHasher()
|
||||
self.shutdown_event = threading.Event()
|
||||
|
||||
@@ -57,6 +60,39 @@ class Application:
|
||||
self.bad_limiter = BadLimiter(self.config)
|
||||
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):
|
||||
"""Signal the application to shut down."""
|
||||
logging.info("Shutdown signaled")
|
||||
|
||||
@@ -3,22 +3,23 @@
|
||||
import logging
|
||||
import threading
|
||||
|
||||
from . import utc_now
|
||||
from . import now_utc
|
||||
from .models import Hostname, User
|
||||
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.
|
||||
|
||||
Args:
|
||||
app: Application instance with dns_service and email_service.
|
||||
start_time: Timezone aware datetime object containg the start time of the cleanup thread.
|
||||
|
||||
Returns:
|
||||
Number of expired hostnames processed.
|
||||
"""
|
||||
now = utc_now()
|
||||
now = now_utc()
|
||||
expired_count = 0
|
||||
|
||||
for hostname in Hostname.select().join(User).where(
|
||||
@@ -30,12 +31,14 @@ def cleanup_expired(app):
|
||||
ipv6_expired = False
|
||||
|
||||
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:
|
||||
ipv4_expired = True
|
||||
|
||||
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:
|
||||
ipv6_expired = True
|
||||
|
||||
@@ -48,38 +51,43 @@ def cleanup_expired(app):
|
||||
if app.dns_service:
|
||||
if ipv4_expired:
|
||||
logging.info(
|
||||
f"Host expired: hostname={hostname.hostname} zone={hostname.zone} "
|
||||
f"ip={hostname.last_ipv4}"
|
||||
f"Cleanup: Host expired: hostname={hostname.hostname} zone={hostname.zone} "
|
||||
f"ipv4={hostname.last_ipv4}"
|
||||
)
|
||||
try:
|
||||
app.dns_service.delete_record(hostname.hostname, hostname.zone, "A")
|
||||
ipv4_deleted = True
|
||||
except Exception as e:
|
||||
logging.error(f"DNS error: {e}")
|
||||
logging.error(
|
||||
f"DNS delete failed: hostname={hostname.hostname} "
|
||||
f"zone={hostname.zone} type=A error={e}"
|
||||
f"Cleanup failed: hostname={hostname.hostname} "
|
||||
f"zone={hostname.zone} type=A"
|
||||
)
|
||||
|
||||
if ipv6_expired:
|
||||
logging.info(
|
||||
f"Host expired: hostname={hostname.hostname} zone={hostname.zone} "
|
||||
f"ip={hostname.last_ipv6}"
|
||||
f"Cleanup: Host expired: hostname={hostname.hostname} zone={hostname.zone} "
|
||||
f"ipv6={hostname.last_ipv6}"
|
||||
)
|
||||
try:
|
||||
app.dns_service.delete_record(hostname.hostname, hostname.zone, "AAAA")
|
||||
ipv6_deleted = True
|
||||
except Exception as e:
|
||||
logging.error(f"DNS error: {e}")
|
||||
logging.error(
|
||||
f"DNS delete failed: hostname={hostname.hostname} "
|
||||
f"zone={hostname.zone} type=AAAA error={e}"
|
||||
f"Cleanup failed: hostname={hostname.hostname} "
|
||||
f"zone={hostname.zone} type=AAAA"
|
||||
)
|
||||
|
||||
if not (ipv4_deleted or ipv6_deleted):
|
||||
continue
|
||||
|
||||
if app.email_service:
|
||||
app.email_service.send_expiry_notification(
|
||||
hostname.user.email,
|
||||
hostname,
|
||||
ipv4_expired,
|
||||
ipv6_expired
|
||||
ipv4_deleted,
|
||||
ipv6_deleted
|
||||
)
|
||||
|
||||
# Clear IP addresses only if DNS delete succeeded
|
||||
@@ -88,7 +96,6 @@ def cleanup_expired(app):
|
||||
if ipv6_deleted:
|
||||
hostname.last_ipv6 = None
|
||||
|
||||
if ipv4_deleted or ipv6_deleted:
|
||||
hostname.save()
|
||||
expired_count += 1
|
||||
|
||||
@@ -113,10 +120,11 @@ class ExpiredRecordsCleanupThread(threading.Thread):
|
||||
def run(self):
|
||||
"""Run the cleanup loop."""
|
||||
logging.info(f"Expired records cleanup thread started: interval={self.interval}s")
|
||||
start_time = now_utc()
|
||||
|
||||
while not self.stop_event.wait(self.interval):
|
||||
try:
|
||||
count = cleanup_expired(self.app)
|
||||
count = cleanup_expired(self.app, start_time)
|
||||
if count > 0:
|
||||
logging.info(f"Expired records cleanup completed: count={count}")
|
||||
except Exception as e:
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
"""CLI commands for user and hostname management."""
|
||||
|
||||
import getpass
|
||||
import logging
|
||||
|
||||
from . import datetime_str
|
||||
from .cleanup import cleanup_expired
|
||||
from .dns import encode_dnsname
|
||||
from .models import (
|
||||
DatabaseError,
|
||||
DoesNotExist,
|
||||
EncodingError,
|
||||
get_hostname,
|
||||
get_user,
|
||||
Hostname,
|
||||
User,
|
||||
)
|
||||
from .dns import encode_dnsname, EncodingError
|
||||
|
||||
|
||||
def cmd_user_list(args, app):
|
||||
@@ -166,24 +167,8 @@ def cmd_hostname_list(args, app):
|
||||
def cmd_hostname_add(args, app):
|
||||
"""Add a hostname."""
|
||||
username = args.username
|
||||
config = app.config
|
||||
|
||||
# Validate and encode hostname/zone
|
||||
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
|
||||
try:
|
||||
user = get_user(username)
|
||||
@@ -192,40 +177,59 @@ def cmd_hostname_add(args, app):
|
||||
return 1
|
||||
|
||||
# Check if hostname+zone exists
|
||||
if Hostname.select().where(
|
||||
(Hostname.hostname == hostname_str) & (Hostname.zone == zone)
|
||||
).exists():
|
||||
print(f"Error: Hostname '{hostname_str}' in zone '{zone}' exists.")
|
||||
try:
|
||||
hostname = get_hostname(args.hostname, args.zone)
|
||||
print(f"Error: Hostname '{hostname.hostname}' in zone '{hostname.zone}' exists.")
|
||||
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
|
||||
Hostname.create(
|
||||
hostname = Hostname.create(
|
||||
user=user,
|
||||
hostname=hostname_str,
|
||||
zone=zone,
|
||||
hostname=args.hostname,
|
||||
zone=args.zone,
|
||||
dns_ttl=dns_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
|
||||
|
||||
|
||||
def cmd_hostname_delete(args, app):
|
||||
"""Delete a hostname."""
|
||||
# Validate and encode hostname and zone
|
||||
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)
|
||||
print(f"Error: Hostname '{hostname}' in zone '{zone}' not found.")
|
||||
return 1
|
||||
except EncodingError as e:
|
||||
print(f"Error: {e}")
|
||||
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
|
||||
if hostname.last_ipv4 or hostname.last_ipv6:
|
||||
# Initialize DNS service if not already
|
||||
@@ -233,45 +237,50 @@ def cmd_hostname_delete(args, app):
|
||||
try:
|
||||
app.init_dns()
|
||||
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:
|
||||
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}")
|
||||
print(f"DNS delete failed: type=A error={e}")
|
||||
return 1
|
||||
|
||||
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}")
|
||||
print(f"DNS delete failed: type=AAAA error={e}")
|
||||
return 1
|
||||
|
||||
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
|
||||
|
||||
|
||||
def cmd_hostname_modify(args, app):
|
||||
"""Modify hostname settings."""
|
||||
# Validate and encode hostname and zone
|
||||
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)
|
||||
print(f"Error: Hostname '{hostname}' in zone '{zone}' not found.")
|
||||
return 1
|
||||
except EncodingError as e:
|
||||
print(f"Error: {e}")
|
||||
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
|
||||
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
|
||||
@@ -280,9 +289,13 @@ def cmd_hostname_modify(args, app):
|
||||
hostname.expiry_ttl = expiry_ttl
|
||||
hostname.save()
|
||||
print(
|
||||
f"Hostname '{hostname_str}' updated: "
|
||||
f"Hostname '{hostname.hostname}' in zone '{hostname.zone}' updated: "
|
||||
f"dns_ttl={dns_ttl}, expiry_ttl={expiry_ttl}"
|
||||
)
|
||||
except DatabaseError as e:
|
||||
print(f"Database error: {e}")
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
@@ -293,11 +306,20 @@ def cmd_cleanup(args, app):
|
||||
try:
|
||||
app.init_dns()
|
||||
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:
|
||||
app.init_email()
|
||||
|
||||
try:
|
||||
count = cleanup_expired(app)
|
||||
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
|
||||
|
||||
@@ -141,9 +141,12 @@ def load_config(config_path):
|
||||
cfg["daemon"].setdefault("ssl", False)
|
||||
cfg["daemon"].setdefault("proxy_header", "")
|
||||
cfg["daemon"].setdefault("trusted_proxies", [])
|
||||
cfg["daemon"].setdefault("thread_pool_size", 10)
|
||||
cfg["daemon"].setdefault("request_timeout", 10)
|
||||
|
||||
cfg.setdefault("database", {})
|
||||
cfg["database"].setdefault("backend", "sqlite")
|
||||
cfg["database"].setdefault("pool_size", 5)
|
||||
|
||||
cfg.setdefault("dns_service", {})
|
||||
cfg["dns_service"].setdefault("dns_server", "127.0.0.1")
|
||||
|
||||
@@ -10,6 +10,7 @@ import dns.name
|
||||
import dns.query
|
||||
import dns.rcode
|
||||
import dns.rdatatype
|
||||
import dns.resolver
|
||||
import dns.tsigkeyring
|
||||
import dns.update
|
||||
|
||||
@@ -37,6 +38,12 @@ def encode_dnsname(hostname):
|
||||
|
||||
Raises:
|
||||
EncodingError: If hostname is invalid.
|
||||
|
||||
Example:
|
||||
>>> encode_dnsname("münchen")
|
||||
'xn--mnchen-3ya'
|
||||
>>> encode_dnsname("example.com.")
|
||||
'example.com'
|
||||
"""
|
||||
hostname = hostname.lower().strip()
|
||||
|
||||
@@ -84,6 +91,24 @@ def encode_dnsname(hostname):
|
||||
|
||||
|
||||
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:
|
||||
addr = ipaddress.ip_address(ip)
|
||||
if isinstance(addr, ipaddress.IPv4Address):
|
||||
@@ -127,6 +152,16 @@ def parse_bind_key_file(path):
|
||||
|
||||
Raises:
|
||||
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:
|
||||
return None, None
|
||||
@@ -319,18 +354,54 @@ class DNSService:
|
||||
return hostname[:-len(zone_suffix)]
|
||||
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):
|
||||
"""
|
||||
Update a DNS record for the given hostname.
|
||||
|
||||
Args:
|
||||
hostname: Fully qualified hostname.
|
||||
hostname: Hostname (without zone suffix).
|
||||
zone: DNS zone name.
|
||||
ip: IP address to set.
|
||||
ttl: DNS record TTL.
|
||||
|
||||
Raises:
|
||||
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:
|
||||
record_type, normalized_ip = detect_ip_type(ip)
|
||||
|
||||
@@ -153,7 +153,7 @@ def main():
|
||||
)
|
||||
|
||||
# Create application instance
|
||||
app = Application(config)
|
||||
app = Application(config, config_path)
|
||||
|
||||
# Initialize database
|
||||
try:
|
||||
|
||||
@@ -2,23 +2,46 @@
|
||||
|
||||
import logging
|
||||
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 (
|
||||
AutoField,
|
||||
CharField,
|
||||
DatabaseProxy,
|
||||
Model,
|
||||
DateTimeField,
|
||||
DoesNotExist,
|
||||
fn,
|
||||
ForeignKeyField,
|
||||
IntegerField,
|
||||
Model,
|
||||
MySQLDatabase,
|
||||
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
|
||||
DATABASE_VERSION = 2
|
||||
@@ -43,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):
|
||||
"""Base model with database binding."""
|
||||
"""Base model with database binding and save retry."""
|
||||
|
||||
class Meta:
|
||||
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):
|
||||
"""User model for authentication."""
|
||||
@@ -57,7 +102,7 @@ class User(BaseModel):
|
||||
username = CharField(max_length=64, unique=True)
|
||||
password_hash = CharField(max_length=128)
|
||||
email = CharField(max_length=255)
|
||||
created_at = DateTimeField(default=utc_now)
|
||||
created_at = DateTimeFieldUTC(default=now_utc)
|
||||
|
||||
class Meta:
|
||||
table_name = "users"
|
||||
@@ -73,9 +118,9 @@ class Hostname(BaseModel):
|
||||
dns_ttl = IntegerField()
|
||||
expiry_ttl = IntegerField()
|
||||
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_update = DateTimeField(null=True)
|
||||
last_ipv6_update = DateTimeFieldUTC(null=True)
|
||||
|
||||
class Meta:
|
||||
table_name = "hostnames"
|
||||
@@ -110,7 +155,6 @@ def init_database(config: dict):
|
||||
Raises:
|
||||
ValueError: If unknown database backend.
|
||||
"""
|
||||
global db
|
||||
|
||||
backend = config["database"].get("backend", "sqlite")
|
||||
|
||||
@@ -119,21 +163,23 @@ def init_database(config: dict):
|
||||
db_dir = os.path.dirname(db_path)
|
||||
if db_dir:
|
||||
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}")
|
||||
|
||||
elif backend == "mariadb":
|
||||
db = MySQLDatabase(
|
||||
actual_db = PooledMySQLDatabase(
|
||||
config["database"]["database"],
|
||||
host=config["database"].get("host", "localhost"),
|
||||
port=config["database"].get("port", 3306),
|
||||
user=config["database"]["user"],
|
||||
password=config["database"]["password"],
|
||||
max_connections=config["database"].get("pool_size", 5),
|
||||
)
|
||||
# Re-bind models to new database
|
||||
User._meta.database = db
|
||||
Hostname._meta.database = db
|
||||
Version._meta.database = db
|
||||
db.initialize(actual_db)
|
||||
db_name = config['database']['database']
|
||||
logging.debug(f"Database backend: MariaDB db={db_name}")
|
||||
|
||||
@@ -249,7 +295,7 @@ def create_tables():
|
||||
logging.debug("Database tables created")
|
||||
|
||||
|
||||
def get_user(username: str):
|
||||
def get_user(username: str) -> User:
|
||||
"""
|
||||
Get user by username.
|
||||
|
||||
@@ -261,11 +307,16 @@ def get_user(username: str):
|
||||
|
||||
Raises:
|
||||
DoesNotExist: If user not found.
|
||||
|
||||
Example:
|
||||
>>> user = get_user("alice")
|
||||
>>> print(user.email)
|
||||
'alice@example.com'
|
||||
"""
|
||||
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.
|
||||
|
||||
@@ -278,9 +329,16 @@ def get_hostname(hostname, zone):
|
||||
|
||||
Raises:
|
||||
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(
|
||||
(Hostname.hostname == hostname) & (Hostname.zone == zone)
|
||||
(Hostname.hostname == encode_dnsname(hostname)) &
|
||||
(Hostname.zone == encode_dnsname(zone))
|
||||
)
|
||||
|
||||
|
||||
@@ -289,7 +347,7 @@ def get_hostname_for_user(hostname: str, user: User):
|
||||
Get hostname owned by specific user.
|
||||
|
||||
Args:
|
||||
hostname: Hostname to look up.
|
||||
hostname: Hostname to look up (FQDN).
|
||||
user: User who should own the hostname.
|
||||
|
||||
Returns:
|
||||
@@ -297,22 +355,14 @@ def get_hostname_for_user(hostname: str, user: User):
|
||||
|
||||
Raises:
|
||||
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 = fn.Concat(Hostname.hostname, '.', Hostname.zone)
|
||||
return Hostname.get((fqdn == 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',
|
||||
]
|
||||
return Hostname.get(
|
||||
(fqdn == encode_dnsname(hostname)) &
|
||||
(Hostname.user == user)
|
||||
)
|
||||
|
||||
@@ -85,11 +85,11 @@ class GoodLimiter(BaseLimiter):
|
||||
Args:
|
||||
config: Full configuration dictionary.
|
||||
"""
|
||||
rl = config.get("rate_limit", {})
|
||||
rl = config["rate_limit"]
|
||||
super().__init__(
|
||||
rl.get("good_window_seconds", 60),
|
||||
rl.get("good_max_requests", 30),
|
||||
rl.get("enabled", False),
|
||||
rl["good_window_seconds"],
|
||||
rl["good_max_requests"],
|
||||
rl["enabled"],
|
||||
False,
|
||||
)
|
||||
|
||||
@@ -104,10 +104,10 @@ class BadLimiter(BaseLimiter):
|
||||
Args:
|
||||
config: Full configuration dictionary.
|
||||
"""
|
||||
rl = config.get("rate_limit", {})
|
||||
rl = config["rate_limit"]
|
||||
super().__init__(
|
||||
rl.get("bad_window_seconds", 60),
|
||||
rl.get("bad_max_requests", 5),
|
||||
rl.get("enabled", False),
|
||||
rl["bad_window_seconds"],
|
||||
rl["bad_max_requests"],
|
||||
rl["enabled"],
|
||||
True,
|
||||
)
|
||||
|
||||
@@ -8,17 +8,34 @@ import json
|
||||
import logging
|
||||
import signal
|
||||
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 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):
|
||||
"""Extract first matching param from query params."""
|
||||
@@ -64,7 +81,7 @@ def _is_trusted_proxy(client_ip, trusted_networks):
|
||||
|
||||
|
||||
class DDNSServer(ThreadingHTTPServer):
|
||||
"""HTTP server with Application instance."""
|
||||
"""HTTP server with Application instance and thread pool."""
|
||||
|
||||
def __init__(self, app, address):
|
||||
"""
|
||||
@@ -79,8 +96,71 @@ class DDNSServer(ThreadingHTTPServer):
|
||||
self.trusted_networks = _parse_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)
|
||||
|
||||
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):
|
||||
"""HTTP request handler for DDNS updates."""
|
||||
@@ -161,39 +241,55 @@ class DDNSRequestHandler(BaseHTTPRequestHandler):
|
||||
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")
|
||||
self.respond(400, "Bad Request")
|
||||
return
|
||||
|
||||
# Bad rate limit check
|
||||
try:
|
||||
self._handle_get_request(client_ip)
|
||||
except DDNSClientError as e:
|
||||
if self.app.bad_limiter:
|
||||
blocked, retry_at = self.app.bad_limiter.is_blocked(client_ip)
|
||||
if blocked:
|
||||
logging.warning(
|
||||
f"Rate limited (bad): client={client_ip}, "
|
||||
f"retry_at={datetime_str(retry_at)}")
|
||||
self.respond(429, "abuse")
|
||||
return
|
||||
self.app.bad_limiter.record(client_ip)
|
||||
logging.warning(e)
|
||||
self.respond(e.code, e.status)
|
||||
except DDNSError as e:
|
||||
logging.error(e)
|
||||
self.respond(500, e.status)
|
||||
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
|
||||
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")
|
||||
self.respond(404, "Not Found")
|
||||
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
|
||||
params = parse_qs(parsed.query)
|
||||
|
||||
@@ -204,85 +300,33 @@ class DDNSRequestHandler(BaseHTTPRequestHandler):
|
||||
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
|
||||
raise DDNSClientError(
|
||||
"Auth failed",
|
||||
401,
|
||||
STATUS_BADAUTH,
|
||||
client_ip
|
||||
)
|
||||
|
||||
# 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
|
||||
raise DDNSClientError(
|
||||
"Missing hostname",
|
||||
400,
|
||||
STATUS_NOHOST,
|
||||
client=client_ip,
|
||||
username=username
|
||||
)
|
||||
|
||||
# Validate and encode hostname
|
||||
try:
|
||||
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
|
||||
# Validate credentials
|
||||
user = self._authenticate(client_ip, username, password)
|
||||
|
||||
# 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.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
|
||||
hostname = self._check_permissions(client_ip, user, hostname_param)
|
||||
|
||||
# Process myip parameter
|
||||
ipv4 = None
|
||||
myip = extract_param(params, endpoint["params"]["ipv4"])
|
||||
if myip:
|
||||
try:
|
||||
rtype, myip = detect_ip_type(myip)
|
||||
@@ -291,31 +335,126 @@ class DDNSRequestHandler(BaseHTTPRequestHandler):
|
||||
else:
|
||||
ipv6 = myip
|
||||
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
|
||||
ipv6 = None
|
||||
myip6 = extract_param(params, endpoint["params"]["ipv6"])
|
||||
if myip6:
|
||||
try:
|
||||
rtype, myip6 = detect_ip_type(myip6)
|
||||
if rtype == "AAAA":
|
||||
ipv6 = myip6
|
||||
else:
|
||||
return (400, "badip")
|
||||
raise 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
|
||||
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 = 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
|
||||
ipv6_changed = False
|
||||
@@ -334,11 +473,15 @@ class DDNSRequestHandler(BaseHTTPRequestHandler):
|
||||
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}"
|
||||
logging.error(f"DNS error: {e}")
|
||||
raise DDNSError(
|
||||
"Update failed",
|
||||
STATUS_DNSERR,
|
||||
client=client_ip,
|
||||
hostname=hostname.hostname,
|
||||
zone=hostname.zone,
|
||||
ipv4=ipv4
|
||||
)
|
||||
return (500, "dnserr")
|
||||
|
||||
if ipv6:
|
||||
hostname.last_ipv6_update = now
|
||||
@@ -355,19 +498,19 @@ class DDNSRequestHandler(BaseHTTPRequestHandler):
|
||||
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}"
|
||||
logging.error(f"DNS error: {e}")
|
||||
raise DDNSError(
|
||||
"Update failed",
|
||||
STATUS_DNSERR,
|
||||
client=client_ip,
|
||||
hostname=hostname.hostname,
|
||||
zone=hostname.zone,
|
||||
ipv6=ipv6
|
||||
)
|
||||
return (500, "dnserr")
|
||||
|
||||
# Update database
|
||||
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 = ""
|
||||
if ipv4_changed:
|
||||
changed_addrs += f" ipv4={ipv4}"
|
||||
@@ -379,10 +522,13 @@ class DDNSRequestHandler(BaseHTTPRequestHandler):
|
||||
f"No change: client={client_ip} hostname={hostname.hostname} "
|
||||
f"zone={hostname.zone}{changed_addrs} notify_change={str(notify_change).lower()}"
|
||||
)
|
||||
return (
|
||||
200, "nochg",
|
||||
{"ipv4": hostname.last_ipv4, "ipv6": hostname.last_ipv6}
|
||||
self.respond(
|
||||
200,
|
||||
STATUS_NOCHG,
|
||||
ipv4=hostname.last_ipv4,
|
||||
ipv6=hostname.last_ipv6
|
||||
)
|
||||
return
|
||||
|
||||
logging.info(
|
||||
f"Updated: client={client_ip} hostname={hostname.hostname} "
|
||||
@@ -400,9 +546,11 @@ class DDNSRequestHandler(BaseHTTPRequestHandler):
|
||||
except Exception as e:
|
||||
logging.error(f"Sending change notification error: {e}")
|
||||
|
||||
return (
|
||||
200, "good",
|
||||
{"ipv4": hostname.last_ipv4, "ipv6": hostname.last_ipv6}
|
||||
self.respond(
|
||||
200,
|
||||
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.start()
|
||||
|
||||
|
||||
# Setup signal handlers
|
||||
def signal_handler(signum, frame):
|
||||
logging.info(f"Signal received: {signum}, shutting down")
|
||||
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.SIGINT, signal_handler)
|
||||
signal.signal(signal.SIGHUP, sighup_handler)
|
||||
|
||||
paths = ", ".join(ep["path"] for ep in config["endpoints"])
|
||||
logging.info(f"Daemon started: {proto}://{host}:{port} endpoints=[{paths}]")
|
||||
@@ -457,6 +630,9 @@ def run_daemon(app):
|
||||
while not app.is_shutting_down():
|
||||
server.handle_request()
|
||||
|
||||
# Graceful shutdown - wait for active requests
|
||||
server.wait_for_requests(5)
|
||||
|
||||
# Cleanup
|
||||
expired_cleanup_thread.stop()
|
||||
ratelimit_cleanup_thread.stop()
|
||||
|
||||
Reference in New Issue
Block a user