Compare commits

...

10 Commits

7 changed files with 328 additions and 169 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,6 +10,7 @@ import dns.name
import dns.query import dns.query
import dns.rcode import dns.rcode
import dns.rdatatype import dns.rdatatype
import dns.resolver
import dns.tsigkeyring import dns.tsigkeyring
import dns.update import dns.update
@@ -353,6 +354,38 @@ class DNSService:
return hostname[:-len(zone_suffix)] return hostname[:-len(zone_suffix)]
return hostname return hostname
def query_record(self, hostname, zone, record_type):
"""
Check if DNS record exists.
Args:
hostname: Hostname (without zone suffix).
zone: DNS zone name.
record_type: Record type string (A or AAAA).
Returns:
IP address string if record exists, None otherwise.
"""
fqdn = f"{self._get_relative_name(hostname, zone)}.{zone}"
if not fqdn.endswith("."):
fqdn += "."
try:
resolver = dns.resolver.Resolver()
resolver.nameservers = [self.server]
resolver.port = self.port
resolver.lifetime = self.timeout
answers = resolver.resolve(fqdn, record_type)
return str(answers[0]) if answers else None
except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer,
dns.resolver.NoNameservers):
return None
except Exception as e:
logging.warning(
f"DNS query failed: hostname={hostname} zone={zone} "
f"type={record_type}: {e}"
)
return None
def update_record(self, hostname, zone, ip, ttl): def update_record(self, hostname, zone, ip, ttl):
""" """
Update a DNS record for the given hostname. Update a DNS record for the given hostname.

View File

@@ -2,6 +2,7 @@
import logging import logging
import os import os
import time
from . import datetime_naive_utc, datetime_aware_utc, now_utc from . import datetime_naive_utc, datetime_aware_utc, now_utc
from .dns import encode_dnsname, EncodingError from .dns import encode_dnsname, EncodingError
@@ -9,17 +10,18 @@ from peewee import (
AutoField, AutoField,
CharField, CharField,
DatabaseProxy, DatabaseProxy,
Model,
DateTimeField, DateTimeField,
DoesNotExist, DoesNotExist,
fn, fn,
ForeignKeyField, ForeignKeyField,
IntegerField, IntegerField,
Model,
SqliteDatabase, SqliteDatabase,
PeeweeException as DatabaseError, PeeweeException as DatabaseError,
) )
from playhouse.pool import PooledMySQLDatabase from playhouse.pool import PooledMySQLDatabase
# Re-export PeeweeException as DatabseException, DoesNotExist and # Re-export PeeweeException as DatabseException, DoesNotExist and
# EncodingError for convenience # EncodingError for convenience
__all__ = [ __all__ = [
@@ -64,12 +66,34 @@ MIGRATION_COLUMN_MAPS = {
} }
class DateTimeFieldUTC(DateTimeField):
def db_value(self, value):
if value:
value = datetime_naive_utc(value)
return super().db_value(value)
def python_value(self, value):
value = super().python_value(value)
if value:
return datetime_aware_utc(value)
class BaseModel(Model): class BaseModel(Model):
"""Base model with database binding.""" """Base model with database binding and save retry."""
class Meta: class Meta:
database = db database = db
def save(self, *args, max_retries=3, retry_delay=0.1, **kwargs):
"""Save with retry on DatabaseError (exponential backoff)."""
for attempt in range(max_retries):
try:
return super().save(*args, **kwargs)
except DatabaseError:
if attempt == max_retries - 1:
raise
time.sleep(retry_delay * (2 ** attempt))
class User(BaseModel): class User(BaseModel):
"""User model for authentication.""" """User model for authentication."""
@@ -78,19 +102,11 @@ class User(BaseModel):
username = CharField(max_length=64, unique=True) username = CharField(max_length=64, unique=True)
password_hash = CharField(max_length=128) password_hash = CharField(max_length=128)
email = CharField(max_length=255) email = CharField(max_length=255)
created_at = DateTimeField(default=now_utc) created_at = DateTimeFieldUTC(default=now_utc)
class Meta: class Meta:
table_name = "users" table_name = "users"
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.created_at = datetime_aware_utc(self.created_at)
def save(self, *args, **kwargs):
self.created_at = datetime_naive_utc(self.created_at)
return super().save(*args, **kwargs)
class Hostname(BaseModel): class Hostname(BaseModel):
"""Hostname model for DNS records.""" """Hostname model for DNS records."""
@@ -102,9 +118,9 @@ class Hostname(BaseModel):
dns_ttl = IntegerField() dns_ttl = IntegerField()
expiry_ttl = IntegerField() expiry_ttl = IntegerField()
last_ipv4 = CharField(max_length=15, null=True) last_ipv4 = CharField(max_length=15, null=True)
last_ipv4_update = DateTimeField(null=True) last_ipv4_update = DateTimeFieldUTC(null=True)
last_ipv6 = CharField(max_length=45, null=True) last_ipv6 = CharField(max_length=45, null=True)
last_ipv6_update = DateTimeField(null=True) last_ipv6_update = DateTimeFieldUTC(null=True)
class Meta: class Meta:
table_name = "hostnames" table_name = "hostnames"
@@ -112,21 +128,6 @@ class Hostname(BaseModel):
(('hostname', 'zone'), True), (('hostname', 'zone'), True),
) )
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.last_ipv4_update = datetime_aware_utc(self.last_ipv4_update)
self.last_ipv6_update = datetime_aware_utc(self.last_ipv6_update)
def save(self, *args, **kwargs):
"""Validate and encode hostname/zone before saving."""
if self.hostname:
self.hostname = encode_dnsname(self.hostname)
if self.zone:
self.zone = encode_dnsname(self.zone)
self.last_ipv4_update = datetime_naive_utc(self.last_ipv4_update)
self.last_ipv6_update = datetime_naive_utc(self.last_ipv6_update)
return super().save(*args, **kwargs)
class Version(BaseModel): class Version(BaseModel):
"""Database schema version for migrations.""" """Database schema version for migrations."""
@@ -162,7 +163,10 @@ def init_database(config: dict):
db_dir = os.path.dirname(db_path) db_dir = os.path.dirname(db_path)
if db_dir: if db_dir:
os.makedirs(db_dir, exist_ok=True) os.makedirs(db_dir, exist_ok=True)
actual_db = SqliteDatabase(db_path) actual_db = SqliteDatabase(db_path, pragmas={
'journal_mode': 'wal',
'busy_timeout': 5000,
})
db.initialize(actual_db) db.initialize(actual_db)
logging.debug(f"Database backend: SQLite path={db_path}") logging.debug(f"Database backend: SQLite path={db_path}")

