Compare commits
6 Commits
d13aea8e7d
...
v1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
7a7442c3db
|
|||
|
a5f0055b4f
|
|||
|
8096484a6e
|
|||
|
76ddd7c18c
|
|||
|
ca8b29fdc8
|
|||
|
25043be991
|
@@ -7,7 +7,7 @@ Dynamic DNS update service with CLI administration. Accepts HTTP(S) requests to
|
||||
- HTTP(S) server for DynDNS-compatible updates
|
||||
- Multiple endpoints with configurable parameter aliases
|
||||
- Dual-stack IPv4/IPv6 support
|
||||
- SQLite or MariaDB database backend
|
||||
- SQLite or MariaDB/MySQL database backend
|
||||
- Argon2 password hashing
|
||||
- Rate limiting (separate limits for good/bad requests)
|
||||
- TTL-based automatic record expiration
|
||||
@@ -22,10 +22,21 @@ Dynamic DNS update service with CLI administration. Accepts HTTP(S) requests to
|
||||
```bash
|
||||
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"
|
||||
```
|
||||
|
||||
### 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).
|
||||
|
||||
## 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:
|
||||
```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/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/change_notification.j2 https://git.ccc-rheintal.ch/spacefreak/ddns-service/raw/branch/master/files/change_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!
|
||||
chmod 640 /etc/ddns-service/config.toml
|
||||
@@ -94,7 +105,7 @@ ssl_key_file = "/etc/ddns-service/key.pem" # required if ssl = true
|
||||
[database]
|
||||
# backend = "sqlite" # default: "sqlite", or "mariadb"
|
||||
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_server = "127.0.0.1" # default: "127.0.0.1" (must be IP address)
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ readme = "README.md"
|
||||
license = "GPL-3.0-only"
|
||||
keywords = ["dns", "ddns", "service", "http", "https"]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Development Status :: 5 - Stable",
|
||||
"Topic :: Internet :: Name Service (DNS)",
|
||||
"Intended Audience :: System Administrators",
|
||||
"Programming Language :: Python :: 3"
|
||||
|
||||
+13
-5
@@ -27,7 +27,8 @@ class Application:
|
||||
config: Configuration dictionary from TOML file.
|
||||
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.password_hasher = argon2.PasswordHasher()
|
||||
self.shutdown_event = threading.Event()
|
||||
@@ -38,6 +39,12 @@ class Application:
|
||||
self.good_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):
|
||||
"""Initialize database connection and run migrations."""
|
||||
init_database(self.config)
|
||||
@@ -68,12 +75,13 @@ class Application:
|
||||
"""
|
||||
new_config = load_config(self.config_path)
|
||||
|
||||
with self._config_lock:
|
||||
# Preserve DB and bind settings
|
||||
new_config["database"] = self.config["database"]
|
||||
new_config["daemon"]["host"] = self.config["daemon"]["host"]
|
||||
new_config["daemon"]["port"] = self.config["daemon"]["port"]
|
||||
new_config["database"] = self._config["database"]
|
||||
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
|
||||
setup_logging(
|
||||
|
||||
+108
-112
@@ -26,6 +26,7 @@ from .cleanup import ExpiredRecordsCleanupThread, RateLimitCleanupThread
|
||||
from .dns import detect_ip_type
|
||||
from .logging import clear_txn_id, set_txn_id
|
||||
from .models import (
|
||||
close_database,
|
||||
DatabaseError,
|
||||
DoesNotExist,
|
||||
EncodingError,
|
||||
@@ -38,6 +39,9 @@ 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):
|
||||
"""Extract first matching param from query params."""
|
||||
@@ -240,6 +244,99 @@ class DDNSRequestHandler(BaseHTTPRequestHandler):
|
||||
pass
|
||||
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):
|
||||
"""Handle GET requests."""
|
||||
set_txn_id()
|
||||
@@ -306,7 +403,7 @@ class DDNSRequestHandler(BaseHTTPRequestHandler):
|
||||
"Auth failed",
|
||||
401,
|
||||
STATUS_BADAUTH,
|
||||
client_ip
|
||||
client_ip=client_ip
|
||||
)
|
||||
|
||||
# Process hostname parameter
|
||||
@@ -320,55 +417,9 @@ class DDNSRequestHandler(BaseHTTPRequestHandler):
|
||||
username=username
|
||||
)
|
||||
|
||||
# Process myip parameter
|
||||
ipv4 = None
|
||||
myip = extract_param(params, endpoint["params"]["ipv4"])
|
||||
if myip:
|
||||
try:
|
||||
rtype, myip = detect_ip_type(myip)
|
||||
if rtype == "A":
|
||||
ipv4 = myip
|
||||
else:
|
||||
ipv6 = myip
|
||||
except ValueError:
|
||||
raise DDNSClientError(
|
||||
"Bad IP address",
|
||||
400,
|
||||
STATUS_BADIP,
|
||||
client=client_ip,
|
||||
username=username,
|
||||
hostname=hostname_param,
|
||||
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 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
|
||||
# Parse IP parameters
|
||||
ipv4, ipv6 = self._parse_ip_params(
|
||||
params, endpoint, client_ip, username, hostname_param)
|
||||
|
||||
# Process notify_change parameter
|
||||
notify_change = extract_param(
|
||||
@@ -377,65 +428,9 @@ class DDNSRequestHandler(BaseHTTPRequestHandler):
|
||||
["1", "y", "yes", "on", "true"]
|
||||
if notify_change else False)
|
||||
|
||||
# Process expiry_ttl parameter
|
||||
expiry_ttl_param = extract_param(
|
||||
params, endpoint["params"]["expiry_ttl"])
|
||||
expiry_ttl = None
|
||||
if expiry_ttl_param:
|
||||
try:
|
||||
expiry_ttl = int(expiry_ttl_param)
|
||||
if expiry_ttl < 0:
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
raise DDNSClientError(
|
||||
"Invalid expiry_ttl",
|
||||
400,
|
||||
STATUS_NOHOST,
|
||||
client=client_ip,
|
||||
username=username,
|
||||
hostname=hostname_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
|
||||
)
|
||||
# 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)
|
||||
@@ -753,12 +748,13 @@ def run_daemon(app):
|
||||
server.handle_request()
|
||||
|
||||
# Graceful shutdown - wait for active requests
|
||||
server.wait_for_requests(5)
|
||||
server.wait_for_requests(SHUTDOWN_TIMEOUT)
|
||||
|
||||
# Cleanup
|
||||
expired_cleanup_thread.stop()
|
||||
ratelimit_cleanup_thread.stop()
|
||||
expired_cleanup_thread.join(timeout=5)
|
||||
ratelimit_cleanup_thread.join(timeout=5)
|
||||
expired_cleanup_thread.join(timeout=SHUTDOWN_TIMEOUT)
|
||||
ratelimit_cleanup_thread.join(timeout=SHUTDOWN_TIMEOUT)
|
||||
server.server_close()
|
||||
close_database()
|
||||
logging.info("Daemon stopped")
|
||||
|
||||
Reference in New Issue
Block a user