Compare commits
10 Commits
3d658968d3
..
v1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
7a7442c3db
|
|||
|
a5f0055b4f
|
|||
|
8096484a6e
|
|||
|
76ddd7c18c
|
|||
|
ca8b29fdc8
|
|||
|
25043be991
|
|||
|
d13aea8e7d
|
|||
|
aec5c278a1
|
|||
|
6af6176dec
|
|||
|
34e31297f8
|
@@ -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)
|
||||||
|
|||||||
+1
-1
@@ -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"
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ __all__ = [
|
|||||||
"logging",
|
"logging",
|
||||||
"main",
|
"main",
|
||||||
"models",
|
"models",
|
||||||
"now_utc"
|
"now_utc",
|
||||||
"ratelimit",
|
"ratelimit",
|
||||||
"server",
|
"server",
|
||||||
"STATUS_GOOD",
|
"STATUS_GOOD",
|
||||||
|
|||||||
+13
-5
@@ -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)
|
||||||
|
|
||||||
|
with self._config_lock:
|
||||||
# Preserve DB and bind settings
|
# Preserve DB and bind settings
|
||||||
new_config["database"] = self.config["database"]
|
new_config["database"] = self._config["database"]
|
||||||
new_config["daemon"]["host"] = self.config["daemon"]["host"]
|
new_config["daemon"]["host"] = self._config["daemon"]["host"]
|
||||||
new_config["daemon"]["port"] = self.config["daemon"]["port"]
|
new_config["daemon"]["port"] = self._config["daemon"]["port"]
|
||||||
|
|
||||||
self.config = new_config
|
self._config = new_config
|
||||||
|
|
||||||
# Reconfigure logging
|
# Reconfigure logging
|
||||||
setup_logging(
|
setup_logging(
|
||||||
|
|||||||
+15
-12
@@ -17,6 +17,15 @@ from .models import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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().order_by(User.username)
|
users = User.select().order_by(User.username)
|
||||||
@@ -53,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
|
||||||
@@ -102,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)
|
||||||
|
|||||||
+10
-5
@@ -15,6 +15,10 @@ 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
|
||||||
@@ -54,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
|
||||||
@@ -72,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):
|
||||||
@@ -209,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:
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ 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',
|
||||||
@@ -36,6 +36,7 @@ __all__ = [
|
|||||||
'get_hostname_for_user',
|
'get_hostname_for_user',
|
||||||
'get_permission',
|
'get_permission',
|
||||||
'get_user',
|
'get_user',
|
||||||
|
'close_database',
|
||||||
'DoesNotExist',
|
'DoesNotExist',
|
||||||
'EncodingError',
|
'EncodingError',
|
||||||
'DatabaseError',
|
'DatabaseError',
|
||||||
@@ -165,7 +166,7 @@ TABLE_TO_MODEL = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def init_database(config: dict):
|
def init_database(config):
|
||||||
"""
|
"""
|
||||||
Initialize database connection based on config.
|
Initialize database connection based on config.
|
||||||
|
|
||||||
@@ -210,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.
|
||||||
|
|
||||||
@@ -257,7 +258,7 @@ 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:
|
if to_version == 3:
|
||||||
_migrate_v3_create_permissions()
|
_migrate_v3_create_permissions()
|
||||||
@@ -283,7 +284,7 @@ def _migrate_v3_create_permissions():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _migrate_mariadb(to_version: int):
|
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')
|
||||||
@@ -332,7 +333,7 @@ def create_tables():
|
|||||||
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.
|
||||||
|
|
||||||
@@ -399,7 +400,7 @@ def get_permission(user, fqdn):
|
|||||||
raise DoesNotExist
|
raise DoesNotExist
|
||||||
|
|
||||||
|
|
||||||
def get_hostname(hostname: str, zone: str) -> Hostname:
|
def get_hostname(hostname, zone):
|
||||||
"""
|
"""
|
||||||
Get hostname by name and zone.
|
Get hostname by name and zone.
|
||||||
|
|
||||||
@@ -428,7 +429,7 @@ def get_hostname(hostname: str, zone: str) -> Hostname:
|
|||||||
def get_hostname_for_user(
|
def get_hostname_for_user(
|
||||||
user: User, hostname: str, zone: str, dns_ttl: int, expiry_ttl: int):
|
user: User, hostname: str, zone: str, dns_ttl: int, expiry_ttl: int):
|
||||||
"""
|
"""
|
||||||
Get hostname if it exists or create a new intance.
|
Get hostname if it exists or create a new instance.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
user: User requesting access.
|
user: User requesting access.
|
||||||
@@ -466,3 +467,10 @@ def get_hostname_for_user(
|
|||||||
),
|
),
|
||||||
True
|
True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def close_database():
|
||||||
|
"""Close database connection."""
|
||||||
|
if not db.is_closed():
|
||||||
|
db.close()
|
||||||
|
logging.debug("Database connection closed")
|
||||||
|
|||||||
+108
-112
@@ -26,6 +26,7 @@ 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,
|
||||||
@@ -38,6 +39,9 @@ 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."""
|
||||||
@@ -240,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()
|
||||||
@@ -306,7 +403,7 @@ class DDNSRequestHandler(BaseHTTPRequestHandler):
|
|||||||
"Auth failed",
|
"Auth failed",
|
||||||
401,
|
401,
|
||||||
STATUS_BADAUTH,
|
STATUS_BADAUTH,
|
||||||
client_ip
|
client_ip=client_ip
|
||||||
)
|
)
|
||||||
|
|
||||||
# Process hostname parameter
|
# Process hostname parameter
|
||||||
@@ -320,55 +417,9 @@ class DDNSRequestHandler(BaseHTTPRequestHandler):
|
|||||||
username=username
|
username=username
|
||||||
)
|
)
|
||||||
|
|
||||||
# Process myip parameter
|
# Parse IP parameters
|
||||||
ipv4 = None
|
ipv4, ipv6 = self._parse_ip_params(
|
||||||
myip = extract_param(params, endpoint["params"]["ipv4"])
|
params, endpoint, client_ip, username, hostname_param)
|
||||||
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
|
|
||||||
|
|
||||||
# Process notify_change parameter
|
# Process notify_change parameter
|
||||||
notify_change = extract_param(
|
notify_change = extract_param(
|
||||||
@@ -377,65 +428,9 @@ class DDNSRequestHandler(BaseHTTPRequestHandler):
|
|||||||
["1", "y", "yes", "on", "true"]
|
["1", "y", "yes", "on", "true"]
|
||||||
if notify_change else False)
|
if notify_change else False)
|
||||||
|
|
||||||
# Process expiry_ttl parameter
|
# Parse expiry_ttl parameter
|
||||||
expiry_ttl_param = extract_param(
|
expiry_ttl = self._parse_expiry_ttl(
|
||||||
params, endpoint["params"]["expiry_ttl"])
|
params, endpoint, client_ip, username, hostname_param)
|
||||||
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
|
|
||||||
)
|
|
||||||
|
|
||||||
# Validate credentials
|
# Validate credentials
|
||||||
user = self._authenticate(client_ip, username, password)
|
user = self._authenticate(client_ip, username, password)
|
||||||
@@ -753,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")
|
||||||
|
|||||||
Reference in New Issue
Block a user