From eafe106bf19c27fe20c8a5120734f2f11412f46c Mon Sep 17 00:00:00 2001 From: Thomas Oettli Date: Sat, 7 Feb 2026 22:34:50 +0100 Subject: [PATCH] Add GET parameter to allow useres to change expiry TTL --- README.md | 17 +++++- files/config.example.toml | 6 ++ src/ddns_service/config.py | 4 ++ src/ddns_service/server.py | 110 +++++++++++++++++++++++++++++++------ 4 files changed, 119 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 9e70654..9736b47 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,9 @@ path = "/var/lib/ddns-service/ddns.db" # required for sqlite [defaults] # dns_ttl = 60 # default: 60 # expiry_ttl = 3600 # default: 3600 +# expiry_ttl_min = # optional, min value via HTTP +# expiry_ttl_max = # optional, max value via HTTP +# expiry_ttl_allow_zero = true # default: true, allow 0 via HTTP [email] # enabled = false # default: false @@ -182,6 +185,7 @@ ipv6 = ["myip6", "ipv6", "ip6"] username = ["username", "user"] password = ["password", "pass", "token"] notify_change = ["notify_change"] +expiry_ttl = ["expiry_ttl"] [[endpoints]] path = "/nic/update" @@ -192,6 +196,7 @@ ipv6 = ["myip6"] username = ["username"] password = ["password"] notify_change = [] +expiry_ttl = [] ``` **Default accepted parameter names** (first match wins): @@ -203,6 +208,7 @@ notify_change = [] | username | username, user | | password | password, pass, token | | notify_change | notify_change | +| expiry_ttl | expiry_ttl | ## CLI Usage @@ -295,7 +301,7 @@ kill -HUP $(pidof ddns-service) ### Request ``` -GET /update?hostname=mypc.dyn.example.com[&myip=1.2.3.4][&myip6=2001:db8::1][¬ify_change=1] +GET /update?hostname=mypc.dyn.example.com[&myip=1.2.3.4][&myip6=2001:db8::1][¬ify_change=1][&expiry_ttl=7200] Authorization: Basic base64(username:password) ``` @@ -306,6 +312,8 @@ GET /update?hostname=mypc.dyn.example.com&username=myuser&password=secret Set `notify_change=1` to receive an email notification when the IP address changes. Requires email to be enabled and a change notification template configured. +Set `expiry_ttl=N` to change the hostname's expiry TTL (in seconds). Can be sent alone without IP parameters. + ### IP Detection - If `myip` and/or `myip6` provided: use those values @@ -325,7 +333,7 @@ Set `notify_change=1` to receive an email notification when the IP address chang **JSON (with `Accept: application/json`):** ```json -{"status": "good", "ipv4": "1.2.3.4", "ipv6": "2001:db8::1"} +{"status": "good", "ipv4": "1.2.3.4", "ipv6": "2001:db8::1", "expiry_ttl": 3600} ``` ## Client Examples @@ -346,6 +354,11 @@ With change notification: curl -u "username:password" "https://ddns.example.com/update?hostname=mypc.dyn.example.com¬ify_change=1" ``` +Change expiry TTL: +```bash +curl -u "username:password" "https://ddns.example.com/update?hostname=mypc.dyn.example.com&expiry_ttl=7200" +``` + ### wget ```bash diff --git a/files/config.example.toml b/files/config.example.toml index be75fcf..c505e81 100644 --- a/files/config.example.toml +++ b/files/config.example.toml @@ -42,6 +42,9 @@ path = "/var/lib/ddns-service/ddns.db" # required for sqlite [defaults] # dns_ttl = 60 # default, DNS record TTL in seconds # expiry_ttl = 3600 # default, 0 to disable expiration +# expiry_ttl_min = # optional, min value allowed via HTTP +# expiry_ttl_max = # optional, max value allowed via HTTP +# expiry_ttl_allow_zero = true # default, allow 0 (never expire) via HTTP [email] # enabled = false # default @@ -70,6 +73,7 @@ from_address = "ddns@example.com" # required if email.enabled # username: username, user # password: password, pass, token # notify_change: notify_change +# expiry_ttl: expiry_ttl # # Multiple endpoints can be defined with custom parameter names @@ -82,6 +86,7 @@ from_address = "ddns@example.com" # required if email.enabled # username = ["username", "user"] # password = ["password", "pass", "token"] # notify_change = ["notify_change"] +# expiry_ttl = ["expiry_ttl"] # [[endpoints]] # path = "/nic/update" @@ -92,3 +97,4 @@ from_address = "ddns@example.com" # required if email.enabled # username = ["username"] # password = ["password"] # notify_change = [] +# expiry_ttl = [] diff --git a/src/ddns_service/config.py b/src/ddns_service/config.py index 9899f65..0ab73e5 100644 --- a/src/ddns_service/config.py +++ b/src/ddns_service/config.py @@ -22,6 +22,7 @@ DEFAULT_ENDPOINT_PARAMS = { "username": ["username", "user"], "password": ["password", "pass", "token"], "notify_change": ["notify_change"], + "expiry_ttl": ["expiry_ttl"], } VALID_PARAM_KEYS = frozenset(DEFAULT_ENDPOINT_PARAMS.keys()) @@ -178,6 +179,9 @@ def load_config(config_path): cfg.setdefault("defaults", {}) cfg["defaults"].setdefault("dns_ttl", 60) cfg["defaults"].setdefault("expiry_ttl", 3600) + cfg["defaults"].setdefault("expiry_ttl_min", None) + cfg["defaults"].setdefault("expiry_ttl_max", None) + cfg["defaults"].setdefault("expiry_ttl_allow_zero", True) cfg.setdefault("email", {}) cfg["email"].setdefault("enabled", False) diff --git a/src/ddns_service/server.py b/src/ddns_service/server.py index c3b9995..e3c4086 100644 --- a/src/ddns_service/server.py +++ b/src/ddns_service/server.py @@ -377,10 +377,77 @@ class DDNSRequestHandler(BaseHTTPRequestHandler): else: ipv6 = ip + # Process expiry_ttl parameter + expiry_ttl_param = extract_param( + params, endpoint["params"]["expiry_ttl"]) + expiry_ttl = None + if expiry_ttl_param: + try: + expiry_ttl = int(expiry_ttl_param) + if expiry_ttl < 0: + raise ValueError + except ValueError: + raise DDNSClientError( + "Invalid expiry_ttl", + 400, + STATUS_NOHOST, + client=client_ip, + username=username, + hostname=hostname.hostname, + zone=hostname.zone, + expiry_ttl=expiry_ttl_param + ) + + # Validate bounds + defaults = self.app.config["defaults"] + allow_zero = defaults.get("expiry_ttl_allow_zero", True) + ttl_min = defaults.get("expiry_ttl_min") + ttl_max = defaults.get("expiry_ttl_max") + + if expiry_ttl == 0: + if not allow_zero: + raise DDNSClientError( + "Zero expiry_ttl not allowed", + 400, + STATUS_NOHOST, + client=client_ip, + username=username, + hostname=hostname.hostname, + zone=hostname.zone, + expiry_ttl=expiry_ttl + ) + else: + if ttl_min is not None and expiry_ttl < ttl_min: + raise DDNSClientError( + "expiry_ttl below minimum", + 400, + STATUS_NOHOST, + client=client_ip, + username=username, + hostname=hostname.hostname, + zone=hostname.zone, + expiry_ttl=expiry_ttl, + min=ttl_min + ) + if ttl_max is not None and expiry_ttl > ttl_max: + raise DDNSClientError( + "expiry_ttl above maximum", + 400, + STATUS_NOHOST, + client=client_ip, + username=username, + hostname=hostname.hostname, + zone=hostname.zone, + expiry_ttl=expiry_ttl, + max=ttl_max + ) + # 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 + 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: @@ -406,7 +473,8 @@ class DDNSRequestHandler(BaseHTTPRequestHandler): hostname, ipv4, ipv6, - notify_change + notify_change, + expiry_ttl ) def _authenticate(self, client_ip, username, password): @@ -466,7 +534,8 @@ class DDNSRequestHandler(BaseHTTPRequestHandler): except Exception as e: logging.error(f"DNS rollback failed ({record_type}): {e}") - def _process_ip_update(self, client_ip, user, hostname, ipv4, ipv6, notify_change): + def _process_ip_update(self, client_ip, user, hostname, ipv4, ipv6, + notify_change, expiry_ttl): """Process IP update for hostname.""" now = now_utc() @@ -475,6 +544,12 @@ class DDNSRequestHandler(BaseHTTPRequestHandler): ipv4_changed = False ipv6_changed = False + # Apply expiry_ttl if provided + expiry_ttl_changed = False + if expiry_ttl is not None and expiry_ttl != hostname.expiry_ttl: + hostname.expiry_ttl = expiry_ttl + expiry_ttl_changed = True + if ipv4: hostname.last_ipv4_update = now if ipv4 != hostname.last_ipv4: @@ -546,28 +621,30 @@ class DDNSRequestHandler(BaseHTTPRequestHandler): zone=hostname.zone ) - changed_addrs = "" - if ipv4_changed: - changed_addrs += f" ipv4={ipv4}" - if ipv6_changed: - changed_addrs += f" ipv6={ipv6}" - - if not ipv4_changed and not ipv6_changed: + if not ipv4_changed and not ipv6_changed and not expiry_ttl_changed: logging.info( 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} notify_change={str(notify_change).lower()}" ) self.respond( 200, STATUS_NOCHG, ipv4=hostname.last_ipv4, - ipv6=hostname.last_ipv6 + ipv6=hostname.last_ipv6, + expiry_ttl=hostname.expiry_ttl ) return + changed_info = "" + if ipv4_changed: + changed_info += f" ipv4={ipv4}" + if ipv6_changed: + changed_info += f" ipv6={ipv6}" + if expiry_ttl_changed: + changed_info += f" expiry_ttl={hostname.expiry_ttl}" logging.info( f"Updated: client={client_ip} hostname={hostname.hostname} " - f"zone={hostname.zone}{changed_addrs} notify_change={str(notify_change).lower()}" + f"zone={hostname.zone}{changed_info} notify_change={str(notify_change).lower()}" ) if notify_change: @@ -585,7 +662,8 @@ class DDNSRequestHandler(BaseHTTPRequestHandler): 200, STATUS_GOOD, ipv4=hostname.last_ipv4, - ipv6=hostname.last_ipv6 + ipv6=hostname.last_ipv6, + expiry_ttl=hostname.expiry_ttl )