Improve error handling and logging

This commit is contained in:
2026-02-01 00:31:16 +01:00
parent 89e63858a0
commit 870a1b9f00

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,25 @@ class DDNSServer(ThreadingHTTPServer):
super().server_close() super().server_close()
class DDNSError(Exception):
def __init__(self, message, code, status, client_ip, username=None, hostname=None):
super().__init__(self, message)
self.message = message
self.code = code
self.status = status
self.client_ip = client_ip
self.username = username
self.hostname = hostname
def __str__(self):
string = f"{self.message}: client={self.client_ip}"
if self.username:
string += f" username={self.username}"
if self.hostname:
string += f" hostname={self.hostname}"
return string
class DDNSRequestHandler(BaseHTTPRequestHandler): class DDNSRequestHandler(BaseHTTPRequestHandler):
"""HTTP request handler for DDNS updates.""" """HTTP request handler for DDNS updates."""
@@ -217,21 +236,11 @@ class DDNSRequestHandler(BaseHTTPRequestHandler):
def do_GET(self): def do_GET(self):
"""Handle GET requests.""" """Handle GET requests."""
set_txn_id() set_txn_id()
try:
self._handle_get_request()
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):
"""Handle GET request logic."""
try: try:
client_ip = self.get_client_ip() client_ip = self.get_client_ip()
except ProxyHeaderError as e: except ProxyHeaderError as e:
logging.error(f"Proxy header error: {e}") logging.error(f"Proxy header error: {e}")
self.send_response_body(400, "Bad Request") self.respond(400, "Bad Request")
return return
# Bad rate limit check # Bad rate limit check
@@ -244,13 +253,31 @@ class DDNSRequestHandler(BaseHTTPRequestHandler):
self.respond(429, STATUS_ABUSE) self.respond(429, STATUS_ABUSE)
return return
try:
self._handle_get_request(client_ip)
except DDNSError as e:
if self.app.bad_limiter:
self.app.bad_limiter.record(client_ip)
logging.warning(e)
self.respond(e.code, 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 # 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
# Parse query parameters # Parse query parameters
@@ -263,48 +290,29 @@ 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 DDNSError(
self._handle_bad_request(client_ip, 401, STATUS_BADAUTH) "Auth failed",
return 401,
STATUS_BADAUTH,
# Validate credentials client_ip
try: )
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 # Get hostname parameter
hostname_param = extract_param(params, endpoint["params"]["hostname"]) hostname_param = extract_param(params, endpoint["params"]["hostname"])
if not hostname_param: if not hostname_param:
logging.warning(f"Missing hostname: client={client_ip} user={username}") raise DDNSError(
self._handle_bad_request(client_ip, 400, STATUS_NOHOST) "Missing hostname",
return 400,
STATUS_NOHOST,
client_ip,
username
)
# Validate credentials
user = self._authenticate(client_ip, username, password)
# Check hostname ownership # Check hostname ownership
try: hostname = self._check_permissions(client_ip, hostname_param, user)
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 # Good rate limit check
if self.app.good_limiter: if self.app.good_limiter:
@@ -320,23 +328,67 @@ class DDNSRequestHandler(BaseHTTPRequestHandler):
if self.app.good_limiter: if self.app.good_limiter:
self.app.good_limiter.record(client_ip) self.app.good_limiter.record(client_ip)
# Determine IPs to update # get myip, myip6 and notify_change parameters
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"]) myip = extract_param(params, endpoint["params"]["ipv4"])
myip6 = extract_param(params, endpoint["params"]["ipv6"]) myip6 = extract_param(params, endpoint["params"]["ipv6"])
notify_change = extract_param(params, endpoint["params"]["notify_change"])
# Process update request
code, status, kwargs = self._process_ip_update(
hostname,
client_ip,
myip,
myip6,
notify_change
)
self.respond(code, status, **kwargs)
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 DDNSError(
"Auth failed",
401,
STATUS_BADAUTH,
client_ip,
username
)
return user
def _check_permissions(self, client_ip, hostname_param, user):
# 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 DDNSError(
"Access denied",
code,
STATUS_NOHOST,
client_ip,
user.username,
hostname_param
)
return hostname
def _process_ip_update(self, hostname, client_ip, myip, myip6, notify_change):
"""Process IP update for hostname."""
ipv4 = None ipv4 = None
ipv6 = None ipv6 = None
@@ -422,16 +474,15 @@ class DDNSRequestHandler(BaseHTTPRequestHandler):
# 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}"
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} "