View File

@@ -2,7 +2,6 @@
from __future__ import annotations from __future__ import annotations
import argon2
import base64 import base64
import ipaddress import ipaddress
import json import json
@@ -32,6 +31,7 @@ from .models import (
get_hostname_for_user, get_hostname_for_user,
get_user get_user
) )
from argon2.exceptions import VerifyMismatchError
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlparse from urllib.parse import parse_qs, urlparse
@@ -138,6 +138,30 @@ class DDNSServer(ThreadingHTTPServer):
super().server_close() super().server_close()
class DDNSError(Exception):
def __init__(self, message, status, **kwargs):
super().__init__(self, message)
self.message = message
self.status = status
self.kwargs = kwargs
def __str__(self):
if not self.kwargs:
return self.message
string = f"{self.message}:"
for key, value in self.kwargs.items():
string += f" {key}={value}"
return string
class DDNSClientError(DDNSError):
def __init__(self, message, code, status, **kwargs):
super().__init__(message, status, **kwargs)
self.code = code
class DDNSRequestHandler(BaseHTTPRequestHandler): class DDNSRequestHandler(BaseHTTPRequestHandler):
"""HTTP request handler for DDNS updates.""" """HTTP request handler for DDNS updates."""
@@ -218,41 +242,54 @@ class DDNSRequestHandler(BaseHTTPRequestHandler):
"""Handle GET requests.""" """Handle GET requests."""
set_txn_id() set_txn_id()
try: try:
self._handle_get_request() client_ip = self.get_client_ip()
except ProxyHeaderError as e:
logging.error(f"Proxy header error: {e}")
self.respond(400, "Bad Request")
return
try:
self._handle_get_request(client_ip)
except DDNSClientError as e:
if self.app.bad_limiter:
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: except Exception as e:
logging.exception(f"Uncaught exception: {e}") logging.exception(f"Uncaught exception: {e}")
self.respond(500, "Internal Server Error") self.respond(500, "Internal Server Error")
finally: finally:
clear_txn_id() clear_txn_id()
def _handle_get_request(self): def _handle_get_request(self, client_ip):
"""Handle GET request logic.""" """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.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, STATUS_ABUSE)
return
# Parse URL # Parse URL
parsed = urlparse(self.path) parsed = urlparse(self.path)
# Find matching endpoint # Find matching endpoint
endpoint = self.app.config["_endpoint_map"].get(parsed.path) endpoint = self.app.config["_endpoint_map"].get(parsed.path)
if endpoint is None: if endpoint is None:
self.send_response_body(404, "Not Found") self.respond(404, "Not Found")
return return
# Bad rate limit check
if self.app.bad_limiter:
blocked, retry_at = self.app.bad_limiter.is_blocked(client_ip)
if blocked:
raise DDNSClientError(
"Rate limited (bad requests)",
429,
STATUS_ABUSE,
client=client_ip,
retry_at=datetime_str(retry_at)
)
# Parse query parameters # Parse query parameters
params = parse_qs(parsed.query) params = parse_qs(parsed.query)
@@ -263,84 +300,33 @@ class DDNSRequestHandler(BaseHTTPRequestHandler):
password = extract_param(params, endpoint["params"]["password"]) password = extract_param(params, endpoint["params"]["password"])
if not username or not password: if not username or not password:
logging.warning(f"Auth failed: client={client_ip} user=anonymous") raise DDNSClientError(
self._handle_bad_request(client_ip, 401, STATUS_BADAUTH) "Auth failed",
return 401,
STATUS_BADAUTH,
client_ip
)
# Get hostname parameter
hostname_param = extract_param(params, endpoint["params"]["hostname"])
if not hostname_param:
raise DDNSClientError(
"Missing hostname",
400,
STATUS_NOHOST,
client=client_ip,
username=username
)
# Validate credentials # Validate credentials
try: user = self._authenticate(client_ip, username, password)
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, STATUS_BADAUTH)
return
# Get hostname parameter # Check hostname ownership
hostname_param = extract_param(params, endpoint["params"]["hostname"]) hostname = self._check_permissions(client_ip, user, hostname_param)
if not hostname_param:
logging.warning(f"Missing hostname: client={client_ip} user={username}")
self._handle_bad_request(client_ip, 400, STATUS_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, STATUS_NOHOST)
return
except EncodingError:
logging.warning(
f"Invalid hostname: client={client_ip}, "
f"hostname={hostname_param}")
self._handle_bad_request(client_ip, 400, STATUS_NOHOST)
return
except DatabaseError as e:
logging.error(f"Database error: {e}")
self.respond(500, "Internal Server Error")
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, STATUS_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
self.respond(code, status, **kwargs)
def _handle_bad_request(self, client_ip, code, status):
"""Handle bad request and record in rate limiter."""
if self.app.bad_limiter:
self.app.bad_limiter.record(client_ip)
self.respond(code, status)
def _process_ip_update(self, hostname, params, endpoint, client_ip):
"""Process IP update for hostname."""
myip = extract_param(params, endpoint["params"]["ipv4"])
myip6 = extract_param(params, endpoint["params"]["ipv6"])
ipv4 = None
ipv6 = None
# Process myip parameter # Process myip parameter
ipv4 = None
myip = extract_param(params, endpoint["params"]["ipv4"])
if myip: if myip:
try: try:
rtype, myip = detect_ip_type(myip) rtype, myip = detect_ip_type(myip)
@@ -349,30 +335,125 @@ class DDNSRequestHandler(BaseHTTPRequestHandler):
else: else:
ipv6 = myip ipv6 = myip
except ValueError: except ValueError:
return (400, STATUS_BADIP, {}) raise DDNSClientError(
"Bad IP address",
400,
STATUS_BADIP,
client=client_ip,
username=username,
hostname=hostname.hostname,
zone=hostname.zone,
ip=myip
)
# Process myip6 parameter # Process myip6 parameter
ipv6 = None
myip6 = extract_param(params, endpoint["params"]["ipv6"])
if myip6: if myip6:
try: try:
rtype, myip6 = detect_ip_type(myip6) rtype, myip6 = detect_ip_type(myip6)
if rtype == "AAAA": if rtype == "AAAA":
ipv6 = myip6 ipv6 = myip6
else: else:
return (400, STATUS_BADIP, {}) raise ValueError
except ValueError: except ValueError:
return (400, STATUS_BADIP, {}) raise DDNSClientError(
"Bad IP address",
400,
STATUS_BADIP,
client=client_ip,
username=username,
hostname=hostname.hostname,
zone=hostname.zone,
ipv6=myip6
)
# Auto-detect from client IP if no params # Auto-detect from client IP if no params
if ipv4 is None and ipv6 is None: if ipv4 is None and ipv6 is None:
try: rtype, ip = detect_ip_type(client_ip)
rtype, ip = detect_ip_type(client_ip) if rtype == "A":
if rtype == "A": ipv4 = ip
ipv4 = ip else:
else: ipv6 = ip
ipv6 = ip
except ValueError:
return (400, STATUS_BADIP, {})
# 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() now = now_utc()
ipv4_changed = False ipv4_changed = False
@@ -392,11 +473,15 @@ class DDNSRequestHandler(BaseHTTPRequestHandler):
ipv4_changed = True ipv4_changed = True
except Exception as e: except Exception as e:
hostname.save() hostname.save()
logging.error( logging.error(f"DNS error: {e}")
f"DNS update failed: client={client_ip} hostname={hostname.hostname} " raise DDNSError(
f"zone={hostname.zone} ipv4={ipv4} error={e}" "Update failed",
STATUS_DNSERR,
client=client_ip,
hostname=hostname.hostname,
zone=hostname.zone,
ipv4=ipv4
) )
return (500, STATUS_DNSERR, {})
if ipv6: if ipv6:
hostname.last_ipv6_update = now hostname.last_ipv6_update = now
@@ -413,19 +498,19 @@ class DDNSRequestHandler(BaseHTTPRequestHandler):
ipv6_changed = True ipv6_changed = True
except Exception as e: except Exception as e:
hostname.save() hostname.save()
logging.error( logging.error(f"DNS error: {e}")
f"DNS update failed: client={client_ip} hostname={hostname.hostname} " raise DDNSError(
f"zone={hostname.zone} ipv6={ipv6} error={e}" "Update failed",
STATUS_DNSERR,
client=client_ip,
hostname=hostname.hostname,
zone=hostname.zone,
ipv6=ipv6
) )
return (500, STATUS_DNSERR, {})
# Update database # Update database
hostname.save() hostname.save()
notify_change_val = extract_param(params, endpoint["params"]["notify_change"])
notify_change = notify_change_val.lower() not in ["0", "n", "no", "off"] \
if notify_change_val else False
changed_addrs = "" changed_addrs = ""
if ipv4_changed: if ipv4_changed:
changed_addrs += f" ipv4={ipv4}" changed_addrs += f" ipv4={ipv4}"
@@ -437,10 +522,13 @@ class DDNSRequestHandler(BaseHTTPRequestHandler):
f"No change: client={client_ip} hostname={hostname.hostname} " f"No change: client={client_ip} hostname={hostname.hostname} "
f"zone={hostname.zone}{changed_addrs} notify_change={str(notify_change).lower()}" f"zone={hostname.zone}{changed_addrs} notify_change={str(notify_change).lower()}"
) )
return ( self.respond(
200, STATUS_NOCHG, 200,
{"ipv4": hostname.last_ipv4, "ipv6": hostname.last_ipv6} STATUS_NOCHG,
ipv4=hostname.last_ipv4,
ipv6=hostname.last_ipv6
) )
return
logging.info( logging.info(
f"Updated: client={client_ip} hostname={hostname.hostname} " f"Updated: client={client_ip} hostname={hostname.hostname} "
@@ -458,9 +546,11 @@ class DDNSRequestHandler(BaseHTTPRequestHandler):
except Exception as e: except Exception as e:
logging.error(f"Sending change notification error: {e}") logging.error(f"Sending change notification error: {e}")
return ( self.respond(
200, STATUS_GOOD, 200,
{"ipv4": hostname.last_ipv4, "ipv6": hostname.last_ipv6} STATUS_GOOD,
ipv4=hostname.last_ipv4,
ipv6=hostname.last_ipv6
) )