Compare commits

...
26 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
16 changed files with 738 additions and 297 deletions
+63 -14
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
@@ -94,7 +105,7 @@ ssl_key_file = "/etc/ddns-service/key.pem" # required if ssl = true
[database] [database]
# backend = "sqlite" # default: "sqlite", or "mariadb" # backend = "sqlite" # default: "sqlite", or "mariadb"
path = "/var/lib/ddns-service/ddns.db" # required for sqlite path = "/var/lib/ddns-service/ddns.db" # required for sqlite
# pool_size = 5 # default: 5 (MariaDB connection pool size) # 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)
@@ -110,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
@@ -182,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"
@@ -192,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):
@@ -203,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
@@ -241,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
@@ -250,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
@@ -295,7 +336,7 @@ kill -HUP $(pidof ddns-service)
### 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)
``` ```
@@ -306,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
@@ -325,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
@@ -346,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
@@ -415,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 %}
+6
View File
@@ -42,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
@@ -70,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
@@ -82,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"
@@ -92,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 = []
+1
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
+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"
+1 -1
View File
@@ -23,7 +23,7 @@ __all__ = [
"logging", "logging",
"main", "main",
"models", "models",
"now_utc" "now_utc",
"ratelimit", "ratelimit",
"server", "server",
"STATUS_GOOD", "STATUS_GOOD",
+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(
+83 -43
View File
@@ -4,7 +4,7 @@ import logging
import threading import threading
from . import now_utc from . import now_utc
from .models import Hostname, User from .models import DatabaseError, Hostname, User
from datetime import timedelta from datetime import timedelta
@@ -22,7 +22,7 @@ def cleanup_expired(app, start_time=None):
now = now_utc() 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)))):
@@ -31,13 +31,15 @@ def cleanup_expired(app, start_time=None):
ipv6_expired = False ipv6_expired = False
if hostname.last_ipv4: if hostname.last_ipv4:
last_update = max(hostname.last_ipv4_update, start_time) 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) 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:
last_update = max(hostname.last_ipv6_update, start_time) 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) expiry_time = last_update + timedelta(seconds=hostname.expiry_ttl)
if now > expiry_time: if now > expiry_time:
ipv6_expired = True ipv6_expired = True
@@ -45,58 +47,96 @@ def cleanup_expired(app, start_time=None):
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"Cleanup: Host expired: hostname={hostname.hostname} zone={hostname.zone} " f"ipv4={hostname.last_ipv4}"
f"ipv4={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 error: {e}")
logging.error(
f"Cleanup failed: hostname={hostname.hostname} "
f"zone={hostname.zone} type=A"
)
if ipv6_expired: if ipv6_expired:
logging.info( logging.info(
f"Cleanup: Host expired: hostname={hostname.hostname} zone={hostname.zone} " f"Cleanup: Host expired: hostname={hostname.hostname} zone={hostname.zone} "
f"ipv6={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"
) )
try:
app.dns_service.delete_record(hostname.hostname, hostname.zone, "AAAA")
ipv6_deleted = True
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 not (ipv4_deleted or ipv6_deleted): 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:
app.dns_service.update_record(
hostname.hostname, hostname.zone,
old_ipv4, hostname.dns_ttl)
except Exception as e:
logging.error(f"DNS rollback failed (A): {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 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,
ipv4_deleted, ipv4_expired,
ipv6_deleted ipv6_expired
) )
# Clear IP addresses only if DNS delete succeeded
if ipv4_deleted:
hostname.last_ipv4 = None
if ipv6_deleted:
hostname.last_ipv6 = None
hostname.save()
expired_count += 1 expired_count += 1
return expired_count return expired_count
+120 -68
View File
@@ -12,13 +12,23 @@ from .models import (
get_hostname, get_hostname,
get_user, get_user,
Hostname, Hostname,
Permission,
User, User,
) )
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
@@ -52,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
@@ -101,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)
@@ -134,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:
@@ -144,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)
@@ -158,64 +164,12 @@ 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
try:
# Get user
try:
user = get_user(username)
except DoesNotExist:
print(f"Error: User '{username}' not found.")
return 1
# Check if hostname+zone exists
try:
hostname = get_hostname(args.hostname, args.zone)
print(f"Error: Hostname '{hostname.hostname}' in zone '{hostname.zone}' exists.")
return 1
except EncodingError as e:
print(f"Error: {e}")
return 1
except DoesNotExist:
pass
# Get TTLs from args or config defaults
config = app.config
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"]
# Create hostname
hostname = Hostname.create(
user=user,
hostname=args.hostname,
zone=args.zone,
dns_ttl=dns_ttl,
expiry_ttl=expiry_ttl
)
print(
f"Hostname '{hostname.hostname}' in zone '{hostname.zone}' added "
f"for user '{username}'."
)
except DatabaseError as e:
print(f"Database error: {e}")
return 1
return 0
def cmd_hostname_delete(args, app): def cmd_hostname_delete(args, app):
"""Delete a hostname.""" """Delete a hostname."""
try: try:
@@ -323,3 +277,101 @@ def cmd_cleanup(args, app):
return 1 return 1
return 0 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
+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)
+151 -29
View File
@@ -2,6 +2,7 @@
import logging import logging
import os import os
import time
from . import datetime_naive_utc, datetime_aware_utc, now_utc from . import datetime_naive_utc, datetime_aware_utc, now_utc
from .dns import encode_dnsname, EncodingError from .dns import encode_dnsname, EncodingError
@@ -12,7 +13,6 @@ from peewee import (
Model, Model,
DateTimeField, DateTimeField,
DoesNotExist, DoesNotExist,
fn,
ForeignKeyField, ForeignKeyField,
IntegerField, IntegerField,
SqliteDatabase, SqliteDatabase,
@@ -21,19 +21,22 @@ from peewee import (
from playhouse.pool import PooledMySQLDatabase from playhouse.pool import PooledMySQLDatabase
# Re-export PeeweeException as DatabseException, DoesNotExist and # Re-export PeeweeException as DatabaseError, DoesNotExist and
# EncodingError for convenience # 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', 'DatabaseError',
@@ -43,10 +46,11 @@ __all__ = [
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': {
@@ -61,7 +65,8 @@ 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
} }
@@ -78,11 +83,21 @@ class DateTimeFieldUTC(DateTimeField):
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."""
@@ -118,6 +133,22 @@ class Hostname(BaseModel):
) )
class Permission(BaseModel):
"""Permission grants access to hostname patterns for users."""
id = AutoField()
user = ForeignKeyField(User, backref="permissions", on_delete="CASCADE")
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):
"""Database schema version for migrations.""" """Database schema version for migrations."""
@@ -131,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.
@@ -152,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}")
@@ -175,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.
@@ -222,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]
@@ -234,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')
@@ -242,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():
@@ -276,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) -> User: def get_user(username):
""" """
Get user by username. Get user by username.
@@ -302,7 +354,53 @@ def get_user(username: str) -> User:
return User.get(User.username == username) return User.get(User.username == username)
def get_hostname(hostname: str, zone: str) -> Hostname: 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):
""" """
Get hostname by name and zone. Get hostname by name and zone.
@@ -328,27 +426,51 @@ def get_hostname(hostname: str, zone: str) -> Hostname:
) )
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.
EncodingError: If hostname is invalid.
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( return (
(fqdn == encode_dnsname(hostname)) & Hostname.get(
(Hostname.user == user) (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")
+219 -101
View File
@@ -7,6 +7,7 @@ import ipaddress
import json import json
import logging import logging
import signal import signal
import socket
import ssl import ssl
import threading import threading
@@ -25,17 +26,22 @@ from .cleanup import ExpiredRecordsCleanupThread, RateLimitCleanupThread
from .dns import detect_ip_type 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 ( from .models import (
close_database,
DatabaseError, DatabaseError,
DoesNotExist, DoesNotExist,
EncodingError, EncodingError,
get_hostname_for_user, get_hostname_for_user,
get_user get_user,
get_permission,
) )
from argon2.exceptions import VerifyMismatchError 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
# Graceful shutdown timeout (seconds)
SHUTDOWN_TIMEOUT = 5
def extract_param(params, aliases): def extract_param(params, aliases):
"""Extract first matching param from query params.""" """Extract first matching param from query params."""
@@ -238,6 +244,99 @@ class DDNSRequestHandler(BaseHTTPRequestHandler):
pass pass
return None, None return None, None
def _parse_ip_params(self, params, endpoint, client_ip, username,
hostname_param):
"""Parse and validate IP address parameters."""
ipv4 = None
ipv6 = None
# Process myip parameter
myip = extract_param(params, endpoint["params"]["ipv4"])
if myip:
try:
rtype, myip = detect_ip_type(myip)
if rtype == "A":
ipv4 = myip
else:
ipv6 = myip
except ValueError:
raise DDNSClientError(
"Bad IP address", 400, STATUS_BADIP,
client=client_ip, username=username,
hostname=hostname_param, ip=myip
)
# Process myip6 parameter
myip6 = extract_param(params, endpoint["params"]["ipv6"])
if myip6:
try:
rtype, myip6 = detect_ip_type(myip6)
if rtype != "AAAA":
raise ValueError
ipv6 = myip6
except ValueError:
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
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): def do_GET(self):
"""Handle GET requests.""" """Handle GET requests."""
set_txn_id() set_txn_id()
@@ -293,7 +392,7 @@ class DDNSRequestHandler(BaseHTTPRequestHandler):
# Parse query parameters # Parse query parameters
params = parse_qs(parsed.query) params = parse_qs(parsed.query)
# Get credentials # Process credentials parameters
username, password = self.parse_basic_auth() username, password = self.parse_basic_auth()
if username is None: if username is None:
username = extract_param(params, endpoint["params"]["username"]) username = extract_param(params, endpoint["params"]["username"])
@@ -304,10 +403,10 @@ class DDNSRequestHandler(BaseHTTPRequestHandler):
"Auth failed", "Auth failed",
401, 401,
STATUS_BADAUTH, STATUS_BADAUTH,
client_ip client_ip=client_ip
) )
# Get hostname parameter # Process hostname parameter
hostname_param = extract_param(params, endpoint["params"]["hostname"]) hostname_param = extract_param(params, endpoint["params"]["hostname"])
if not hostname_param: if not hostname_param:
raise DDNSClientError( raise DDNSClientError(
@@ -318,68 +417,30 @@ class DDNSRequestHandler(BaseHTTPRequestHandler):
username=username 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 # Validate credentials
user = self._authenticate(client_ip, username, password) user = self._authenticate(client_ip, username, password)
# Check hostname ownership # Check hostname permission
hostname = self._check_permissions(client_ip, user, hostname_param) hostname, created = self._get_hostname_for_user(
client_ip,
# Process myip parameter user,
ipv4 = None hostname_param
myip = extract_param(params, endpoint["params"]["ipv4"]) )
if myip:
try:
rtype, myip = detect_ip_type(myip)
if rtype == "A":
ipv4 = myip
else:
ipv6 = myip
except ValueError:
raise DDNSClientError(
"Bad IP address",
400,
STATUS_BADIP,
client=client_ip,
username=username,
hostname=hostname.hostname,
zone=hostname.zone,
ip=myip
)
# Process myip6 parameter
ipv6 = None
myip6 = extract_param(params, endpoint["params"]["ipv6"])
if myip6:
try:
rtype, myip6 = detect_ip_type(myip6)
if rtype == "AAAA":
ipv6 = myip6
else:
raise ValueError
except ValueError:
raise DDNSClientError(
"Bad IP address",
400,
STATUS_BADIP,
client=client_ip,
username=username,
hostname=hostname.hostname,
zone=hostname.zone,
ipv6=myip6
)
# Auto-detect from client IP if no params
if ipv4 is None and ipv6 is None:
rtype, ip = detect_ip_type(client_ip)
if rtype == "A":
ipv4 = ip
else:
ipv6 = ip
# Process notify_change parameter
notify_change = extract_param(params, endpoint["params"]["notify_change"])
notify_change = notify_change.lower() in ["1", "y", "yes", "on", "true"] \
if notify_change else False
# Good rate limit check # Good rate limit check
if self.app.good_limiter: if self.app.good_limiter:
@@ -405,7 +466,9 @@ class DDNSRequestHandler(BaseHTTPRequestHandler):
hostname, hostname,
ipv4, ipv4,
ipv6, ipv6,
notify_change notify_change,
expiry_ttl,
created
) )
def _authenticate(self, client_ip, username, password): def _authenticate(self, client_ip, username, password):
@@ -429,35 +492,63 @@ class DDNSRequestHandler(BaseHTTPRequestHandler):
return user return user
def _check_permissions(self, client_ip, user, hostname_param): def _get_hostname_for_user(self, client_ip, user, hostname_param):
# Check hostname ownership """Check permissions and get/create hostname."""
code = None code = None
try: try:
hostname = get_hostname_for_user(hostname_param, user) 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: except DoesNotExist:
code = 403 code = 403
except EncodingError: except EncodingError:
code = 400 code = 400
if code: raise DDNSClientError(
raise DDNSClientError( "Access denied",
"Access denied", code,
code, STATUS_NOHOST,
STATUS_NOHOST, client=client_ip,
client=client_ip, username=user.username,
username=user.username, hostname=hostname_param
hostname=hostname_param )
)
return hostname 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): def _process_ip_update(self, client_ip, user, hostname, ipv4, ipv6,
notify_change, expiry_ttl, created):
"""Process IP update for hostname.""" """Process IP update for hostname."""
now = now_utc() 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:
@@ -469,10 +560,9 @@ 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(f"DNS error: {e}")
raise DDNSError( raise DDNSError(
"Update failed", "Update failed",
@@ -494,11 +584,13 @@ 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(f"DNS error: {e}")
# Roll back IPv4 DNS if it was changed
if ipv4_changed:
self._rollback_dns(hostname, old_ipv4, "A")
raise DDNSError( raise DDNSError(
"Update failed", "Update failed",
STATUS_DNSERR, STATUS_DNSERR,
@@ -509,30 +601,51 @@ class DDNSRequestHandler(BaseHTTPRequestHandler):
) )
# 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
)
changed_addrs = "" if not ipv4_changed and not ipv6_changed and not expiry_ttl_changed:
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()}"
) )
self.respond( self.respond(
200, 200,
STATUS_NOCHG, STATUS_NOCHG,
ipv4=hostname.last_ipv4, ipv4=hostname.last_ipv4,
ipv6=hostname.last_ipv6 ipv6=hostname.last_ipv6,
expiry_ttl=hostname.expiry_ttl,
created=created
) )
return 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:
@@ -541,7 +654,8 @@ 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}")
@@ -550,7 +664,9 @@ class DDNSRequestHandler(BaseHTTPRequestHandler):
200, 200,
STATUS_GOOD, STATUS_GOOD,
ipv4=hostname.last_ipv4, ipv4=hostname.last_ipv4,
ipv6=hostname.last_ipv6 ipv6=hostname.last_ipv6,
expiry_ttl=hostname.expiry_ttl,
created=created
) )
@@ -611,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}")
@@ -631,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")