Compare commits

...
36 Commits
Author SHA1 Message Date
spacefreak 7a7442c3db Set development status to stable 2026-07-22 15:29:06 +02:00
spacefreak a5f0055b4f Fix download instructions for notification templates in README.md 2026-07-22 15:26:53 +02:00
spacefreak 8096484a6e Add version specific installation instructions to README.md 2026-07-22 15:23:01 +02:00
spacefreak 76ddd7c18c Mention MySQL in README.md 2026-07-22 15:12:27 +02:00
spacefreak ca8b29fdc8 Refactor source and add SHUTDOWN_TIMEOUT constant 2026-07-22 15:00:28 +02:00
spacefreak 25043be991 Handle config reload thread-safe 2026-07-22 14:56:47 +02:00
spacefreak d13aea8e7d Consolidate password verification 2026-07-22 14:50:33 +02:00
spacefreak aec5c278a1 Add function close_database 2026-02-08 14:48:48 +01:00
spacefreak 6af6176dec Add MAX_HOSTNAME_LENGTH and MAX_LABEL_LENGTH constants 2026-02-08 14:48:02 +01:00
spacefreak 34e31297f8 Fix missing comma 2026-02-08 14:46:48 +01:00
spacefreak 3d658968d3 Fix notification email in case only expiry-TTL changes 2026-02-08 03:03:24 +01:00
spacefreak 81ffdc9925 Add and use permission table, update CLI and README.md 2026-02-08 02:42:49 +01:00
spacefreak eafe106bf1 Add GET parameter to allow useres to change expiry TTL 2026-02-07 22:34:50 +01:00
spacefreak 5b51a8a153 Fix call to send_expiry_notification after cleanup 2026-02-05 22:25:47 +01:00
spacefreak acd16b4a82 Fix database query on cleanup 2026-02-05 22:18:29 +01:00
spacefreak d0e9136e53 Remove obsolete function disable_logging 2026-02-05 22:08:11 +01:00
spacefreak d780572d71 Improve database queries 2026-02-05 22:05:40 +01:00
spacefreak e6339eae61 Log to stdout when using CLI 2026-02-05 21:53:33 +01:00
spacefreak 485c977bdd Order output of CLI list commands 2026-02-05 21:45:26 +01:00
spacefreak 419adb10a4 Update timestamps even when IP does not change 2026-02-05 21:16:43 +01:00
spacefreak 871a685267 Add extra_started_commands variable to OpenRC init script 2026-02-05 21:03:03 +01:00
spacefreak 8dda556480 Fix bug when reloading config with SSL enabled 2026-02-05 20:53:13 +01:00
spacefreak 215fbb116e Add rollback logic when db update fails after DNS changes 2026-02-05 20:19:43 +01:00
spacefreak adaf08f4d1 Raise errors when querying DNS 2026-02-05 18:48:36 +01:00
spacefreak 2d61ad11b3 Add DNS query function 2026-02-01 17:03:33 +01:00
spacefreak e37a9e84a6 Add pragmas to SQLite DBs and add retry logic to BaseModel 2026-02-01 17:01:35 +01:00
spacefreak 21b5a4c553 Fix bug when responding with nochange or success 2026-02-01 05:09:09 +01:00
spacefreak 5d2b9c3ffb Cleanup: use max of last update and start time to determine expiry time 2026-02-01 04:57:22 +01:00
spacefreak cd5979556c Refactor and further improve error handling / logging 2026-02-01 04:25:21 +01:00
spacefreak 60ebf4b387 Improve cleanup logic 2026-02-01 01:36:52 +01:00
spacefreak 105a9d4253 Handle naive datetime in database with custom database field 2026-02-01 01:27:48 +01:00
spacefreak 870a1b9f00 Improve error handling and logging 2026-02-01 00:31:16 +01:00
spacefreak 89e63858a0 Add reload function to OpenRC init script 2026-01-24 04:03:51 +01:00
spacefreak e365fa7d77 Change README.md and config.example.toml 2026-01-24 04:03:26 +01:00
spacefreak b97eb0404c Improve timezone handling, db models convert naive/timezone-aware 2026-01-24 02:27:57 +01:00
spacefreak 07e37e525c Use db model hostname validation in cli and improve exception handling 2026-01-24 00:46:48 +01:00
16 changed files with 1108 additions and 451 deletions
+76 -13
View File
@@ -7,7 +7,7 @@ Dynamic DNS update service with CLI administration. Accepts HTTP(S) requests to
- HTTP(S) server for DynDNS-compatible updates - HTTP(S) server for DynDNS-compatible updates
- Multiple endpoints with configurable parameter aliases - Multiple endpoints with configurable parameter aliases
- Dual-stack IPv4/IPv6 support - Dual-stack IPv4/IPv6 support
- SQLite or MariaDB database backend - SQLite or MariaDB/MySQL database backend
- Argon2 password hashing - Argon2 password hashing
- Rate limiting (separate limits for good/bad requests) - Rate limiting (separate limits for good/bad requests)
- TTL-based automatic record expiration - TTL-based automatic record expiration
@@ -22,10 +22,21 @@ Dynamic DNS update service with CLI administration. Accepts HTTP(S) requests to
```bash ```bash
pip install git+https://git.ccc-rheintal.ch/spacefreak/ddns-service.git pip install git+https://git.ccc-rheintal.ch/spacefreak/ddns-service.git
# With MariaDB support: # With MariaDB/MySQL support:
pip install "ddns-service[mysql] @ git+https://git.ccc-rheintal.ch/spacefreak/ddns-service.git" pip install "ddns-service[mysql] @ git+https://git.ccc-rheintal.ch/spacefreak/ddns-service.git"
``` ```
### Install a specific version
Append `@<git-tag>` to the repository URL to install a tagged release:
```bash
pip install git+https://git.ccc-rheintal.ch/spacefreak/ddns-service.git@v1.0.0
# With MariaDB/MySQL support:
pip install "ddns-service[mysql] @ git+https://git.ccc-rheintal.ch/spacefreak/ddns-service.git@v1.0.0"
```
Requires Python 3.11+. Dependencies installed automatically: argon2-cffi, dnspython, jinja2, peewee (+ pymysql for mysql extra). Requires Python 3.11+. Dependencies installed automatically: argon2-cffi, dnspython, jinja2, peewee (+ pymysql for mysql extra).
## Service setup ## Service setup
@@ -46,8 +57,8 @@ chown ddns:ddns /etc/ddns-service /var/lib/ddns-service /var/log/ddns-service
3. Config file and templates: 3. Config file and templates:
```bash ```bash
wget -O /etc/ddns-service/config.toml https://git.ccc-rheintal.ch/spacefreak/ddns-service/raw/branch/master/files/config.example.toml wget -O /etc/ddns-service/config.toml https://git.ccc-rheintal.ch/spacefreak/ddns-service/raw/branch/master/files/config.example.toml
wget -O /etc/ddns-service/config.toml https://git.ccc-rheintal.ch/spacefreak/ddns-service/raw/branch/master/files/change_notification.j2 wget -O /etc/ddns-service/change_notification.j2 https://git.ccc-rheintal.ch/spacefreak/ddns-service/raw/branch/master/files/change_notification.j2
wget -O /etc/ddns-service/config.toml https://git.ccc-rheintal.ch/spacefreak/ddns-service/raw/branch/master/files/expiry_notification.j2 wget -O /etc/ddns-service/expiry_notification.j2 https://git.ccc-rheintal.ch/spacefreak/ddns-service/raw/branch/master/files/expiry_notification.j2
# The config file must not be world readable! # The config file must not be world readable!
chmod 640 /etc/ddns-service/config.toml chmod 640 /etc/ddns-service/config.toml
@@ -73,6 +84,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 +99,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/MySQL 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)
@@ -106,6 +121,9 @@ path = "/var/lib/ddns-service/ddns.db" # required for sqlite
[defaults] [defaults]
# dns_ttl = 60 # default: 60 # dns_ttl = 60 # default: 60
# expiry_ttl = 3600 # default: 3600 # 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] [email]
# enabled = false # default: false # enabled = false # default: false
@@ -178,6 +196,7 @@ ipv6 = ["myip6", "ipv6", "ip6"]
username = ["username", "user"] username = ["username", "user"]
password = ["password", "pass", "token"] password = ["password", "pass", "token"]
notify_change = ["notify_change"] notify_change = ["notify_change"]
expiry_ttl = ["expiry_ttl"]
[[endpoints]] [[endpoints]]
path = "/nic/update" path = "/nic/update"
@@ -188,6 +207,7 @@ ipv6 = ["myip6"]
username = ["username"] username = ["username"]
password = ["password"] password = ["password"]
notify_change = [] notify_change = []
expiry_ttl = []
``` ```
**Default accepted parameter names** (first match wins): **Default accepted parameter names** (first match wins):
@@ -199,6 +219,7 @@ notify_change = []
| username | username, user | | username | username, user |
| password | password, pass, token | | password | password, pass, token |
| notify_change | notify_change | | notify_change | notify_change |
| expiry_ttl | expiry_ttl |
## CLI Usage ## CLI Usage
@@ -237,8 +258,39 @@ ddns-service user passwd myuser
ddns-service user email myuser new@example.com ddns-service user email myuser new@example.com
``` ```
### Permission Management
Permissions control which hostnames users can update.
```bash
# List all permissions
ddns-service permission list
# List permissions for specific user
ddns-service permission list --user myuser
# Add permission for exact hostname
ddns-service permission add myuser mypc dyn.example.com
# Add wildcard permission (any hostname in zone)
ddns-service permission add myuser '*' dyn.example.com
# Add suffix wildcard (e.g., *.home matches foo.home, bar.home)
ddns-service permission add myuser '*.home' dyn.example.com
# Delete permission
ddns-service permission delete myuser mypc dyn.example.com
```
**Pattern matching:**
- `*` - matches any hostname in the zone
- `*.suffix` - matches hostnames ending in `.suffix` (recursive)
- `exact` - matches only that exact hostname
### Hostname Management ### Hostname Management
Hostnames are auto-created when users with permission send their first update.
```bash ```bash
# List all hostnames # List all hostnames
ddns-service hostname list ddns-service hostname list
@@ -246,13 +298,6 @@ ddns-service hostname list
# List hostnames for specific user # List hostnames for specific user
ddns-service hostname list --user myuser ddns-service hostname list --user myuser
# Add hostname
ddns-service hostname add myuser mypc dyn.example.com
# Add hostname with custom TTLs
ddns-service hostname add myuser mypc dyn.example.com \
--dns-ttl 60 --expiry-ttl 7200
# Modify hostname TTLs # Modify hostname TTLs
ddns-service hostname modify mypc dyn.example.com --dns-ttl 120 ddns-service hostname modify mypc dyn.example.com --dns-ttl 120
@@ -276,12 +321,22 @@ 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
``` ```
GET /update?hostname=mypc.dyn.example.com[&myip=1.2.3.4][&myip6=2001:db8::1][&notify_change=1] GET /update?hostname=mypc.dyn.example.com[&myip=1.2.3.4][&myip6=2001:db8::1][&notify_change=1][&expiry_ttl=7200]
Authorization: Basic base64(username:password) Authorization: Basic base64(username:password)
``` ```
@@ -292,6 +347,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 `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 ### IP Detection
- If `myip` and/or `myip6` provided: use those values - If `myip` and/or `myip6` provided: use those values
@@ -311,7 +368,7 @@ Set `notify_change=1` to receive an email notification when the IP address chang
**JSON (with `Accept: application/json`):** **JSON (with `Accept: application/json`):**
```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 ## Client Examples
@@ -332,6 +389,11 @@ With change notification:
curl -u "username:password" "https://ddns.example.com/update?hostname=mypc.dyn.example.com&notify_change=1" curl -u "username:password" "https://ddns.example.com/update?hostname=mypc.dyn.example.com&notify_change=1"
``` ```
Change expiry TTL:
```bash
curl -u "username:password" "https://ddns.example.com/update?hostname=mypc.dyn.example.com&expiry_ttl=7200"
```
### wget ### wget
```bash ```bash
@@ -401,6 +463,7 @@ Templates use Jinja2 syntax. Available variables:
| ipv6_changed | Boolean, IPv6 changed | | ipv6_changed | Boolean, IPv6 changed |
| ipv6 | Current IPv6 address | | ipv6 | Current IPv6 address |
| last_ipv6_update | Last IPv6 update time | | last_ipv6_update | Last IPv6 update time |
| expiry_ttl_changed | Boolean, Expiry-TTL changed |
| expiry_ttl | Expiry TTL in seconds | | expiry_ttl | Expiry TTL in seconds |
**Expiry notification:** **Expiry notification:**
+3
View File
@@ -7,3 +7,6 @@ IPv4 address: {{ipv4}} (changed at: {{last_ipv4_update}})
{% if ipv6_changed %} {% if ipv6_changed %}
IPv6 address: {{ipv6}} (changed at: {{last_ipv6_update}}) IPv6 address: {{ipv6}} (changed at: {{last_ipv6_update}})
{% endif %} {% endif %}
{% if expiry_ttl_changed %}
Expiry-TTL: {{expiry_ttl}}
{% endif %}
+10
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
@@ -38,6 +42,9 @@ path = "/var/lib/ddns-service/ddns.db" # required for sqlite
[defaults] [defaults]
# dns_ttl = 60 # default, DNS record TTL in seconds # dns_ttl = 60 # default, DNS record TTL in seconds
# expiry_ttl = 3600 # default, 0 to disable expiration # 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] [email]
# enabled = false # default # enabled = false # default
@@ -66,6 +73,7 @@ from_address = "ddns@example.com" # required if email.enabled
# username: username, user # username: username, user
# password: password, pass, token # password: password, pass, token
# notify_change: notify_change # notify_change: notify_change
# expiry_ttl: expiry_ttl
# #
# Multiple endpoints can be defined with custom parameter names # Multiple endpoints can be defined with custom parameter names
@@ -78,6 +86,7 @@ from_address = "ddns@example.com" # required if email.enabled
# username = ["username", "user"] # username = ["username", "user"]
# password = ["password", "pass", "token"] # password = ["password", "pass", "token"]
# notify_change = ["notify_change"] # notify_change = ["notify_change"]
# expiry_ttl = ["expiry_ttl"]
# [[endpoints]] # [[endpoints]]
# path = "/nic/update" # path = "/nic/update"
@@ -88,3 +97,4 @@ from_address = "ddns@example.com" # required if email.enabled
# username = ["username"] # username = ["username"]
# password = ["password"] # password = ["password"]
# notify_change = [] # notify_change = []
# expiry_ttl = []
+7
View File
@@ -11,6 +11,7 @@ command_args="--daemon ${OPTIONS}"
command_user="${USER}:${GROUP}" command_user="${USER}:${GROUP}"
command_background="yes" command_background="yes"
pidfile="/run/${RC_SVCNAME}.pid" pidfile="/run/${RC_SVCNAME}.pid"
extra_started_commands="reload"
depend() { depend() {
need net need net
@@ -20,3 +21,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 $?
}
+1 -1
View File
@@ -26,7 +26,7 @@ readme = "README.md"
license = "GPL-3.0-only" license = "GPL-3.0-only"
keywords = ["dns", "ddns", "service", "http", "https"] keywords = ["dns", "ddns", "service", "http", "https"]
classifiers = [ classifiers = [
"Development Status :: 4 - Beta", "Development Status :: 5 - Stable",
"Topic :: Internet :: Name Service (DNS)", "Topic :: Internet :: Name Service (DNS)",
"Intended Audience :: System Administrators", "Intended Audience :: System Administrators",
"Programming Language :: Python :: 3" "Programming Language :: Python :: 3"
+68 -22
View File
@@ -10,26 +10,20 @@ import datetime
__version__ = "1.0.0" __version__ = "1.0.0"
__author__ = "Thomas Oettli <spacefreak@noop.ch>" __author__ = "Thomas Oettli <spacefreak@noop.ch>"
# 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"
__all__ = [ __all__ = [
"app", "app",
"cleanup", "cleanup",
"cli", "cli",
"config", "config",
"datetime_aware_utc",
"datetime_naive_utc",
"datetime_str", "datetime_str",
"dns", "dns",
"email", "email",
"logging", "logging",
"main", "main",
"models", "models",
"now_utc",
"ratelimit", "ratelimit",
"server", "server",
"STATUS_GOOD", "STATUS_GOOD",
@@ -41,13 +35,75 @@ __all__ = [
"STATUS_BADIP", "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_FORMAT = "%Y-%m-%d %H:%M:%S %Z"
# Datetime convention: # Datetime convention:
# All datetime objects in this codebase are naive UTC to match database storage. # All datetime objects in this codebase are timezone-aware.
# - utc_now(): returns naive UTC datetime # - now_utc(): returns timezone-aware UTC datetime
# - datetime_str(): converts naive UTC to display string (adds tzinfo for formatting) # - 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 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): def datetime_str(dt, utc=False):
@@ -72,13 +128,3 @@ def datetime_str(dt, utc=False):
return aware_dt.strftime(DATETIME_FORMAT) return aware_dt.strftime(DATETIME_FORMAT)
else: else:
return aware_dt.astimezone().strftime(DATETIME_FORMAT) return aware_dt.astimezone().strftime(DATETIME_FORMAT)
def utc_now():
"""
Get current time as naive UTC datetime.
Returns naive datetime to match database storage behavior.
All naive datetimes in this codebase are assumed to be UTC.
"""
return datetime.datetime.now(datetime.UTC).replace(tzinfo=None)
+14 -6
View File
@@ -27,7 +27,8 @@ class Application:
config: Configuration dictionary from TOML file. config: Configuration dictionary from TOML file.
config_path: Path to configuration file (for reload). config_path: Path to configuration file (for reload).
""" """
self.config = config self._config = config
self._config_lock = threading.RLock()
self.config_path = config_path self.config_path = config_path
self.password_hasher = argon2.PasswordHasher() self.password_hasher = argon2.PasswordHasher()
self.shutdown_event = threading.Event() self.shutdown_event = threading.Event()
@@ -38,6 +39,12 @@ class Application:
self.good_limiter = None self.good_limiter = None
self.bad_limiter = None self.bad_limiter = None
@property
def config(self):
"""Thread-safe config access."""
with self._config_lock:
return self._config
def init_database(self): def init_database(self):
"""Initialize database connection and run migrations.""" """Initialize database connection and run migrations."""
init_database(self.config) init_database(self.config)
@@ -68,12 +75,13 @@ class Application:
""" """
new_config = load_config(self.config_path) new_config = load_config(self.config_path)
# Preserve DB and bind settings with self._config_lock:
new_config["database"] = self.config["database"] # Preserve DB and bind settings
new_config["daemon"]["host"] = self.config["daemon"]["host"] new_config["database"] = self._config["database"]
new_config["daemon"]["port"] = self.config["daemon"]["port"] new_config["daemon"]["host"] = self._config["daemon"]["host"]
new_config["daemon"]["port"] = self._config["daemon"]["port"]
self.config = new_config self._config = new_config
# Reconfigure logging # Reconfigure logging
setup_logging( setup_logging(
+90 -42
View File
@@ -3,25 +3,26 @@
import logging import logging
import threading import threading
from . import utc_now from . import now_utc
from .models import Hostname, User from .models import DatabaseError, 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.
""" """
now = utc_now() now = now_utc()
expired_count = 0 expired_count = 0
for hostname in Hostname.select().join(User).where( for hostname in Hostname.select(Hostname, User).join(User).where(
(Hostname.expiry_ttl != 0) & (Hostname.expiry_ttl != 0) &
((Hostname.last_ipv4.is_null(False) & Hostname.last_ipv4_update.is_null(False)) | ((Hostname.last_ipv4.is_null(False) & Hostname.last_ipv4_update.is_null(False)) |
(Hostname.last_ipv6.is_null(False) & Hostname.last_ipv6_update.is_null(False)))): (Hostname.last_ipv6.is_null(False) & Hostname.last_ipv6_update.is_null(False)))):
@@ -30,51 +31,105 @@ 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) if start_time \
else hostname.last_ipv4_update
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) if start_time \
else hostname.last_ipv6_update
expiry_time = last_update + timedelta(seconds=hostname.expiry_ttl)
if now > expiry_time: if now > expiry_time:
ipv6_expired = True ipv6_expired = True
if not ipv4_expired and not ipv6_expired: if not ipv4_expired and not ipv6_expired:
continue continue
ipv4_deleted = False old_ipv4 = hostname.last_ipv4
ipv6_deleted = False old_ipv6 = hostname.last_ipv6
ipv4_dns_deleted = False
ipv6_dns_deleted = False
if app.dns_service: if ipv4_expired:
if ipv4_expired: logging.info(
logging.info( f"Cleanup: Host expired: hostname={hostname.hostname} zone={hostname.zone} "
f"Host expired: hostname={hostname.hostname} zone={hostname.zone} " f"ipv4={hostname.last_ipv4}"
f"ip={hostname.last_ipv4}" )
try:
ipv4_exists = app.dns_service.query_record(
hostname.hostname, hostname.zone, "A")
if ipv4_exists:
app.dns_service.delete_record(
hostname.hostname, hostname.zone, "A")
ipv4_dns_deleted = True
hostname.last_ipv4 = None
except Exception as e:
logging.error(f"DNS error: {e}")
logging.error(
f"Cleanup failed: hostname={hostname.hostname} "
f"zone={hostname.zone} type=A"
) )
try:
app.dns_service.delete_record(hostname.hostname, hostname.zone, "A")
ipv4_deleted = True
except Exception as e:
logging.error(
f"DNS delete failed: hostname={hostname.hostname} "
f"zone={hostname.zone} type=A error={e}"
)
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:
ipv6_exists = app.dns_service.query_record(
hostname.hostname, hostname.zone, "AAAA")
if ipv6_exists:
app.dns_service.delete_record(
hostname.hostname, hostname.zone, "AAAA")
ipv6_dns_deleted = True
hostname.last_ipv6 = None
except Exception as e:
logging.error(f"DNS error: {e}")
logging.error(
f"Cleanup failed: hostname={hostname.hostname} "
f"zone={hostname.zone} type=AAAA"
) )
if hostname.last_ipv4 == old_ipv4 and hostname.last_ipv6 == old_ipv6:
continue
try:
if hostname.last_ipv4 is None and hostname.last_ipv6 is None:
hostname.delete_instance()
else:
hostname.save()
except DatabaseError as e:
logging.error(
f"DB operation failed after retries: hostname={hostname.hostname} "
f"zone={hostname.zone}: {e}"
)
# Rollback: re-add DNS records that were deleted
if ipv4_dns_deleted:
try: try:
app.dns_service.delete_record(hostname.hostname, hostname.zone, "AAAA") app.dns_service.update_record(
ipv6_deleted = True hostname.hostname, hostname.zone,
old_ipv4, hostname.dns_ttl)
except Exception as e: except Exception as e:
logging.error( logging.error(f"DNS rollback failed (A): {e}")
f"DNS delete failed: hostname={hostname.hostname} "
f"zone={hostname.zone} type=AAAA error={e}" if ipv6_dns_deleted:
) try:
app.dns_service.update_record(
hostname.hostname, hostname.zone,
old_ipv6, hostname.dns_ttl)
except Exception as e:
logging.error(f"DNS rollback failed (AAAA): {e}")
continue
if app.email_service: if app.email_service:
# Restore old IPs on in-memory model for email template
hostname.last_ipv4 = old_ipv4
hostname.last_ipv6 = old_ipv6
app.email_service.send_expiry_notification( app.email_service.send_expiry_notification(
hostname.user.email, hostname.user.email,
hostname, hostname,
@@ -82,15 +137,7 @@ def cleanup_expired(app):
ipv6_expired ipv6_expired
) )
# Clear IP addresses only if DNS delete succeeded expired_count += 1
if ipv4_deleted:
hostname.last_ipv4 = None
if ipv6_deleted:
hostname.last_ipv6 = None
if ipv4_deleted or ipv6_deleted:
hostname.save()
expired_count += 1
return expired_count return expired_count
@@ -113,10 +160,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:
+189 -115
View File
@@ -1,23 +1,34 @@
"""CLI commands for user and hostname management.""" """CLI commands for user and hostname management."""
import getpass import getpass
import logging
from . import datetime_str from . import datetime_str
from .cleanup import cleanup_expired from .cleanup import cleanup_expired
from .dns import encode_dnsname
from .models import ( from .models import (
DatabaseError,
DoesNotExist, DoesNotExist,
EncodingError,
get_hostname, get_hostname,
get_user, get_user,
Hostname, Hostname,
Permission,
User, User,
) )
from .dns import encode_dnsname, EncodingError
def validate_password(password, confirm):
"""Validate password and confirmation match with min length."""
if password != confirm:
return "Error: Passwords do not match."
if len(password) < 8:
return "Error: Password must be at least 8 characters."
return None
def cmd_user_list(args, app): def cmd_user_list(args, app):
"""List all users.""" """List all users."""
users = User.select() users = User.select().order_by(User.username)
if not users: if not users:
print("No users found.") print("No users found.")
return 0 return 0
@@ -51,12 +62,9 @@ def cmd_user_add(args, app):
password = getpass.getpass("Password: ") password = getpass.getpass("Password: ")
password_confirm = getpass.getpass("Confirm password: ") password_confirm = getpass.getpass("Confirm password: ")
if password != password_confirm: error = validate_password(password, password_confirm)
print("Error: Passwords do not match.") if error:
return 1 print(error)
if len(password) < 8:
print("Error: Password must be at least 8 characters.")
return 1 return 1
# Hash password and create user # Hash password and create user
@@ -100,12 +108,9 @@ def cmd_user_passwd(args, app):
password = getpass.getpass("New password: ") password = getpass.getpass("New password: ")
password_confirm = getpass.getpass("Confirm password: ") password_confirm = getpass.getpass("Confirm password: ")
if password != password_confirm: error = validate_password(password, password_confirm)
print("Error: Passwords do not match.") if error:
return 1 print(error)
if len(password) < 8:
print("Error: Password must be at least 8 characters.")
return 1 return 1
user.password_hash = app.password_hasher.hash(password) user.password_hash = app.password_hasher.hash(password)
@@ -133,7 +138,7 @@ def cmd_user_email(args, app):
def cmd_hostname_list(args, app): def cmd_hostname_list(args, app):
"""List hostnames.""" """List hostnames."""
query = Hostname.select().join(User) query = Hostname.select(Hostname, User.username).join(User)
if args.user: if args.user:
try: try:
@@ -143,13 +148,15 @@ def cmd_hostname_list(args, app):
print(f"Error: User '{args.user}' not found.") print(f"Error: User '{args.user}' not found.")
return 1 return 1
query = query.order_by(User.username, Hostname.hostname, Hostname.zone)
hostnames = list(query) hostnames = list(query)
if not hostnames: if not hostnames:
print("No hostnames found.") print("No hostnames found.")
return 0 return 0
print( print(
f"\n{'Hostname':<35} {'User':<15} {'Zone':<20} " f"\n{'Hostname':<35} {'Zone':<20} {'User':<15} "
f"{'DNS-TTL':<8} {'Exp-TTL':<8} {'Last-Update IPv4':<25} {'Last-Update IPv6'}" f"{'DNS-TTL':<8} {'Exp-TTL':<8} {'Last-Update IPv4':<25} {'Last-Update IPv6'}"
) )
print("-" * 140) print("-" * 140)
@@ -157,132 +164,92 @@ def cmd_hostname_list(args, app):
last_ipv4_update = datetime_str(h.last_ipv4_update) last_ipv4_update = datetime_str(h.last_ipv4_update)
last_ipv6_update = datetime_str(h.last_ipv6_update) last_ipv6_update = datetime_str(h.last_ipv6_update)
print( print(
f"{h.hostname:<35} {h.user.username:<15} {h.zone:<20} " f"{h.hostname:<35} {h.zone:<20} {h.user.username:<15} "
f"{h.dns_ttl:<8} {h.expiry_ttl:<8} {last_ipv4_update:<25} {last_ipv6_update}" f"{h.dns_ttl:<8} {h.expiry_ttl:<8} {last_ipv4_update:<25} {last_ipv6_update}"
) )
return 0 return 0
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)
except DoesNotExist:
print(f"Error: User '{username}' not found.")
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.")
return 1
# Create hostname
Hostname.create(
user=user,
hostname=hostname_str,
zone=zone,
dns_ttl=dns_ttl,
expiry_ttl=expiry_ttl
)
print(f"Hostname '{hostname_str}' added for user '{username}'.")
return 0
def cmd_hostname_delete(args, app): def cmd_hostname_delete(args, app):
"""Delete a hostname.""" """Delete a hostname."""
# Validate and encode hostname and zone
try: try:
hostname_str = encode_dnsname(args.hostname) try:
zone = encode_dnsname(args.zone) hostname = get_hostname(args.hostname, args.zone)
except EncodingError as e: except DoesNotExist:
print(f"Error: {e}") hostname = encode_dnsname(args.hostname)
return 1 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: # Delete DNS records if active
hostname = get_hostname(hostname_str, zone) if hostname.last_ipv4 or hostname.last_ipv6:
except DoesNotExist: # Initialize DNS service if not already
print(f"Error: Hostname '{hostname_str}' in zone '{zone}' not found.") if app.dns_service is None:
return 1 try:
app.init_dns()
except Exception as e:
print(f"DNS init failed: {e}")
return 1
# Delete DNS records if active
if hostname.last_ipv4 or hostname.last_ipv6:
# Initialize DNS service if not already
if app.dns_service is None:
try:
app.init_dns()
except Exception as e:
logging.warning(f"DNS init failed: {e}")
if app.dns_service:
if hostname.last_ipv4: if hostname.last_ipv4:
try: try:
app.dns_service.delete_record( app.dns_service.delete_record(
hostname.hostname, hostname.zone, "A" hostname.hostname, hostname.zone, "A"
) )
except Exception as e: 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: if hostname.last_ipv6:
try: try:
app.dns_service.delete_record( app.dns_service.delete_record(
hostname.hostname, hostname.zone, "AAAA" hostname.hostname, hostname.zone, "AAAA"
) )
except Exception as e: 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.hostname}' in zone '{hostname.zone}' deleted.")
except DatabaseError as e:
print(f"Database error: {e}")
return 1
hostname.delete_instance()
print(f"Hostname '{hostname_str}' in zone '{zone}' deleted.")
return 0 return 0
def cmd_hostname_modify(args, app): def cmd_hostname_modify(args, app):
"""Modify hostname settings.""" """Modify hostname settings."""
# Validate and encode hostname and zone
try: try:
hostname_str = encode_dnsname(args.hostname) try:
zone = encode_dnsname(args.zone) hostname = get_hostname(args.hostname, args.zone)
except EncodingError as e: except DoesNotExist:
print(f"Error: {e}") 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
# 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
hostname.dns_ttl = dns_ttl
hostname.expiry_ttl = expiry_ttl
hostname.save()
print(
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 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
hostname.dns_ttl = dns_ttl
hostname.expiry_ttl = expiry_ttl
hostname.save()
print(
f"Hostname '{hostname_str}' updated: "
f"dns_ttl={dns_ttl}, expiry_ttl={expiry_ttl}"
)
return 0 return 0
@@ -293,11 +260,118 @@ def cmd_cleanup(args, app):
try: try:
app.init_dns() app.init_dns()
except Exception as e: 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: if app.email_service is None:
app.init_email() app.init_email()
count = cleanup_expired(app) try:
print(f"Cleanup complete: {count} expired hostname(s) processed.") 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
def cmd_permission_list(args, app):
"""List permissions."""
query = Permission.select(Permission, User.username).join(User)
if args.user:
try:
user = get_user(args.user)
query = query.where(Permission.user == user)
except DoesNotExist:
print(f"Error: User '{args.user}' not found.")
return 1
query = query.order_by(
User.username, Permission.zone, Permission.hostname_pattern)
permissions = list(query)
if not permissions:
print("No permissions found.")
return 0
print(f"\n{'User':<20} {'Pattern':<30} {'Zone':<30}")
print("-" * 80)
for p in permissions:
print(f"{p.user.username:<20} {p.hostname_pattern:<30} {p.zone:<30}")
return 0
def cmd_permission_add(args, app):
"""Add a permission."""
username = args.username
pattern = args.hostname_pattern
zone = args.zone
try:
user = get_user(username)
except DoesNotExist:
print(f"Error: User '{username}' not found.")
return 1
# Validate pattern
if pattern != '*' and not pattern.startswith('*.') and '*' in pattern:
print("Error: Invalid pattern. Use '*', '*.suffix', or exact.")
return 1
# Check if permission exists
exists = Permission.select().where(
(Permission.user == user) &
(Permission.hostname_pattern == pattern) &
(Permission.zone == zone)
).exists()
if exists:
print("Error: Permission already exists.")
return 1
try:
Permission.create(
user=user,
hostname_pattern=pattern,
zone=zone
)
print(f"Permission added: {username} {pattern} {zone}")
except DatabaseError as e:
print(f"Database error: {e}")
return 1
return 0
def cmd_permission_delete(args, app):
"""Delete a permission."""
username = args.username
pattern = args.hostname_pattern
zone = args.zone
try:
user = get_user(username)
except DoesNotExist:
print(f"Error: User '{username}' not found.")
return 1
try:
perm = Permission.get(
(Permission.user == user) &
(Permission.hostname_pattern == pattern) &
(Permission.zone == zone)
)
perm.delete_instance()
print(f"Permission deleted: {username} {pattern} {zone}")
except DoesNotExist:
print("Error: Permission not found.")
return 1
except DatabaseError as e:
print(f"Database error: {e}")
return 1
return 0 return 0
+4
View File
@@ -22,6 +22,7 @@ DEFAULT_ENDPOINT_PARAMS = {
"username": ["username", "user"], "username": ["username", "user"],
"password": ["password", "pass", "token"], "password": ["password", "pass", "token"],
"notify_change": ["notify_change"], "notify_change": ["notify_change"],
"expiry_ttl": ["expiry_ttl"],
} }
VALID_PARAM_KEYS = frozenset(DEFAULT_ENDPOINT_PARAMS.keys()) VALID_PARAM_KEYS = frozenset(DEFAULT_ENDPOINT_PARAMS.keys())
@@ -178,6 +179,9 @@ def load_config(config_path):
cfg.setdefault("defaults", {}) cfg.setdefault("defaults", {})
cfg["defaults"].setdefault("dns_ttl", 60) cfg["defaults"].setdefault("dns_ttl", 60)
cfg["defaults"].setdefault("expiry_ttl", 3600) 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.setdefault("email", {})
cfg["email"].setdefault("enabled", False) cfg["email"].setdefault("enabled", False)
+40 -5
View File
@@ -10,10 +10,15 @@ 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
# DNS name length limits (RFC 1035)
MAX_HOSTNAME_LENGTH = 253
MAX_LABEL_LENGTH = 63
# Valid hostname label pattern (after punycode encoding) # Valid hostname label pattern (after punycode encoding)
LABEL_PATTERN = re.compile( LABEL_PATTERN = re.compile(
r'^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$', re.IGNORECASE r'^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$', re.IGNORECASE
@@ -53,8 +58,9 @@ def encode_dnsname(hostname):
if hostname.endswith('.'): if hostname.endswith('.'):
hostname = hostname[:-1] hostname = hostname[:-1]
if len(hostname) > 253: if len(hostname) > MAX_HOSTNAME_LENGTH:
raise EncodingError("Hostname too long (max 253 characters)") raise EncodingError(
f"Hostname too long (max {MAX_HOSTNAME_LENGTH} characters)")
try: try:
# Encode each label using IDNA # Encode each label using IDNA
@@ -71,9 +77,9 @@ def encode_dnsname(hostname):
except UnicodeError as e: except UnicodeError as e:
raise EncodingError(f"Invalid label '{label}': {e}") raise EncodingError(f"Invalid label '{label}': {e}")
if len(encoded) > 63: if len(encoded) > MAX_LABEL_LENGTH:
raise EncodingError( raise EncodingError(
f"Label '{label}' too long (max 63 characters)" f"Label '{label}' too long (max {MAX_LABEL_LENGTH} characters)"
) )
if not LABEL_PATTERN.match(encoded): if not LABEL_PATTERN.match(encoded):
@@ -208,7 +214,7 @@ def parse_bind_key_file(path):
except DNSError: except DNSError:
raise raise
except Exception as e: except Exception as e:
raise DNSError(f"Failed to parse key file {path}: {e}") raise DNSError(f"Failed to parse key file {path}: {e}") from e
class DNSService: class DNSService:
@@ -353,6 +359,35 @@ 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):
return None
except Exception as e:
raise DNSError(
f"DNS query failed for {hostname}.{zone} {record_type}: {e}"
)
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.
+3 -1
View File
@@ -98,7 +98,8 @@ class EmailService:
email, email,
hostname, hostname,
ipv4_changed, ipv4_changed,
ipv6_changed ipv6_changed,
expiry_ttl_changed
): ):
""" """
Send hostname changed notification email. Send hostname changed notification email.
@@ -131,6 +132,7 @@ class EmailService:
"ipv6_changed": ipv6_changed, "ipv6_changed": ipv6_changed,
"ipv6": hostname.last_ipv6, "ipv6": hostname.last_ipv6,
"last_ipv6_update": datetime_str(hostname.last_ipv6_update), "last_ipv6_update": datetime_str(hostname.last_ipv6_update),
"expiry_ttl_changed": expiry_ttl_changed,
"expiry_ttl": hostname.expiry_ttl, "expiry_ttl": hostname.expiry_ttl,
} }
-5
View File
@@ -129,8 +129,3 @@ def setup_logging(
handler.addFilter(txn_filter) handler.addFilter(txn_filter)
handler.setFormatter(formatter) handler.setFormatter(formatter)
root.addHandler(handler) root.addHandler(handler)
def disable_logging():
"""Disable all logging (for CLI quiet mode)."""
logging.disable(logging.CRITICAL)
+29 -23
View File
@@ -11,10 +11,12 @@ from . import __version__
from .app import Application from .app import Application
from .cli import ( from .cli import (
cmd_cleanup, cmd_cleanup,
cmd_hostname_add,
cmd_hostname_delete, cmd_hostname_delete,
cmd_hostname_list, cmd_hostname_list,
cmd_hostname_modify, cmd_hostname_modify,
cmd_permission_add,
cmd_permission_delete,
cmd_permission_list,
cmd_user_add, cmd_user_add,
cmd_user_delete, cmd_user_delete,
cmd_user_email, cmd_user_email,
@@ -22,7 +24,7 @@ from .cli import (
cmd_user_passwd, cmd_user_passwd,
) )
from .config import ConfigError, find_config_file, load_config from .config import ConfigError, find_config_file, load_config
from .logging import disable_logging, setup_logging from .logging import setup_logging
from .server import run_daemon from .server import run_daemon
@@ -81,14 +83,6 @@ def build_parser():
hostname_list.add_argument("--user", help="Filter by username") hostname_list.add_argument("--user", help="Filter by username")
hostname_list.set_defaults(func=cmd_hostname_list) hostname_list.set_defaults(func=cmd_hostname_list)
hostname_add = hostname_subparsers.add_parser("add", help="Add hostname")
hostname_add.add_argument("username", help="Username")
hostname_add.add_argument("hostname", help="Hostname (FQDN)")
hostname_add.add_argument("zone", help="DNS zone")
hostname_add.add_argument("--dns-ttl", type=int, help="DNS record TTL")
hostname_add.add_argument("--expiry-ttl", type=int, help="Expiry TTL")
hostname_add.set_defaults(func=cmd_hostname_add)
hostname_delete = hostname_subparsers.add_parser( hostname_delete = hostname_subparsers.add_parser(
"delete", help="Delete hostname" "delete", help="Delete hostname"
) )
@@ -105,6 +99,26 @@ def build_parser():
hostname_modify.add_argument("--expiry-ttl", type=int, help="Expiry TTL") hostname_modify.add_argument("--expiry-ttl", type=int, help="Expiry TTL")
hostname_modify.set_defaults(func=cmd_hostname_modify) hostname_modify.set_defaults(func=cmd_hostname_modify)
# Permission commands
perm_parser = subparsers.add_parser("permission", help="Permissions")
perm_subparsers = perm_parser.add_subparsers(dest="permission_command")
perm_list = perm_subparsers.add_parser("list", help="List permissions")
perm_list.add_argument("--user", help="Filter by username")
perm_list.set_defaults(func=cmd_permission_list)
perm_add = perm_subparsers.add_parser("add", help="Add permission")
perm_add.add_argument("username", help="Username")
perm_add.add_argument("hostname_pattern", help="Pattern (*, *.suffix, exact)")
perm_add.add_argument("zone", help="DNS zone")
perm_add.set_defaults(func=cmd_permission_add)
perm_delete = perm_subparsers.add_parser("delete", help="Delete permission")
perm_delete.add_argument("username", help="Username")
perm_delete.add_argument("hostname_pattern", help="Hostname pattern")
perm_delete.add_argument("zone", help="DNS zone")
perm_delete.set_defaults(func=cmd_permission_delete)
# Cleanup command # Cleanup command
cleanup_parser = subparsers.add_parser("cleanup", help="Run cleanup manually") cleanup_parser = subparsers.add_parser("cleanup", help="Run cleanup manually")
cleanup_parser.set_defaults(func=cmd_cleanup) cleanup_parser.set_defaults(func=cmd_cleanup)
@@ -138,19 +152,11 @@ def main():
log_versions=config["daemon"]["log_versions"], log_versions=config["daemon"]["log_versions"],
) )
else: else:
if config["daemon"]["log_target"] == "stdout" and not args.debug: log_level = "DEBUG" if args.debug else "WARNING"
disable_logging() setup_logging(
else: level=log_level,
log_level = "DEBUG" if args.debug else config["daemon"]["log_level"] target="stdout",
setup_logging( )
level=log_level,
target=config["daemon"]["log_target"],
syslog_socket=config["daemon"]["syslog_socket"],
syslog_facility=config["daemon"]["syslog_facility"],
log_file=config["daemon"]["log_file"],
log_file_size=config["daemon"]["log_file_size"],
log_versions=config["daemon"]["log_versions"],
)
# Create application instance # Create application instance
app = Application(config, config_path) app = Application(config, config_path)
+174 -38
View File
@@ -2,47 +2,55 @@
import logging import logging
import os 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 .dns import encode_dnsname, EncodingError
from peewee import ( from peewee import (
AutoField, AutoField,
CharField, CharField,
DatabaseProxy, DatabaseProxy,
Model,
DateTimeField, DateTimeField,
DoesNotExist, DoesNotExist,
fn,
ForeignKeyField, ForeignKeyField,
IntegerField, IntegerField,
Model,
SqliteDatabase, SqliteDatabase,
PeeweeException as DatabaseError,
) )
from playhouse.pool import PooledMySQLDatabase from playhouse.pool import PooledMySQLDatabase
# Re-export DoesNotExist and EncodingError for convenience
# Re-export PeeweeException as DatabaseError, DoesNotExist and
# EncodingError for convenience
__all__ = [ __all__ = [
'db', 'db',
'DATABASE_VERSION', 'DATABASE_VERSION',
'User', 'User',
'Hostname', 'Hostname',
'Permission',
'Version', 'Version',
'init_database', 'init_database',
'create_tables', 'create_tables',
'get_user',
'get_hostname', 'get_hostname',
'get_hostname_for_user', 'get_hostname_for_user',
'get_permission',
'get_user',
'close_database',
'DoesNotExist', 'DoesNotExist',
'EncodingError', 'EncodingError',
'DatabaseError',
] ]
# Database proxy (initialized later with actual backend) # Database proxy (initialized later with actual backend)
db = DatabaseProxy() db = DatabaseProxy()
# Current database schema version # Current database schema version
DATABASE_VERSION = 2 DATABASE_VERSION = 3
# Migration column mappings: key = target version # Migration column mappings: key = target version
# Values: {table: {old_col: new_col}} - None value = drop column # Values: {table: {old_col: new_col}} - None value = drop column
# Empty dict means no table schema changes (v3: new table only)
MIGRATION_COLUMN_MAPS = { MIGRATION_COLUMN_MAPS = {
2: { 2: {
'hostnames': { 'hostnames': {
@@ -57,16 +65,39 @@ MIGRATION_COLUMN_MAPS = {
'last_ipv6': 'last_ipv6', 'last_ipv6': 'last_ipv6',
'last_ipv6_update': 'last_ipv6_update', 'last_ipv6_update': 'last_ipv6_update',
} }
} },
3: {} # New permissions table, populated from hostnames
} }
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."""
@@ -75,7 +106,7 @@ 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=utc_now) created_at = DateTimeFieldUTC(default=now_utc)
class Meta: class Meta:
table_name = "users" table_name = "users"
@@ -91,9 +122,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"
@@ -101,13 +132,21 @@ class Hostname(BaseModel):
(('hostname', 'zone'), True), (('hostname', 'zone'), True),
) )
def save(self, *args, **kwargs):
"""Validate and encode hostname/zone before saving.""" class Permission(BaseModel):
if self.hostname: """Permission grants access to hostname patterns for users."""
self.hostname = encode_dnsname(self.hostname)
if self.zone: id = AutoField()
self.zone = encode_dnsname(self.zone) user = ForeignKeyField(User, backref="permissions", on_delete="CASCADE")
return super().save(*args, **kwargs) hostname_pattern = CharField(max_length=255) # '*', '*.suffix', or exact
zone = CharField(max_length=255)
created_at = DateTimeFieldUTC(default=now_utc)
class Meta:
table_name = "permissions"
indexes = (
(('user', 'hostname_pattern', 'zone'), True),
)
class Version(BaseModel): class Version(BaseModel):
@@ -123,10 +162,11 @@ class Version(BaseModel):
TABLE_TO_MODEL = { TABLE_TO_MODEL = {
'users': User, 'users': User,
'hostnames': Hostname, 'hostnames': Hostname,
'permissions': Permission,
} }
def init_database(config: dict): def init_database(config):
""" """
Initialize database connection based on config. Initialize database connection based on config.
@@ -144,7 +184,11 @@ 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,
'foreign_keys': 1,
})
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}")
@@ -167,7 +211,7 @@ def init_database(config: dict):
db.connect() db.connect()
def _migrate_table_sqlite(model_class, from_version: int, column_map: dict): def _migrate_table_sqlite(model_class, from_version, column_map):
""" """
Migrate a single SQLite table using Peewee model for schema. Migrate a single SQLite table using Peewee model for schema.
@@ -214,8 +258,12 @@ def _migrate_table_sqlite(model_class, from_version: int, column_map: dict):
db.execute_sql(f'DROP TABLE "{backup_name}"') db.execute_sql(f'DROP TABLE "{backup_name}"')
def _migrate_sqlite(from_version: int, to_version: int): def _migrate_sqlite(from_version, to_version):
"""Migrate SQLite from from_version to to_version.""" """Migrate SQLite from from_version to to_version."""
if to_version == 3:
_migrate_v3_create_permissions()
return
db.execute_sql('PRAGMA foreign_keys=OFF') db.execute_sql('PRAGMA foreign_keys=OFF')
try: try:
tables = MIGRATION_COLUMN_MAPS[to_version] tables = MIGRATION_COLUMN_MAPS[to_version]
@@ -226,7 +274,17 @@ def _migrate_sqlite(from_version: int, to_version: int):
db.execute_sql('PRAGMA foreign_keys=ON') db.execute_sql('PRAGMA foreign_keys=ON')
def _migrate_mariadb(to_version: int): def _migrate_v3_create_permissions():
"""Create permissions table and populate from existing hostnames."""
db.create_tables([Permission])
db.execute_sql(
'INSERT INTO permissions '
'(user_id, hostname_pattern, zone, created_at) '
'SELECT user_id, hostname, zone, CURRENT_TIMESTAMP FROM hostnames'
)
def _migrate_mariadb(to_version):
"""Migrate MariaDB to target version using ALTER TABLE.""" """Migrate MariaDB to target version using ALTER TABLE."""
if to_version == 2: if to_version == 2:
db.execute_sql('ALTER TABLE hostnames DROP INDEX hostnames_hostname') db.execute_sql('ALTER TABLE hostnames DROP INDEX hostnames_hostname')
@@ -234,6 +292,8 @@ def _migrate_mariadb(to_version: int):
'ALTER TABLE hostnames ADD UNIQUE INDEX ' 'ALTER TABLE hostnames ADD UNIQUE INDEX '
'hostnames_hostname_zone (hostname, zone)' 'hostnames_hostname_zone (hostname, zone)'
) )
elif to_version == 3:
_migrate_v3_create_permissions()
def check_and_migrate(): def check_and_migrate():
@@ -268,12 +328,12 @@ def create_tables():
check_and_migrate() check_and_migrate()
return return
db.create_tables([User, Hostname, Version]) db.create_tables([User, Hostname, Permission, Version])
Version.create(version=DATABASE_VERSION) Version.create(version=DATABASE_VERSION)
logging.debug("Database tables created") logging.debug("Database tables created")
def get_user(username: str): def get_user(username):
""" """
Get user by username. Get user by username.
@@ -294,6 +354,52 @@ def get_user(username: str):
return User.get(User.username == username) return User.get(User.username == username)
def get_permission(user, fqdn):
"""
Get permission for user to access FQDN.
Patterns:
'*' - matches any hostname
'*.suffix' - matches hostname ends with .suffix
'exact' - matches hostname == pattern
Args:
user: User to check permission for.
fqdn: Full qualified domain name.
Returns:
Permission instance.
Raises:
DoesNotExist: If permission not found.
EncodingError: If fqdn is invalid.
"""
fqdn = encode_dnsname(fqdn)
permissions = Permission.select().where(Permission.user == user)
for perm in permissions:
if not fqdn.endswith(f".{perm.zone}"):
continue
pattern = perm.hostname_pattern
if pattern == '*':
return perm
hostname = fqdn.removesuffix(f".{perm.zone}")
if pattern.startswith('*.'):
suffix = pattern[1:] # Remove '*'
if hostname.endswith(suffix):
return perm
elif hostname == pattern:
return perm
raise DoesNotExist
def get_hostname(hostname, zone): def get_hostname(hostname, zone):
""" """
Get hostname by name and zone. Get hostname by name and zone.
@@ -307,6 +413,7 @@ def get_hostname(hostname, zone):
Raises: Raises:
DoesNotExist: If hostname not found. DoesNotExist: If hostname not found.
EncodingError: If hostname or zone is invalid.
Example: Example:
>>> host = get_hostname("myhost", "example.com") >>> host = get_hostname("myhost", "example.com")
@@ -314,27 +421,56 @@ def get_hostname(hostname, zone):
'192.168.1.1' '192.168.1.1'
""" """
return Hostname.get( return Hostname.get(
(Hostname.hostname == hostname) & (Hostname.zone == zone) (Hostname.hostname == encode_dnsname(hostname)) &
(Hostname.zone == encode_dnsname(zone))
) )
def get_hostname_for_user(hostname: str, user: User): def get_hostname_for_user(
user: User, hostname: str, zone: str, dns_ttl: int, expiry_ttl: int):
""" """
Get hostname owned by specific user. Get hostname if it exists or create a new instance.
Args: Args:
hostname: Hostname to look up (FQDN). user: User requesting access.
user: User who should own the hostname. hostname: Hostname (e.g., 'myhost').
zone: Zone (e.g., 'example.com').
dns_ttl: Expiry TTL for auto-created hostnames.
expiry_ttl: Expiry TTL for auto-created hostnames.
Returns: Returns:
Hostname instance. Tuple of (Hostname, created) where created is True if auto-created.
Raises:
DoesNotExist: If hostname not found or not owned by user.
Example: Example:
>>> user = get_user("alice") >>> host, created = get_hostname_or_create(
>>> host = get_hostname_for_user("myhost.example.com", user) ... user, 'myhost', 'example.com', 60, 3600)
""" """
fqdn = fn.Concat(Hostname.hostname, '.', Hostname.zone) try:
return Hostname.get((fqdn == hostname) & (Hostname.user == user)) return (
Hostname.get(
(Hostname.user == user) &
(Hostname.hostname == hostname) &
(Hostname.zone == zone)
),
False
)
except DoesNotExist:
pass
return (
Hostname(
user=user,
hostname=hostname,
zone=zone,
dns_ttl=dns_ttl,
expiry_ttl=expiry_ttl,
),
True
)
def close_database():
"""Close database connection."""
if not db.is_closed():
db.close()
logging.debug("Database connection closed")
+400 -180
View File
@@ -7,17 +7,13 @@ import ipaddress
import json import json
import logging import logging
import signal import signal
import socket
import ssl import ssl
import threading import threading
from concurrent.futures import ThreadPoolExecutor
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlparse
import argon2
from . import ( from . import (
now_utc,
datetime_str, datetime_str,
utc_now,
STATUS_GOOD, STATUS_GOOD,
STATUS_NOCHG, STATUS_NOCHG,
STATUS_BADAUTH, STATUS_BADAUTH,
@@ -27,9 +23,24 @@ from . import (
STATUS_BADIP, STATUS_BADIP,
) )
from .cleanup import ExpiredRecordsCleanupThread, RateLimitCleanupThread from .cleanup import ExpiredRecordsCleanupThread, RateLimitCleanupThread
from .dns import detect_ip_type
from .logging import clear_txn_id, set_txn_id from .logging import clear_txn_id, set_txn_id
from .models import DoesNotExist, get_hostname_for_user, get_user from .models import (
from .dns import detect_ip_type, encode_dnsname, EncodingError close_database,
DatabaseError,
DoesNotExist,
EncodingError,
get_hostname_for_user,
get_user,
get_permission,
)
from argon2.exceptions import VerifyMismatchError
from concurrent.futures import ThreadPoolExecutor
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlparse
# Graceful shutdown timeout (seconds)
SHUTDOWN_TIMEOUT = 5
def extract_param(params, aliases): def extract_param(params, aliases):
@@ -123,7 +134,9 @@ class DDNSServer(ThreadingHTTPServer):
logging.info(f"Waiting for {self.active_requests} active request(s)") logging.info(f"Waiting for {self.active_requests} active request(s)")
self.requests_done.wait(timeout=timeout) self.requests_done.wait(timeout=timeout)
if self.active_requests > 0: if self.active_requests > 0:
logging.warning(f"Shutdown timeout, {self.active_requests} request(s) still active") logging.warning(
f"Shutdown timeout, {self.active_requests} request(s) still active"
)
def server_close(self): def server_close(self):
"""Shutdown thread pool and close server.""" """Shutdown thread pool and close server."""
@@ -131,6 +144,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."""
@@ -207,128 +244,14 @@ class DDNSRequestHandler(BaseHTTPRequestHandler):
pass pass
return None, None return None, None
def do_GET(self): def _parse_ip_params(self, params, endpoint, client_ip, username,
"""Handle GET requests.""" hostname_param):
set_txn_id() """Parse and validate IP address parameters."""
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")
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
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")
return
# Parse query parameters
params = parse_qs(parsed.query)
# Get credentials
username, password = self.parse_basic_auth()
if username is None:
username = extract_param(params, endpoint["params"]["username"])
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, STATUS_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, STATUS_BADAUTH)
return
# 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, STATUS_NOHOST)
return
# 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, 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
# 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 ipv4 = None
ipv6 = None ipv6 = None
# Process myip parameter # Process myip parameter
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)
@@ -337,34 +260,295 @@ 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_param, ip=myip
)
# Process myip6 parameter # Process myip6 parameter
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 raise ValueError
else: ipv6 = myip6
return (400, STATUS_BADIP, {})
except ValueError: except ValueError:
return (400, STATUS_BADIP, {}) raise DDNSClientError(
"Bad IPv6 address", 400, STATUS_BADIP,
client=client_ip, username=username,
hostname=hostname_param, 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:
rtype, ip = detect_ip_type(client_ip)
if rtype == "A":
ipv4 = ip
else:
ipv6 = ip
return ipv4, ipv6
def _parse_expiry_ttl(self, params, endpoint, client_ip, username,
hostname_param):
"""Parse and validate expiry_ttl parameter."""
expiry_ttl_param = extract_param(params, endpoint["params"]["expiry_ttl"])
if not expiry_ttl_param:
return None
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_param, expiry_ttl=expiry_ttl_param
)
# Validate bounds
defaults = self.app.config["defaults"]
if expiry_ttl == 0:
if not defaults["expiry_ttl_allow_zero"]:
raise DDNSClientError(
"Zero expiry_ttl not allowed", 400, STATUS_NOHOST,
client=client_ip, username=username,
hostname=hostname_param, expiry_ttl=expiry_ttl
)
else:
ttl_min = defaults["expiry_ttl_min"]
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_param, expiry_ttl=expiry_ttl, min=ttl_min
)
ttl_max = defaults["expiry_ttl_max"]
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_param, expiry_ttl=expiry_ttl, max=ttl_max
)
return expiry_ttl
def do_GET(self):
"""Handle GET requests."""
set_txn_id()
try:
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:
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.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)
# Process credentials parameters
username, password = self.parse_basic_auth()
if username is None:
username = extract_param(params, endpoint["params"]["username"])
password = extract_param(params, endpoint["params"]["password"])
if not username or not password:
raise DDNSClientError(
"Auth failed",
401,
STATUS_BADAUTH,
client_ip=client_ip
)
# Process 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
)
# Parse IP parameters
ipv4, ipv6 = self._parse_ip_params(
params, endpoint, client_ip, username, hostname_param)
# 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)
# Parse expiry_ttl parameter
expiry_ttl = self._parse_expiry_ttl(
params, endpoint, client_ip, username, hostname_param)
# Validate credentials
user = self._authenticate(client_ip, username, password)
# Check hostname permission
hostname, created = self._get_hostname_for_user(
client_ip,
user,
hostname_param
)
# 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,
expiry_ttl,
created
)
def _authenticate(self, client_ip, username, password):
try:
try: try:
rtype, ip = detect_ip_type(client_ip) user = get_user(username)
if rtype == "A": except DoesNotExist:
ipv4 = ip # User does not exist, Hash fake password to prevent time-based attacks
else: self.app.password_hasher.hash("FAKE-PASSWORD")
ipv6 = ip raise DoesNotExist
except ValueError:
return (400, STATUS_BADIP, {})
now = utc_now() 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 _get_hostname_for_user(self, client_ip, user, hostname_param):
"""Check permissions and get/create hostname."""
code = None
try:
perm = get_permission(user, hostname_param)
hostname_param = hostname_param.removesuffix(f".{perm.zone}")
return get_hostname_for_user(
user,
hostname_param,
perm.zone,
self.app.config["defaults"]["dns_ttl"],
self.app.config["defaults"]["expiry_ttl"]
)
except DoesNotExist:
code = 403
except EncodingError:
code = 400
raise DDNSClientError(
"Access denied",
code,
STATUS_NOHOST,
client=client_ip,
username=user.username,
hostname=hostname_param
)
def _rollback_dns(self, hostname, old_ip, record_type):
"""Roll back a DNS record to its previous value."""
try:
if old_ip:
self.app.dns_service.update_record(
hostname.hostname, hostname.zone,
old_ip, hostname.dns_ttl)
else:
self.app.dns_service.delete_record(
hostname.hostname, hostname.zone, record_type)
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, expiry_ttl, created):
"""Process IP update for hostname."""
now = now_utc()
old_ipv4 = hostname.last_ipv4
old_ipv6 = hostname.last_ipv6
ipv4_changed = False ipv4_changed = False
ipv6_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: if ipv4:
hostname.last_ipv4_update = now hostname.last_ipv4_update = now
if ipv4 != hostname.last_ipv4: if ipv4 != hostname.last_ipv4:
@@ -376,15 +560,18 @@ class DDNSRequestHandler(BaseHTTPRequestHandler):
ipv4, ipv4,
hostname.dns_ttl hostname.dns_ttl
) )
hostname.last_ipv4 = ipv4
ipv4_changed = True ipv4_changed = True
hostname.last_ipv4 = ipv4
except Exception as e: except Exception as e:
hostname.save() logging.error(f"DNS error: {e}")
logging.error( raise DDNSError(
f"DNS update failed: client={client_ip} hostname={hostname.hostname} " "Update failed",
f"zone={hostname.zone} ipv4={ipv4} error={e}" 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
@@ -397,42 +584,68 @@ class DDNSRequestHandler(BaseHTTPRequestHandler):
ipv6, ipv6,
hostname.dns_ttl hostname.dns_ttl
) )
hostname.last_ipv6 = ipv6
ipv6_changed = True ipv6_changed = True
hostname.last_ipv6 = ipv6
except Exception as e: except Exception as e:
hostname.save() logging.error(f"DNS error: {e}")
logging.error( # Roll back IPv4 DNS if it was changed
f"DNS update failed: client={client_ip} hostname={hostname.hostname} " if ipv4_changed:
f"zone={hostname.zone} ipv6={ipv6} error={e}" self._rollback_dns(hostname, old_ipv4, "A")
raise DDNSError(
"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() try:
hostname.save()
except DatabaseError as e:
logging.error(
f"DB save failed after retries: hostname={hostname.hostname} "
f"zone={hostname.zone}: {e}"
)
if ipv4_changed:
self._rollback_dns(hostname, old_ipv4, "A")
if ipv6_changed:
self._rollback_dns(hostname, old_ipv6, "AAAA")
raise DDNSError(
"Update failed",
STATUS_DNSERR,
client=client_ip,
hostname=hostname.hostname,
zone=hostname.zone
)
notify_change_val = extract_param(params, endpoint["params"]["notify_change"]) if not ipv4_changed and not ipv6_changed and not expiry_ttl_changed:
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}"
if ipv6_changed:
changed_addrs += f" ipv6={ipv6}"
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} 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,
expiry_ttl=hostname.expiry_ttl,
created=created
) )
return
action = "Created" if created else "Updated"
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( logging.info(
f"Updated: client={client_ip} hostname={hostname.hostname} " f"{action}: 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: if notify_change:
@@ -441,14 +654,19 @@ class DDNSRequestHandler(BaseHTTPRequestHandler):
hostname.user.email, hostname.user.email,
hostname, hostname,
ipv4_changed, ipv4_changed,
ipv6_changed ipv6_changed,
expiry_ttl_changed
) )
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,
expiry_ttl=hostname.expiry_ttl,
created=created
) )
@@ -509,9 +727,10 @@ def run_daemon(app):
app.config["daemon"]["ssl_cert_file"], app.config["daemon"]["ssl_cert_file"],
app.config["daemon"]["ssl_key_file"] app.config["daemon"]["ssl_key_file"]
) )
# Note: existing connections use old cert, new connections use new fd = server.socket.detach()
raw_socket = socket.socket(fileno=fd)
server.socket = new_context.wrap_socket( server.socket = new_context.wrap_socket(
server.socket.detach(), server_side=True raw_socket, server_side=True
) )
except Exception as e: except Exception as e:
logging.error(f"Config reload failed: {e}") logging.error(f"Config reload failed: {e}")
@@ -529,12 +748,13 @@ def run_daemon(app):
server.handle_request() server.handle_request()
# Graceful shutdown - wait for active requests # Graceful shutdown - wait for active requests
server.wait_for_requests(5) server.wait_for_requests(SHUTDOWN_TIMEOUT)
# Cleanup # Cleanup
expired_cleanup_thread.stop() expired_cleanup_thread.stop()
ratelimit_cleanup_thread.stop() ratelimit_cleanup_thread.stop()
expired_cleanup_thread.join(timeout=5) expired_cleanup_thread.join(timeout=SHUTDOWN_TIMEOUT)
ratelimit_cleanup_thread.join(timeout=5) ratelimit_cleanup_thread.join(timeout=SHUTDOWN_TIMEOUT)
server.server_close() server.server_close()
close_database()
logging.info("Daemon stopped") logging.info("Daemon stopped")