Refactor and further improve error handling / logging

This commit is contained in:
2026-02-01 04:25:21 +01:00
parent 60ebf4b387
commit cd5979556c
2 changed files with 144 additions and 107 deletions

View File

@@ -48,30 +48,32 @@ 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): if not (ipv4_deleted or ipv6_deleted):

View File

@@ -139,24 +139,29 @@ class DDNSServer(ThreadingHTTPServer):
class DDNSError(Exception): class DDNSError(Exception):
def __init__(self, message, code, status, client_ip, username=None, hostname=None): def __init__(self, message, status, **kwargs):
super().__init__(self, message) super().__init__(self, message)
self.message = message self.message = message
self.code = code
self.status = status self.status = status
self.client_ip = client_ip self.kwargs = kwargs
self.username = username
self.hostname = hostname
def __str__(self): def __str__(self):
string = f"{self.message}: client={self.client_ip}" if not self.kwargs:
if self.username: return self.message
string += f" username={self.username}"
if self.hostname: string = f"{self.message}:"
string += f" hostname={self.hostname}" for key, value in self.kwargs.items():
string += f" {key}={value}"
return string 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."""
@@ -243,23 +248,16 @@ class DDNSRequestHandler(BaseHTTPRequestHandler):
self.respond(400, "Bad Request") self.respond(400, "Bad Request")
return 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
try: try:
self._handle_get_request(client_ip) self._handle_get_request(client_ip)
except DDNSError as e: except DDNSClientError as e:
if self.app.bad_limiter: if self.app.bad_limiter:
self.app.bad_limiter.record(client_ip) self.app.bad_limiter.record(client_ip)
logging.warning(e) logging.warning(e)
self.respond(e.code, e.status) self.respond(e.code, e.status)
except DDNSError as e:
logging.error(e)
self.respond(500, e.status)
except DatabaseError as e: except DatabaseError as e:
logging.error(f"Database error: {e}") logging.error(f"Database error: {e}")
self.respond(500, "Internal Server Error") self.respond(500, "Internal Server Error")
@@ -280,6 +278,18 @@ class DDNSRequestHandler(BaseHTTPRequestHandler):
self.respond(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)
@@ -290,7 +300,7 @@ 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:
raise DDNSError( raise DDNSClientError(
"Auth failed", "Auth failed",
401, 401,
STATUS_BADAUTH, STATUS_BADAUTH,
@@ -300,48 +310,103 @@ class DDNSRequestHandler(BaseHTTPRequestHandler):
# Get hostname parameter # Get hostname parameter
hostname_param = extract_param(params, endpoint["params"]["hostname"]) hostname_param = extract_param(params, endpoint["params"]["hostname"])
if not hostname_param: if not hostname_param:
raise DDNSError( raise DDNSClientError(
"Missing hostname", "Missing hostname",
400, 400,
STATUS_NOHOST, STATUS_NOHOST,
client_ip, client=client_ip,
username username=username
) )
# Validate credentials # Validate credentials
user = self._authenticate(client_ip, username, password) user = self._authenticate(client_ip, username, password)
# Check hostname ownership # Check hostname ownership
hostname = self._check_permissions(client_ip, hostname_param, user) 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)
if rtype == "A":
ipv4 = myip
else:
ipv6 = myip
except ValueError:
raise DDNSClientError(
"Bad IP address",
400,
STATUS_BADIP,
client=client_ip,
username=username,
hostname=hostname.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:
raise ValueError
except ValueError:
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:
rtype, ip = detect_ip_type(client_ip)
if rtype == "A":
ipv4 = ip
else:
ipv6 = ip
# 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 # Good rate limit check
if self.app.good_limiter: if self.app.good_limiter:
blocked, retry_at = self.app.good_limiter.is_blocked(client_ip) blocked, retry_at = self.app.good_limiter.is_blocked(client_ip)
if blocked: if blocked:
logging.warning( raise DDNSClientError(
f"Rate limited: client={client_ip}, " "Rate limited (good requests)",
f"retry_at={datetime_str(retry_at)}") 429,
self.respond(429, STATUS_ABUSE) STATUS_ABUSE,
return client=client_ip,
username=username,
retry_at=datetime_str(retry_at)
)
# Record good request # Record good request
if self.app.good_limiter: if self.app.good_limiter:
self.app.good_limiter.record(client_ip) self.app.good_limiter.record(client_ip)
# get myip, myip6 and notify_change parameters
myip = extract_param(params, endpoint["params"]["ipv4"])
myip6 = extract_param(params, endpoint["params"]["ipv6"])
notify_change = extract_param(params, endpoint["params"]["notify_change"])
# Process update request # Process update request
code, status, kwargs = self._process_ip_update( self._process_ip_update(
hostname,
client_ip, client_ip,
myip, user,
myip6, hostname,
ipv4,
ipv6,
notify_change notify_change
) )
self.respond(code, status, **kwargs)
def _authenticate(self, client_ip, username, password): def _authenticate(self, client_ip, username, password):
try: try:
@@ -354,17 +419,17 @@ class DDNSRequestHandler(BaseHTTPRequestHandler):
self.app.password_hasher.verify(user.password_hash, password) self.app.password_hasher.verify(user.password_hash, password)
except (DoesNotExist, VerifyMismatchError): except (DoesNotExist, VerifyMismatchError):
raise DDNSError( raise DDNSClientError(
"Auth failed", "Auth failed",
401, 401,
STATUS_BADAUTH, STATUS_BADAUTH,
client_ip, client=client_ip,
username username=username
) )
return user return user
def _check_permissions(self, client_ip, hostname_param, user): def _check_permissions(self, client_ip, user, hostname_param):
# Check hostname ownership # Check hostname ownership
code = None code = None
@@ -376,55 +441,19 @@ class DDNSRequestHandler(BaseHTTPRequestHandler):
code = 400 code = 400
if code: if code:
raise DDNSError( raise DDNSClientError(
"Access denied", "Access denied",
code, code,
STATUS_NOHOST, STATUS_NOHOST,
client_ip, client=client_ip,
user.username, username=user.username,
hostname_param hostname=hostname_param
) )
return hostname return hostname
def _process_ip_update(self, hostname, client_ip, myip, myip6, notify_change): def _process_ip_update(self, client_ip, user, hostname, ipv4, ipv6, notify_change):
"""Process IP update for hostname.""" """Process IP update for hostname."""
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, STATUS_BADIP, {})
# Process myip6 parameter
if myip6:
try:
rtype, myip6 = detect_ip_type(myip6)
if rtype == "AAAA":
ipv6 = myip6
else:
return (400, STATUS_BADIP, {})
except ValueError:
return (400, STATUS_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, STATUS_BADIP, {})
now = now_utc() now = now_utc()
ipv4_changed = False ipv4_changed = False
@@ -444,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
@@ -465,11 +498,15 @@ 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()
@@ -480,18 +517,16 @@ class DDNSRequestHandler(BaseHTTPRequestHandler):
if ipv6_changed: if ipv6_changed:
changed_addrs += f" ipv6={ipv6}" changed_addrs += f" ipv6={ipv6}"
notify_change = notify_change.lower() in ["1", "y", "yes", "on", "true"] \
if notify_change else False
if not ipv4_changed and not ipv6_changed: if not ipv4_changed and not ipv6_changed:
logging.info( logging.info(
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, STATUS_NOCHG,
{"ipv4": hostname.last_ipv4, "ipv6": hostname.last_ipv6} {"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} "
@@ -509,7 +544,7 @@ 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, STATUS_GOOD,
{"ipv4": hostname.last_ipv4, "ipv6": hostname.last_ipv6} {"ipv4": hostname.last_ipv4, "ipv6": hostname.last_ipv6}
) )