Compare commits
49
Commits
6c4b876191
...
v1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a7442c3db
|
||
|
|
a5f0055b4f
|
||
|
|
8096484a6e
|
||
|
|
76ddd7c18c
|
||
|
|
ca8b29fdc8
|
||
|
|
25043be991
|
||
|
|
d13aea8e7d
|
||
|
|
aec5c278a1
|
||
|
|
6af6176dec
|
||
|
|
34e31297f8
|
||
|
|
3d658968d3
|
||
|
|
81ffdc9925
|
||
|
|
eafe106bf1
|
||
|
|
5b51a8a153
|
||
|
|
acd16b4a82
|
||
|
|
d0e9136e53
|
||
|
|
d780572d71
|
||
|
|
e6339eae61
|
||
|
|
485c977bdd
|
||
|
|
419adb10a4
|
||
|
|
871a685267
|
||
|
|
8dda556480
|
||
|
|
215fbb116e
|
||
|
|
adaf08f4d1
|
||
|
|
2d61ad11b3
|
||
|
|
e37a9e84a6
|
||
|
|
21b5a4c553
|
||
|
|
5d2b9c3ffb
|
||
|
|
cd5979556c
|
||
|
|
60ebf4b387
|
||
|
|
105a9d4253
|
||
|
|
870a1b9f00
|
||
|
|
89e63858a0
|
||
|
|
e365fa7d77
|
||
|
|
b97eb0404c
|
||
|
|
07e37e525c
|
||
|
|
cde4b879c1
|
||
|
|
255c0ad1dd
|
||
|
|
a1e3ee1770
|
||
|
|
bd0c930060
|
||
|
|
2123b5169b
|
||
|
|
5bb37fde71
|
||
|
|
444db3f190
|
||
|
|
8b186d6e95
|
||
|
|
faa1e4afd5
|
||
|
|
6c382ae60c
|
||
|
|
2381d2e1d2
|
||
|
|
feb4a67291
|
||
|
|
f0b924ea56
|
@@ -7,7 +7,7 @@ Dynamic DNS update service with CLI administration. Accepts HTTP(S) requests to
|
|||||||
- HTTP(S) server for DynDNS-compatible updates
|
- HTTP(S) server for DynDNS-compatible updates
|
||||||
- Multiple endpoints with configurable parameter aliases
|
- Multiple endpoints with configurable parameter aliases
|
||||||
- Dual-stack IPv4/IPv6 support
|
- Dual-stack IPv4/IPv6 support
|
||||||
- SQLite or MariaDB database backend
|
- SQLite or MariaDB/MySQL database backend
|
||||||
- Argon2 password hashing
|
- Argon2 password hashing
|
||||||
- Rate limiting (separate limits for good/bad requests)
|
- Rate limiting (separate limits for good/bad requests)
|
||||||
- TTL-based automatic record expiration
|
- TTL-based automatic record expiration
|
||||||
@@ -22,10 +22,21 @@ Dynamic DNS update service with CLI administration. Accepts HTTP(S) requests to
|
|||||||
```bash
|
```bash
|
||||||
pip install git+https://git.ccc-rheintal.ch/spacefreak/ddns-service.git
|
pip install git+https://git.ccc-rheintal.ch/spacefreak/ddns-service.git
|
||||||
|
|
||||||
# With MariaDB support:
|
# With MariaDB/MySQL support:
|
||||||
pip install "ddns-service[mysql] @ git+https://git.ccc-rheintal.ch/spacefreak/ddns-service.git"
|
pip install "ddns-service[mysql] @ git+https://git.ccc-rheintal.ch/spacefreak/ddns-service.git"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Install a specific version
|
||||||
|
|
||||||
|
Append `@<git-tag>` to the repository URL to install a tagged release:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install git+https://git.ccc-rheintal.ch/spacefreak/ddns-service.git@v1.0.0
|
||||||
|
|
||||||
|
# With MariaDB/MySQL support:
|
||||||
|
pip install "ddns-service[mysql] @ git+https://git.ccc-rheintal.ch/spacefreak/ddns-service.git@v1.0.0"
|
||||||
|
```
|
||||||
|
|
||||||
Requires Python 3.11+. Dependencies installed automatically: argon2-cffi, dnspython, jinja2, peewee (+ pymysql for mysql extra).
|
Requires Python 3.11+. Dependencies installed automatically: argon2-cffi, dnspython, jinja2, peewee (+ pymysql for mysql extra).
|
||||||
|
|
||||||
## Service setup
|
## Service setup
|
||||||
@@ -46,8 +57,8 @@ chown ddns:ddns /etc/ddns-service /var/lib/ddns-service /var/log/ddns-service
|
|||||||
3. Config file and templates:
|
3. Config file and templates:
|
||||||
```bash
|
```bash
|
||||||
wget -O /etc/ddns-service/config.toml https://git.ccc-rheintal.ch/spacefreak/ddns-service/raw/branch/master/files/config.example.toml
|
wget -O /etc/ddns-service/config.toml https://git.ccc-rheintal.ch/spacefreak/ddns-service/raw/branch/master/files/config.example.toml
|
||||||
wget -O /etc/ddns-service/config.toml https://git.ccc-rheintal.ch/spacefreak/ddns-service/raw/branch/master/files/change_notification.j2
|
wget -O /etc/ddns-service/change_notification.j2 https://git.ccc-rheintal.ch/spacefreak/ddns-service/raw/branch/master/files/change_notification.j2
|
||||||
wget -O /etc/ddns-service/config.toml https://git.ccc-rheintal.ch/spacefreak/ddns-service/raw/branch/master/files/expiry_notification.j2
|
wget -O /etc/ddns-service/expiry_notification.j2 https://git.ccc-rheintal.ch/spacefreak/ddns-service/raw/branch/master/files/expiry_notification.j2
|
||||||
|
|
||||||
# The config file must not be world readable!
|
# The config file must not be world readable!
|
||||||
chmod 640 /etc/ddns-service/config.toml
|
chmod 640 /etc/ddns-service/config.toml
|
||||||
@@ -73,6 +84,7 @@ Example:
|
|||||||
```toml
|
```toml
|
||||||
[daemon]
|
[daemon]
|
||||||
# host = "localhost" # default: "localhost" (use reverse proxy for public access)
|
# host = "localhost" # default: "localhost" (use reverse proxy for public access)
|
||||||
|
# # Use "0.0.0.0" for IPv4-only, "::" for IPv6-only (dual-stack depends on OS)
|
||||||
# port = 8443 # default: 8443
|
# port = 8443 # default: 8443
|
||||||
# log_level = "INFO" # default: "INFO"
|
# log_level = "INFO" # default: "INFO"
|
||||||
# log_target = "stdout" # default: "stdout", or "syslog", "file"
|
# log_target = "stdout" # default: "stdout", or "syslog", "file"
|
||||||
@@ -87,10 +99,13 @@ ssl_cert_file = "/etc/ddns-service/cert.pem" # required if ssl = true
|
|||||||
ssl_key_file = "/etc/ddns-service/key.pem" # required if ssl = true
|
ssl_key_file = "/etc/ddns-service/key.pem" # required if ssl = true
|
||||||
# proxy_header = "" # default: "" (disabled), e.g. "X-Forwarded-For"
|
# proxy_header = "" # default: "" (disabled), e.g. "X-Forwarded-For"
|
||||||
# trusted_proxies = [] # default: [], e.g. ["127.0.0.1", "10.0.0.0/8"]
|
# trusted_proxies = [] # default: [], e.g. ["127.0.0.1", "10.0.0.0/8"]
|
||||||
|
# thread_pool_size = 10 # default: 10
|
||||||
|
# request_timeout = 10 # default: 10 (seconds)
|
||||||
|
|
||||||
[database]
|
[database]
|
||||||
# backend = "sqlite" # default: "sqlite", or "mariadb"
|
# backend = "sqlite" # default: "sqlite", or "mariadb"
|
||||||
path = "/var/lib/ddns-service/ddns.db" # required for sqlite
|
path = "/var/lib/ddns-service/ddns.db" # required for sqlite
|
||||||
|
# pool_size = 5 # default: 5 (MariaDB/MySQL connection pool size)
|
||||||
|
|
||||||
[dns_service]
|
[dns_service]
|
||||||
# dns_server = "127.0.0.1" # default: "127.0.0.1" (must be IP address)
|
# dns_server = "127.0.0.1" # default: "127.0.0.1" (must be IP address)
|
||||||
@@ -106,6 +121,9 @@ path = "/var/lib/ddns-service/ddns.db" # required for sqlite
|
|||||||
[defaults]
|
[defaults]
|
||||||
# dns_ttl = 60 # default: 60
|
# dns_ttl = 60 # default: 60
|
||||||
# expiry_ttl = 3600 # default: 3600
|
# expiry_ttl = 3600 # default: 3600
|
||||||
|
# expiry_ttl_min = # optional, min value via HTTP
|
||||||
|
# expiry_ttl_max = # optional, max value via HTTP
|
||||||
|
# expiry_ttl_allow_zero = true # default: true, allow 0 via HTTP
|
||||||
|
|
||||||
[email]
|
[email]
|
||||||
# enabled = false # default: false
|
# enabled = false # default: false
|
||||||
@@ -178,6 +196,7 @@ ipv6 = ["myip6", "ipv6", "ip6"]
|
|||||||
username = ["username", "user"]
|
username = ["username", "user"]
|
||||||
password = ["password", "pass", "token"]
|
password = ["password", "pass", "token"]
|
||||||
notify_change = ["notify_change"]
|
notify_change = ["notify_change"]
|
||||||
|
expiry_ttl = ["expiry_ttl"]
|
||||||
|
|
||||||
[[endpoints]]
|
[[endpoints]]
|
||||||
path = "/nic/update"
|
path = "/nic/update"
|
||||||
@@ -188,6 +207,7 @@ ipv6 = ["myip6"]
|
|||||||
username = ["username"]
|
username = ["username"]
|
||||||
password = ["password"]
|
password = ["password"]
|
||||||
notify_change = []
|
notify_change = []
|
||||||
|
expiry_ttl = []
|
||||||
```
|
```
|
||||||
|
|
||||||
**Default accepted parameter names** (first match wins):
|
**Default accepted parameter names** (first match wins):
|
||||||
@@ -199,6 +219,7 @@ notify_change = []
|
|||||||
| username | username, user |
|
| username | username, user |
|
||||||
| password | password, pass, token |
|
| password | password, pass, token |
|
||||||
| notify_change | notify_change |
|
| notify_change | notify_change |
|
||||||
|
| expiry_ttl | expiry_ttl |
|
||||||
|
|
||||||
## CLI Usage
|
## CLI Usage
|
||||||
|
|
||||||
@@ -237,8 +258,39 @@ ddns-service user passwd myuser
|
|||||||
ddns-service user email myuser new@example.com
|
ddns-service user email myuser new@example.com
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Permission Management
|
||||||
|
|
||||||
|
Permissions control which hostnames users can update.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List all permissions
|
||||||
|
ddns-service permission list
|
||||||
|
|
||||||
|
# List permissions for specific user
|
||||||
|
ddns-service permission list --user myuser
|
||||||
|
|
||||||
|
# Add permission for exact hostname
|
||||||
|
ddns-service permission add myuser mypc dyn.example.com
|
||||||
|
|
||||||
|
# Add wildcard permission (any hostname in zone)
|
||||||
|
ddns-service permission add myuser '*' dyn.example.com
|
||||||
|
|
||||||
|
# Add suffix wildcard (e.g., *.home matches foo.home, bar.home)
|
||||||
|
ddns-service permission add myuser '*.home' dyn.example.com
|
||||||
|
|
||||||
|
# Delete permission
|
||||||
|
ddns-service permission delete myuser mypc dyn.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pattern matching:**
|
||||||
|
- `*` - matches any hostname in the zone
|
||||||
|
- `*.suffix` - matches hostnames ending in `.suffix` (recursive)
|
||||||
|
- `exact` - matches only that exact hostname
|
||||||
|
|
||||||
### Hostname Management
|
### Hostname Management
|
||||||
|
|
||||||
|
Hostnames are auto-created when users with permission send their first update.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# List all hostnames
|
# List all hostnames
|
||||||
ddns-service hostname list
|
ddns-service hostname list
|
||||||
@@ -246,13 +298,6 @@ ddns-service hostname list
|
|||||||
# List hostnames for specific user
|
# List hostnames for specific user
|
||||||
ddns-service hostname list --user myuser
|
ddns-service hostname list --user myuser
|
||||||
|
|
||||||
# Add hostname
|
|
||||||
ddns-service hostname add myuser mypc dyn.example.com
|
|
||||||
|
|
||||||
# Add hostname with custom TTLs
|
|
||||||
ddns-service hostname add myuser mypc dyn.example.com \
|
|
||||||
--dns-ttl 60 --expiry-ttl 7200
|
|
||||||
|
|
||||||
# Modify hostname TTLs
|
# Modify hostname TTLs
|
||||||
ddns-service hostname modify mypc dyn.example.com --dns-ttl 120
|
ddns-service hostname modify mypc dyn.example.com --dns-ttl 120
|
||||||
|
|
||||||
@@ -276,12 +321,22 @@ ddns-service --daemon
|
|||||||
ddns-service --daemon --debug
|
ddns-service --daemon --debug
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Signals
|
||||||
|
|
||||||
|
- **SIGHUP**: Reload configuration (all settings except database, host, port; SSL certs are reloaded)
|
||||||
|
- **SIGTERM/SIGINT**: Graceful shutdown (waits up to 5 seconds for active requests)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Reload config
|
||||||
|
kill -HUP $(pidof ddns-service)
|
||||||
|
```
|
||||||
|
|
||||||
## HTTP API
|
## HTTP API
|
||||||
|
|
||||||
### Request
|
### Request
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /update?hostname=mypc.dyn.example.com[&myip=1.2.3.4][&myip6=2001:db8::1][¬ify_change=1]
|
GET /update?hostname=mypc.dyn.example.com[&myip=1.2.3.4][&myip6=2001:db8::1][¬ify_change=1][&expiry_ttl=7200]
|
||||||
Authorization: Basic base64(username:password)
|
Authorization: Basic base64(username:password)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -292,6 +347,8 @@ GET /update?hostname=mypc.dyn.example.com&username=myuser&password=secret
|
|||||||
|
|
||||||
Set `notify_change=1` to receive an email notification when the IP address changes. Requires email to be enabled and a change notification template configured.
|
Set `notify_change=1` to receive an email notification when the IP address changes. Requires email to be enabled and a change notification template configured.
|
||||||
|
|
||||||
|
Set `expiry_ttl=N` to change the hostname's expiry TTL (in seconds). Can be sent alone without IP parameters.
|
||||||
|
|
||||||
### IP Detection
|
### IP Detection
|
||||||
|
|
||||||
- If `myip` and/or `myip6` provided: use those values
|
- If `myip` and/or `myip6` provided: use those values
|
||||||
@@ -311,7 +368,7 @@ Set `notify_change=1` to receive an email notification when the IP address chang
|
|||||||
|
|
||||||
**JSON (with `Accept: application/json`):**
|
**JSON (with `Accept: application/json`):**
|
||||||
```json
|
```json
|
||||||
{"status": "good", "ipv4": "1.2.3.4", "ipv6": "2001:db8::1"}
|
{"status": "good", "ipv4": "1.2.3.4", "ipv6": "2001:db8::1", "expiry_ttl": 3600}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Client Examples
|
## Client Examples
|
||||||
@@ -332,6 +389,11 @@ With change notification:
|
|||||||
curl -u "username:password" "https://ddns.example.com/update?hostname=mypc.dyn.example.com¬ify_change=1"
|
curl -u "username:password" "https://ddns.example.com/update?hostname=mypc.dyn.example.com¬ify_change=1"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Change expiry TTL:
|
||||||
|
```bash
|
||||||
|
curl -u "username:password" "https://ddns.example.com/update?hostname=mypc.dyn.example.com&expiry_ttl=7200"
|
||||||
|
```
|
||||||
|
|
||||||
### wget
|
### wget
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -401,6 +463,7 @@ Templates use Jinja2 syntax. Available variables:
|
|||||||
| ipv6_changed | Boolean, IPv6 changed |
|
| ipv6_changed | Boolean, IPv6 changed |
|
||||||
| ipv6 | Current IPv6 address |
|
| ipv6 | Current IPv6 address |
|
||||||
| last_ipv6_update | Last IPv6 update time |
|
| last_ipv6_update | Last IPv6 update time |
|
||||||
|
| expiry_ttl_changed | Boolean, Expiry-TTL changed |
|
||||||
| expiry_ttl | Expiry TTL in seconds |
|
| expiry_ttl | Expiry TTL in seconds |
|
||||||
|
|
||||||
**Expiry notification:**
|
**Expiry notification:**
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
[daemon]
|
[daemon]
|
||||||
# host = "localhost" # default, use reverse proxy for public access!
|
# host = "localhost" # default, use reverse proxy for public access!
|
||||||
|
# # Use "0.0.0.0" for IPv4-only, "::" for IPv6-only (dual-stack depends on OS)
|
||||||
# port = 8443 # default
|
# port = 8443 # default
|
||||||
# log_level = "INFO" # default
|
# log_level = "INFO" # default
|
||||||
# log_target = "stdout" # default, "stdout", "syslog" or "file"
|
# log_target = "stdout" # default, "stdout", "syslog" or "file"
|
||||||
@@ -14,6 +15,8 @@ ssl_cert_file = "/etc/ddns-service/cert.pem" # required if ssl = true
|
|||||||
ssl_key_file = "/etc/ddns-service/key.pem" # required if ssl = true
|
ssl_key_file = "/etc/ddns-service/key.pem" # required if ssl = true
|
||||||
# proxy_header = "" # default (disabled), header name e.g. "X-Forwarded-For"
|
# proxy_header = "" # default (disabled), header name e.g. "X-Forwarded-For"
|
||||||
# trusted_proxies = [] # default, list of trusted proxy IPs/CIDRs
|
# trusted_proxies = [] # default, list of trusted proxy IPs/CIDRs
|
||||||
|
# thread_pool_size = 10 # default, max concurrent request handlers
|
||||||
|
# request_timeout = 10 # default, socket timeout in seconds
|
||||||
|
|
||||||
[database]
|
[database]
|
||||||
# backend = "sqlite" # default, "sqlite", or "mariadb"
|
# backend = "sqlite" # default, "sqlite", or "mariadb"
|
||||||
@@ -23,6 +26,7 @@ path = "/var/lib/ddns-service/ddns.db" # required for sqlite
|
|||||||
# user = "ddns" # required for mariadb
|
# user = "ddns" # required for mariadb
|
||||||
# password = "secret" # required for mariadb
|
# password = "secret" # required for mariadb
|
||||||
# database = "ddns" # required for mariadb
|
# database = "ddns" # required for mariadb
|
||||||
|
# pool_size = 5 # default, MariaDB connection pool size
|
||||||
|
|
||||||
[dns_service]
|
[dns_service]
|
||||||
# dns_server = "127.0.0.1" # default, must be IP address
|
# dns_server = "127.0.0.1" # default, must be IP address
|
||||||
@@ -38,6 +42,9 @@ path = "/var/lib/ddns-service/ddns.db" # required for sqlite
|
|||||||
[defaults]
|
[defaults]
|
||||||
# dns_ttl = 60 # default, DNS record TTL in seconds
|
# dns_ttl = 60 # default, DNS record TTL in seconds
|
||||||
# expiry_ttl = 3600 # default, 0 to disable expiration
|
# expiry_ttl = 3600 # default, 0 to disable expiration
|
||||||
|
# expiry_ttl_min = # optional, min value allowed via HTTP
|
||||||
|
# expiry_ttl_max = # optional, max value allowed via HTTP
|
||||||
|
# expiry_ttl_allow_zero = true # default, allow 0 (never expire) via HTTP
|
||||||
|
|
||||||
[email]
|
[email]
|
||||||
# enabled = false # default
|
# enabled = false # default
|
||||||
@@ -66,6 +73,7 @@ from_address = "ddns@example.com" # required if email.enabled
|
|||||||
# username: username, user
|
# username: username, user
|
||||||
# password: password, pass, token
|
# password: password, pass, token
|
||||||
# notify_change: notify_change
|
# notify_change: notify_change
|
||||||
|
# expiry_ttl: expiry_ttl
|
||||||
#
|
#
|
||||||
# Multiple endpoints can be defined with custom parameter names
|
# Multiple endpoints can be defined with custom parameter names
|
||||||
|
|
||||||
@@ -78,6 +86,7 @@ from_address = "ddns@example.com" # required if email.enabled
|
|||||||
# username = ["username", "user"]
|
# username = ["username", "user"]
|
||||||
# password = ["password", "pass", "token"]
|
# password = ["password", "pass", "token"]
|
||||||
# notify_change = ["notify_change"]
|
# notify_change = ["notify_change"]
|
||||||
|
# expiry_ttl = ["expiry_ttl"]
|
||||||
|
|
||||||
# [[endpoints]]
|
# [[endpoints]]
|
||||||
# path = "/nic/update"
|
# path = "/nic/update"
|
||||||
@@ -88,3 +97,4 @@ from_address = "ddns@example.com" # required if email.enabled
|
|||||||
# username = ["username"]
|
# username = ["username"]
|
||||||
# password = ["password"]
|
# password = ["password"]
|
||||||
# notify_change = []
|
# notify_change = []
|
||||||
|
# expiry_ttl = []
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ command_args="--daemon ${OPTIONS}"
|
|||||||
command_user="${USER}:${GROUP}"
|
command_user="${USER}:${GROUP}"
|
||||||
command_background="yes"
|
command_background="yes"
|
||||||
pidfile="/run/${RC_SVCNAME}.pid"
|
pidfile="/run/${RC_SVCNAME}.pid"
|
||||||
|
extra_started_commands="reload"
|
||||||
|
|
||||||
depend() {
|
depend() {
|
||||||
need net
|
need net
|
||||||
@@ -20,3 +21,9 @@ depend() {
|
|||||||
start_pre() {
|
start_pre() {
|
||||||
checkpath --directory --owner ${USER}:${GROUP} --mode 0750 /var/lib/ddns-service
|
checkpath --directory --owner ${USER}:${GROUP} --mode 0750 /var/lib/ddns-service
|
||||||
}
|
}
|
||||||
|
|
||||||
|
reload() {
|
||||||
|
ebegin "Reloading ${RC_SVCNAME}"
|
||||||
|
start-stop-daemon --signal HUP --pidfile "${pidfile}"
|
||||||
|
eend $?
|
||||||
|
}
|
||||||
|
|||||||
+1
-1
@@ -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"
|
||||||
|
|||||||
@@ -15,20 +15,110 @@ __all__ = [
|
|||||||
"cleanup",
|
"cleanup",
|
||||||
"cli",
|
"cli",
|
||||||
"config",
|
"config",
|
||||||
|
"datetime_aware_utc",
|
||||||
|
"datetime_naive_utc",
|
||||||
"datetime_str",
|
"datetime_str",
|
||||||
"dns",
|
"dns",
|
||||||
"email",
|
"email",
|
||||||
"logging",
|
"logging",
|
||||||
"main",
|
"main",
|
||||||
"models",
|
"models",
|
||||||
|
"now_utc",
|
||||||
"ratelimit",
|
"ratelimit",
|
||||||
"server",
|
"server",
|
||||||
|
"STATUS_GOOD",
|
||||||
|
"STATUS_NOCHG",
|
||||||
|
"STATUS_BADAUTH",
|
||||||
|
"STATUS_NOHOST",
|
||||||
|
"STATUS_DNSERR",
|
||||||
|
"STATUS_ABUSE",
|
||||||
|
"STATUS_BADIP",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# DynDNS-compatible response statuses
|
||||||
|
STATUS_GOOD = "good"
|
||||||
|
STATUS_NOCHG = "nochg"
|
||||||
|
STATUS_BADAUTH = "badauth"
|
||||||
|
STATUS_NOHOST = "nohost"
|
||||||
|
STATUS_DNSERR = "dnserr"
|
||||||
|
STATUS_ABUSE = "abuse"
|
||||||
|
STATUS_BADIP = "badip"
|
||||||
|
|
||||||
DATETIME_FORMAT = "%Y-%m-%d %H:%M:%S %Z"
|
DATETIME_FORMAT = "%Y-%m-%d %H:%M:%S %Z"
|
||||||
|
|
||||||
|
# Datetime convention:
|
||||||
|
# All datetime objects in this codebase are timezone-aware.
|
||||||
|
# - now_utc(): returns timezone-aware UTC datetime
|
||||||
|
# - datetime_str(): converts naive UTC (adds tzinfo for formatting)
|
||||||
|
# or timezone-aware datetime to display string
|
||||||
|
# - Database stores/returns naive datetimes (always UTC by convention)
|
||||||
|
# - Database models automatically convert between naive/timezone-aware datetimes
|
||||||
|
|
||||||
|
|
||||||
|
def now_utc():
|
||||||
|
"""
|
||||||
|
Get current date and time in UTC.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Timezone-aware datetime object in UTC.
|
||||||
|
"""
|
||||||
|
return datetime.datetime.now(datetime.UTC)
|
||||||
|
|
||||||
|
|
||||||
|
def datetime_naive_utc(dt):
|
||||||
|
"""
|
||||||
|
Convert datetime to naive UTC datetime.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dt: Datetime object (naive UTC or timezone-aware or None).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Naive datetime object in UTC or None if dt is not a datetime.
|
||||||
|
"""
|
||||||
|
if not isinstance(dt, datetime.datetime):
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not dt.tzinfo:
|
||||||
|
return dt
|
||||||
|
|
||||||
|
return dt.astimezone(datetime.UTC).replace(tzinfo=None)
|
||||||
|
|
||||||
|
|
||||||
|
def datetime_aware_utc(dt):
|
||||||
|
"""
|
||||||
|
Convert datetime to UTC datetime.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dt: Datetime object (naive UTC or timezone-aware or None).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Timzone-aware datetime object in UTC or None if dt is not a datetime.
|
||||||
|
"""
|
||||||
|
if not isinstance(dt, datetime.datetime):
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not dt.tzinfo:
|
||||||
|
return dt.replace(tzinfo=datetime.UTC)
|
||||||
|
|
||||||
|
if dt.tzinfo == datetime.UTC:
|
||||||
|
return dt
|
||||||
|
|
||||||
|
return dt.astimezone(datetime.UTC)
|
||||||
|
|
||||||
|
|
||||||
def datetime_str(dt, utc=False):
|
def datetime_str(dt, utc=False):
|
||||||
|
"""
|
||||||
|
Convert datetime to display string.
|
||||||
|
|
||||||
|
Assumes naive datetimes are UTC per codebase convention.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dt: Datetime object (naive UTC or timezone-aware).
|
||||||
|
utc: If True, display in UTC; otherwise convert to local timezone.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted datetime string, or "Never" if dt is not a datetime.
|
||||||
|
"""
|
||||||
if not isinstance(dt, datetime.datetime):
|
if not isinstance(dt, datetime.datetime):
|
||||||
return "Never"
|
return "Never"
|
||||||
|
|
||||||
@@ -38,7 +128,3 @@ def datetime_str(dt, utc=False):
|
|||||||
return aware_dt.strftime(DATETIME_FORMAT)
|
return aware_dt.strftime(DATETIME_FORMAT)
|
||||||
else:
|
else:
|
||||||
return aware_dt.astimezone().strftime(DATETIME_FORMAT)
|
return aware_dt.astimezone().strftime(DATETIME_FORMAT)
|
||||||
|
|
||||||
|
|
||||||
def utc_now():
|
|
||||||
return datetime.datetime.now(datetime.UTC).replace(tzinfo=None)
|
|
||||||
|
|||||||
+48
-4
@@ -1,12 +1,13 @@
|
|||||||
"""Application class - central dependency holder."""
|
"""Application class - central dependency holder."""
|
||||||
|
|
||||||
|
import argon2
|
||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
import argon2
|
from .config import load_config
|
||||||
|
|
||||||
from .dns import DNSService
|
from .dns import DNSService
|
||||||
from .email import EmailService
|
from .email import EmailService
|
||||||
|
from .logging import setup_logging
|
||||||
from .models import create_tables, init_database
|
from .models import create_tables, init_database
|
||||||
from .ratelimit import BadLimiter, GoodLimiter
|
from .ratelimit import BadLimiter, GoodLimiter
|
||||||
|
|
||||||
@@ -18,14 +19,17 @@ class Application:
|
|||||||
Holds configuration and all service instances.
|
Holds configuration and all service instances.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, config):
|
def __init__(self, config, config_path=None):
|
||||||
"""
|
"""
|
||||||
Initialize application with configuration.
|
Initialize application with configuration.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
config: Configuration dictionary from TOML file.
|
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.password_hasher = argon2.PasswordHasher()
|
||||||
self.shutdown_event = threading.Event()
|
self.shutdown_event = threading.Event()
|
||||||
|
|
||||||
@@ -35,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)
|
||||||
@@ -57,6 +67,40 @@ class Application:
|
|||||||
self.bad_limiter = BadLimiter(self.config)
|
self.bad_limiter = BadLimiter(self.config)
|
||||||
logging.info("Rate limiters initialized")
|
logging.info("Rate limiters initialized")
|
||||||
|
|
||||||
|
def reload_config(self):
|
||||||
|
"""
|
||||||
|
Reload configuration from file.
|
||||||
|
|
||||||
|
Does not reload: database settings, host, port.
|
||||||
|
"""
|
||||||
|
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"]
|
||||||
|
|
||||||
|
self._config = new_config
|
||||||
|
|
||||||
|
# Reconfigure logging
|
||||||
|
setup_logging(
|
||||||
|
level=self.config["daemon"]["log_level"],
|
||||||
|
target=self.config["daemon"]["log_target"],
|
||||||
|
syslog_socket=self.config["daemon"]["syslog_socket"],
|
||||||
|
syslog_facility=self.config["daemon"]["syslog_facility"],
|
||||||
|
log_file=self.config["daemon"]["log_file"],
|
||||||
|
log_file_size=self.config["daemon"]["log_file_size"],
|
||||||
|
log_versions=self.config["daemon"]["log_versions"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Re-init services
|
||||||
|
self.init_dns()
|
||||||
|
self.init_email()
|
||||||
|
self.init_rate_limiters()
|
||||||
|
|
||||||
|
logging.info("Configuration reloaded")
|
||||||
|
|
||||||
def signal_shutdown(self):
|
def signal_shutdown(self):
|
||||||
"""Signal the application to shut down."""
|
"""Signal the application to shut down."""
|
||||||
logging.info("Shutdown signaled")
|
logging.info("Shutdown signaled")
|
||||||
|
|||||||
+90
-42
@@ -3,25 +3,26 @@
|
|||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
from . import utc_now
|
from . import now_utc
|
||||||
from .models import Hostname, User
|
from .models import DatabaseError, Hostname, User
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
|
|
||||||
def cleanup_expired(app):
|
def cleanup_expired(app, start_time=None):
|
||||||
"""
|
"""
|
||||||
Clean up expired hostnames and return count of cleaned entries.
|
Clean up expired hostnames and return count of cleaned entries.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
app: Application instance with dns_service and email_service.
|
app: Application instance with dns_service and email_service.
|
||||||
|
start_time: Timezone aware datetime object containg the start time of the cleanup thread.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Number of expired hostnames processed.
|
Number of expired hostnames processed.
|
||||||
"""
|
"""
|
||||||
now = utc_now()
|
now = now_utc()
|
||||||
expired_count = 0
|
expired_count = 0
|
||||||
|
|
||||||
for hostname in Hostname.select().join(User).where(
|
for hostname in Hostname.select(Hostname, User).join(User).where(
|
||||||
(Hostname.expiry_ttl != 0) &
|
(Hostname.expiry_ttl != 0) &
|
||||||
((Hostname.last_ipv4.is_null(False) & Hostname.last_ipv4_update.is_null(False)) |
|
((Hostname.last_ipv4.is_null(False) & Hostname.last_ipv4_update.is_null(False)) |
|
||||||
(Hostname.last_ipv6.is_null(False) & Hostname.last_ipv6_update.is_null(False)))):
|
(Hostname.last_ipv6.is_null(False) & Hostname.last_ipv6_update.is_null(False)))):
|
||||||
@@ -30,51 +31,105 @@ def cleanup_expired(app):
|
|||||||
ipv6_expired = False
|
ipv6_expired = False
|
||||||
|
|
||||||
if hostname.last_ipv4:
|
if hostname.last_ipv4:
|
||||||
expiry_time = hostname.last_ipv4_update + timedelta(seconds=hostname.expiry_ttl)
|
last_update = max(hostname.last_ipv4_update, start_time) if start_time \
|
||||||
|
else hostname.last_ipv4_update
|
||||||
|
expiry_time = last_update + timedelta(seconds=hostname.expiry_ttl)
|
||||||
if now > expiry_time:
|
if now > expiry_time:
|
||||||
ipv4_expired = True
|
ipv4_expired = True
|
||||||
|
|
||||||
if hostname.last_ipv6:
|
if hostname.last_ipv6:
|
||||||
expiry_time = hostname.last_ipv6_update + timedelta(seconds=hostname.expiry_ttl)
|
last_update = max(hostname.last_ipv6_update, start_time) if start_time \
|
||||||
|
else hostname.last_ipv6_update
|
||||||
|
expiry_time = last_update + timedelta(seconds=hostname.expiry_ttl)
|
||||||
if now > expiry_time:
|
if now > expiry_time:
|
||||||
ipv6_expired = True
|
ipv6_expired = True
|
||||||
|
|
||||||
if not ipv4_expired and not ipv6_expired:
|
if not ipv4_expired and not ipv6_expired:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
ipv4_deleted = False
|
old_ipv4 = hostname.last_ipv4
|
||||||
ipv6_deleted = False
|
old_ipv6 = hostname.last_ipv6
|
||||||
|
ipv4_dns_deleted = False
|
||||||
|
ipv6_dns_deleted = False
|
||||||
|
|
||||||
if app.dns_service:
|
if ipv4_expired:
|
||||||
if ipv4_expired:
|
logging.info(
|
||||||
logging.info(
|
f"Cleanup: Host expired: hostname={hostname.hostname} zone={hostname.zone} "
|
||||||
f"Host expired: hostname={hostname.hostname} zone={hostname.zone} "
|
f"ipv4={hostname.last_ipv4}"
|
||||||
f"ip={hostname.last_ipv4}"
|
)
|
||||||
|
try:
|
||||||
|
ipv4_exists = app.dns_service.query_record(
|
||||||
|
hostname.hostname, hostname.zone, "A")
|
||||||
|
if ipv4_exists:
|
||||||
|
app.dns_service.delete_record(
|
||||||
|
hostname.hostname, hostname.zone, "A")
|
||||||
|
ipv4_dns_deleted = True
|
||||||
|
hostname.last_ipv4 = None
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"DNS error: {e}")
|
||||||
|
logging.error(
|
||||||
|
f"Cleanup failed: hostname={hostname.hostname} "
|
||||||
|
f"zone={hostname.zone} type=A"
|
||||||
)
|
)
|
||||||
try:
|
|
||||||
app.dns_service.delete_record(hostname.hostname, hostname.zone, "A")
|
|
||||||
ipv4_deleted = True
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(
|
|
||||||
f"DNS delete failed: hostname={hostname.hostname} "
|
|
||||||
f"zone={hostname.zone} type=A error={e}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if ipv6_expired:
|
if ipv6_expired:
|
||||||
logging.info(
|
logging.info(
|
||||||
f"Host expired: hostname={hostname.hostname} zone={hostname.zone} "
|
f"Cleanup: Host expired: hostname={hostname.hostname} zone={hostname.zone} "
|
||||||
f"ip={hostname.last_ipv6}"
|
f"ipv6={hostname.last_ipv6}"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
ipv6_exists = app.dns_service.query_record(
|
||||||
|
hostname.hostname, hostname.zone, "AAAA")
|
||||||
|
if ipv6_exists:
|
||||||
|
app.dns_service.delete_record(
|
||||||
|
hostname.hostname, hostname.zone, "AAAA")
|
||||||
|
ipv6_dns_deleted = True
|
||||||
|
hostname.last_ipv6 = None
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"DNS error: {e}")
|
||||||
|
logging.error(
|
||||||
|
f"Cleanup failed: hostname={hostname.hostname} "
|
||||||
|
f"zone={hostname.zone} type=AAAA"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if hostname.last_ipv4 == old_ipv4 and hostname.last_ipv6 == old_ipv6:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
if hostname.last_ipv4 is None and hostname.last_ipv6 is None:
|
||||||
|
hostname.delete_instance()
|
||||||
|
else:
|
||||||
|
hostname.save()
|
||||||
|
|
||||||
|
except DatabaseError as e:
|
||||||
|
logging.error(
|
||||||
|
f"DB operation failed after retries: hostname={hostname.hostname} "
|
||||||
|
f"zone={hostname.zone}: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Rollback: re-add DNS records that were deleted
|
||||||
|
if ipv4_dns_deleted:
|
||||||
try:
|
try:
|
||||||
app.dns_service.delete_record(hostname.hostname, hostname.zone, "AAAA")
|
app.dns_service.update_record(
|
||||||
ipv6_deleted = True
|
hostname.hostname, hostname.zone,
|
||||||
|
old_ipv4, hostname.dns_ttl)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(
|
logging.error(f"DNS rollback failed (A): {e}")
|
||||||
f"DNS delete failed: hostname={hostname.hostname} "
|
|
||||||
f"zone={hostname.zone} type=AAAA error={e}"
|
if ipv6_dns_deleted:
|
||||||
)
|
try:
|
||||||
|
app.dns_service.update_record(
|
||||||
|
hostname.hostname, hostname.zone,
|
||||||
|
old_ipv6, hostname.dns_ttl)
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"DNS rollback failed (AAAA): {e}")
|
||||||
|
|
||||||
|
continue
|
||||||
|
|
||||||
if app.email_service:
|
if app.email_service:
|
||||||
|
# Restore old IPs on in-memory model for email template
|
||||||
|
hostname.last_ipv4 = old_ipv4
|
||||||
|
hostname.last_ipv6 = old_ipv6
|
||||||
app.email_service.send_expiry_notification(
|
app.email_service.send_expiry_notification(
|
||||||
hostname.user.email,
|
hostname.user.email,
|
||||||
hostname,
|
hostname,
|
||||||
@@ -82,15 +137,7 @@ def cleanup_expired(app):
|
|||||||
ipv6_expired
|
ipv6_expired
|
||||||
)
|
)
|
||||||
|
|
||||||
# Clear IP addresses only if DNS delete succeeded
|
expired_count += 1
|
||||||
if ipv4_deleted:
|
|
||||||
hostname.last_ipv4 = None
|
|
||||||
if ipv6_deleted:
|
|
||||||
hostname.last_ipv6 = None
|
|
||||||
|
|
||||||
if ipv4_deleted or ipv6_deleted:
|
|
||||||
hostname.save()
|
|
||||||
expired_count += 1
|
|
||||||
|
|
||||||
return expired_count
|
return expired_count
|
||||||
|
|
||||||
@@ -113,10 +160,11 @@ class ExpiredRecordsCleanupThread(threading.Thread):
|
|||||||
def run(self):
|
def run(self):
|
||||||
"""Run the cleanup loop."""
|
"""Run the cleanup loop."""
|
||||||
logging.info(f"Expired records cleanup thread started: interval={self.interval}s")
|
logging.info(f"Expired records cleanup thread started: interval={self.interval}s")
|
||||||
|
start_time = now_utc()
|
||||||
|
|
||||||
while not self.stop_event.wait(self.interval):
|
while not self.stop_event.wait(self.interval):
|
||||||
try:
|
try:
|
||||||
count = cleanup_expired(self.app)
|
count = cleanup_expired(self.app, start_time)
|
||||||
if count > 0:
|
if count > 0:
|
||||||
logging.info(f"Expired records cleanup completed: count={count}")
|
logging.info(f"Expired records cleanup completed: count={count}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
+189
-115
@@ -1,23 +1,34 @@
|
|||||||
"""CLI commands for user and hostname management."""
|
"""CLI commands for user and hostname management."""
|
||||||
|
|
||||||
import getpass
|
import getpass
|
||||||
import logging
|
|
||||||
|
|
||||||
from . import datetime_str
|
from . import datetime_str
|
||||||
from .cleanup import cleanup_expired
|
from .cleanup import cleanup_expired
|
||||||
|
from .dns import encode_dnsname
|
||||||
from .models import (
|
from .models import (
|
||||||
|
DatabaseError,
|
||||||
DoesNotExist,
|
DoesNotExist,
|
||||||
|
EncodingError,
|
||||||
get_hostname,
|
get_hostname,
|
||||||
get_user,
|
get_user,
|
||||||
Hostname,
|
Hostname,
|
||||||
|
Permission,
|
||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
from .dns import encode_dnsname, EncodingError
|
|
||||||
|
|
||||||
|
def validate_password(password, confirm):
|
||||||
|
"""Validate password and confirmation match with min length."""
|
||||||
|
if password != confirm:
|
||||||
|
return "Error: Passwords do not match."
|
||||||
|
if len(password) < 8:
|
||||||
|
return "Error: Password must be at least 8 characters."
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def cmd_user_list(args, app):
|
def cmd_user_list(args, app):
|
||||||
"""List all users."""
|
"""List all users."""
|
||||||
users = User.select()
|
users = User.select().order_by(User.username)
|
||||||
if not users:
|
if not users:
|
||||||
print("No users found.")
|
print("No users found.")
|
||||||
return 0
|
return 0
|
||||||
@@ -51,12 +62,9 @@ def cmd_user_add(args, app):
|
|||||||
password = getpass.getpass("Password: ")
|
password = getpass.getpass("Password: ")
|
||||||
password_confirm = getpass.getpass("Confirm password: ")
|
password_confirm = getpass.getpass("Confirm password: ")
|
||||||
|
|
||||||
if password != password_confirm:
|
error = validate_password(password, password_confirm)
|
||||||
print("Error: Passwords do not match.")
|
if error:
|
||||||
return 1
|
print(error)
|
||||||
|
|
||||||
if len(password) < 8:
|
|
||||||
print("Error: Password must be at least 8 characters.")
|
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
# Hash password and create user
|
# Hash password and create user
|
||||||
@@ -100,12 +108,9 @@ def cmd_user_passwd(args, app):
|
|||||||
password = getpass.getpass("New password: ")
|
password = getpass.getpass("New password: ")
|
||||||
password_confirm = getpass.getpass("Confirm password: ")
|
password_confirm = getpass.getpass("Confirm password: ")
|
||||||
|
|
||||||
if password != password_confirm:
|
error = validate_password(password, password_confirm)
|
||||||
print("Error: Passwords do not match.")
|
if error:
|
||||||
return 1
|
print(error)
|
||||||
|
|
||||||
if len(password) < 8:
|
|
||||||
print("Error: Password must be at least 8 characters.")
|
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
user.password_hash = app.password_hasher.hash(password)
|
user.password_hash = app.password_hasher.hash(password)
|
||||||
@@ -133,7 +138,7 @@ def cmd_user_email(args, app):
|
|||||||
|
|
||||||
def cmd_hostname_list(args, app):
|
def cmd_hostname_list(args, app):
|
||||||
"""List hostnames."""
|
"""List hostnames."""
|
||||||
query = Hostname.select().join(User)
|
query = Hostname.select(Hostname, User.username).join(User)
|
||||||
|
|
||||||
if args.user:
|
if args.user:
|
||||||
try:
|
try:
|
||||||
@@ -143,13 +148,15 @@ def cmd_hostname_list(args, app):
|
|||||||
print(f"Error: User '{args.user}' not found.")
|
print(f"Error: User '{args.user}' not found.")
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
query = query.order_by(User.username, Hostname.hostname, Hostname.zone)
|
||||||
|
|
||||||
hostnames = list(query)
|
hostnames = list(query)
|
||||||
if not hostnames:
|
if not hostnames:
|
||||||
print("No hostnames found.")
|
print("No hostnames found.")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
print(
|
print(
|
||||||
f"\n{'Hostname':<35} {'User':<15} {'Zone':<20} "
|
f"\n{'Hostname':<35} {'Zone':<20} {'User':<15} "
|
||||||
f"{'DNS-TTL':<8} {'Exp-TTL':<8} {'Last-Update IPv4':<25} {'Last-Update IPv6'}"
|
f"{'DNS-TTL':<8} {'Exp-TTL':<8} {'Last-Update IPv4':<25} {'Last-Update IPv6'}"
|
||||||
)
|
)
|
||||||
print("-" * 140)
|
print("-" * 140)
|
||||||
@@ -157,132 +164,92 @@ def cmd_hostname_list(args, app):
|
|||||||
last_ipv4_update = datetime_str(h.last_ipv4_update)
|
last_ipv4_update = datetime_str(h.last_ipv4_update)
|
||||||
last_ipv6_update = datetime_str(h.last_ipv6_update)
|
last_ipv6_update = datetime_str(h.last_ipv6_update)
|
||||||
print(
|
print(
|
||||||
f"{h.hostname:<35} {h.user.username:<15} {h.zone:<20} "
|
f"{h.hostname:<35} {h.zone:<20} {h.user.username:<15} "
|
||||||
f"{h.dns_ttl:<8} {h.expiry_ttl:<8} {last_ipv4_update:<25} {last_ipv6_update}"
|
f"{h.dns_ttl:<8} {h.expiry_ttl:<8} {last_ipv4_update:<25} {last_ipv6_update}"
|
||||||
)
|
)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def cmd_hostname_add(args, app):
|
|
||||||
"""Add a hostname."""
|
|
||||||
username = args.username
|
|
||||||
config = app.config
|
|
||||||
|
|
||||||
# Validate and encode hostname/zone
|
|
||||||
try:
|
|
||||||
hostname_str = encode_dnsname(args.hostname)
|
|
||||||
zone = encode_dnsname(args.zone)
|
|
||||||
except EncodingError as e:
|
|
||||||
print(f"Error: {e}")
|
|
||||||
return 1
|
|
||||||
|
|
||||||
# Get TTLs from args or config defaults
|
|
||||||
dns_ttl = args.dns_ttl
|
|
||||||
if dns_ttl is None:
|
|
||||||
dns_ttl = config["defaults"]["dns_ttl"]
|
|
||||||
expiry_ttl = args.expiry_ttl
|
|
||||||
if expiry_ttl is None:
|
|
||||||
expiry_ttl = config["defaults"]["expiry_ttl"]
|
|
||||||
|
|
||||||
# Get user
|
|
||||||
try:
|
|
||||||
user = get_user(username)
|
|
||||||
except DoesNotExist:
|
|
||||||
print(f"Error: User '{username}' not found.")
|
|
||||||
return 1
|
|
||||||
|
|
||||||
# Check if hostname+zone exists
|
|
||||||
if Hostname.select().where(
|
|
||||||
(Hostname.hostname == hostname_str) & (Hostname.zone == zone)
|
|
||||||
).exists():
|
|
||||||
print(f"Error: Hostname '{hostname_str}' in zone '{zone}' exists.")
|
|
||||||
return 1
|
|
||||||
|
|
||||||
# Create hostname
|
|
||||||
Hostname.create(
|
|
||||||
user=user,
|
|
||||||
hostname=hostname_str,
|
|
||||||
zone=zone,
|
|
||||||
dns_ttl=dns_ttl,
|
|
||||||
expiry_ttl=expiry_ttl
|
|
||||||
)
|
|
||||||
print(f"Hostname '{hostname_str}' added for user '{username}'.")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_hostname_delete(args, app):
|
def cmd_hostname_delete(args, app):
|
||||||
"""Delete a hostname."""
|
"""Delete a hostname."""
|
||||||
# Validate and encode hostname and zone
|
|
||||||
try:
|
try:
|
||||||
hostname_str = encode_dnsname(args.hostname)
|
try:
|
||||||
zone = encode_dnsname(args.zone)
|
hostname = get_hostname(args.hostname, args.zone)
|
||||||
except EncodingError as e:
|
except DoesNotExist:
|
||||||
print(f"Error: {e}")
|
hostname = encode_dnsname(args.hostname)
|
||||||
return 1
|
zone = encode_dnsname(args.zone)
|
||||||
|
print(f"Error: Hostname '{hostname}' in zone '{zone}' not found.")
|
||||||
|
return 1
|
||||||
|
except EncodingError as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
return 1
|
||||||
|
|
||||||
try:
|
# Delete DNS records if active
|
||||||
hostname = get_hostname(hostname_str, zone)
|
if hostname.last_ipv4 or hostname.last_ipv6:
|
||||||
except DoesNotExist:
|
# Initialize DNS service if not already
|
||||||
print(f"Error: Hostname '{hostname_str}' in zone '{zone}' not found.")
|
if app.dns_service is None:
|
||||||
return 1
|
try:
|
||||||
|
app.init_dns()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"DNS init failed: {e}")
|
||||||
|
return 1
|
||||||
|
|
||||||
# Delete DNS records if active
|
|
||||||
if hostname.last_ipv4 or hostname.last_ipv6:
|
|
||||||
# Initialize DNS service if not already
|
|
||||||
if app.dns_service is None:
|
|
||||||
try:
|
|
||||||
app.init_dns()
|
|
||||||
except Exception as e:
|
|
||||||
logging.warning(f"DNS init failed: {e}")
|
|
||||||
|
|
||||||
if app.dns_service:
|
|
||||||
if hostname.last_ipv4:
|
if hostname.last_ipv4:
|
||||||
try:
|
try:
|
||||||
app.dns_service.delete_record(
|
app.dns_service.delete_record(
|
||||||
hostname.hostname, hostname.zone, "A"
|
hostname.hostname, hostname.zone, "A"
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.warning(f"DNS delete failed: type=A error={e}")
|
print(f"DNS delete failed: type=A error={e}")
|
||||||
|
return 1
|
||||||
|
|
||||||
if hostname.last_ipv6:
|
if hostname.last_ipv6:
|
||||||
try:
|
try:
|
||||||
app.dns_service.delete_record(
|
app.dns_service.delete_record(
|
||||||
hostname.hostname, hostname.zone, "AAAA"
|
hostname.hostname, hostname.zone, "AAAA"
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.warning(f"DNS delete failed: type=AAAA error={e}")
|
print(f"DNS delete failed: type=AAAA error={e}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
hostname.delete_instance()
|
||||||
|
print(f"Hostname '{hostname.hostname}' in zone '{hostname.zone}' deleted.")
|
||||||
|
except DatabaseError as e:
|
||||||
|
print(f"Database error: {e}")
|
||||||
|
return 1
|
||||||
|
|
||||||
hostname.delete_instance()
|
|
||||||
print(f"Hostname '{hostname_str}' in zone '{zone}' deleted.")
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def cmd_hostname_modify(args, app):
|
def cmd_hostname_modify(args, app):
|
||||||
"""Modify hostname settings."""
|
"""Modify hostname settings."""
|
||||||
# Validate and encode hostname and zone
|
|
||||||
try:
|
try:
|
||||||
hostname_str = encode_dnsname(args.hostname)
|
try:
|
||||||
zone = encode_dnsname(args.zone)
|
hostname = get_hostname(args.hostname, args.zone)
|
||||||
except EncodingError as e:
|
except DoesNotExist:
|
||||||
print(f"Error: {e}")
|
hostname = encode_dnsname(args.hostname)
|
||||||
|
zone = encode_dnsname(args.zone)
|
||||||
|
print(f"Error: Hostname '{hostname}' in zone '{zone}' not found.")
|
||||||
|
return 1
|
||||||
|
except EncodingError as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Get new TTLs
|
||||||
|
dns_ttl = args.dns_ttl if args.dns_ttl is not None else hostname.dns_ttl
|
||||||
|
expiry_ttl = args.expiry_ttl if args.expiry_ttl is not None else hostname.expiry_ttl
|
||||||
|
|
||||||
|
hostname.dns_ttl = dns_ttl
|
||||||
|
hostname.expiry_ttl = expiry_ttl
|
||||||
|
hostname.save()
|
||||||
|
print(
|
||||||
|
f"Hostname '{hostname.hostname}' in zone '{hostname.zone}' updated: "
|
||||||
|
f"dns_ttl={dns_ttl}, expiry_ttl={expiry_ttl}"
|
||||||
|
)
|
||||||
|
except DatabaseError as e:
|
||||||
|
print(f"Database error: {e}")
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
try:
|
|
||||||
hostname = get_hostname(hostname_str, zone)
|
|
||||||
except DoesNotExist:
|
|
||||||
print(f"Error: Hostname '{hostname_str}' in zone '{zone}' not found.")
|
|
||||||
return 1
|
|
||||||
|
|
||||||
# Get new TTLs
|
|
||||||
dns_ttl = args.dns_ttl if args.dns_ttl is not None else hostname.dns_ttl
|
|
||||||
expiry_ttl = args.expiry_ttl if args.expiry_ttl is not None else hostname.expiry_ttl
|
|
||||||
|
|
||||||
hostname.dns_ttl = dns_ttl
|
|
||||||
hostname.expiry_ttl = expiry_ttl
|
|
||||||
hostname.save()
|
|
||||||
print(
|
|
||||||
f"Hostname '{hostname_str}' updated: "
|
|
||||||
f"dns_ttl={dns_ttl}, expiry_ttl={expiry_ttl}"
|
|
||||||
)
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
@@ -293,11 +260,118 @@ def cmd_cleanup(args, app):
|
|||||||
try:
|
try:
|
||||||
app.init_dns()
|
app.init_dns()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.warning(f"DNS init failed: {e}")
|
print(f"DNS init failed: {e}")
|
||||||
|
return 1
|
||||||
|
|
||||||
if app.email_service is None:
|
if app.email_service is None:
|
||||||
app.init_email()
|
app.init_email()
|
||||||
|
|
||||||
count = cleanup_expired(app)
|
try:
|
||||||
print(f"Cleanup complete: {count} expired hostname(s) processed.")
|
count = cleanup_expired(app)
|
||||||
|
print(f"Cleanup complete: {count} expired hostname(s) processed.")
|
||||||
|
except DatabaseError as e:
|
||||||
|
print(f"Database error: {e}")
|
||||||
|
return 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_permission_list(args, app):
|
||||||
|
"""List permissions."""
|
||||||
|
query = Permission.select(Permission, User.username).join(User)
|
||||||
|
|
||||||
|
if args.user:
|
||||||
|
try:
|
||||||
|
user = get_user(args.user)
|
||||||
|
query = query.where(Permission.user == user)
|
||||||
|
except DoesNotExist:
|
||||||
|
print(f"Error: User '{args.user}' not found.")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
query = query.order_by(
|
||||||
|
User.username, Permission.zone, Permission.hostname_pattern)
|
||||||
|
|
||||||
|
permissions = list(query)
|
||||||
|
if not permissions:
|
||||||
|
print("No permissions found.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
print(f"\n{'User':<20} {'Pattern':<30} {'Zone':<30}")
|
||||||
|
print("-" * 80)
|
||||||
|
for p in permissions:
|
||||||
|
print(f"{p.user.username:<20} {p.hostname_pattern:<30} {p.zone:<30}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_permission_add(args, app):
|
||||||
|
"""Add a permission."""
|
||||||
|
username = args.username
|
||||||
|
pattern = args.hostname_pattern
|
||||||
|
zone = args.zone
|
||||||
|
|
||||||
|
try:
|
||||||
|
user = get_user(username)
|
||||||
|
except DoesNotExist:
|
||||||
|
print(f"Error: User '{username}' not found.")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Validate pattern
|
||||||
|
if pattern != '*' and not pattern.startswith('*.') and '*' in pattern:
|
||||||
|
print("Error: Invalid pattern. Use '*', '*.suffix', or exact.")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Check if permission exists
|
||||||
|
exists = Permission.select().where(
|
||||||
|
(Permission.user == user) &
|
||||||
|
(Permission.hostname_pattern == pattern) &
|
||||||
|
(Permission.zone == zone)
|
||||||
|
).exists()
|
||||||
|
if exists:
|
||||||
|
print("Error: Permission already exists.")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
Permission.create(
|
||||||
|
user=user,
|
||||||
|
hostname_pattern=pattern,
|
||||||
|
zone=zone
|
||||||
|
)
|
||||||
|
print(f"Permission added: {username} {pattern} {zone}")
|
||||||
|
except DatabaseError as e:
|
||||||
|
print(f"Database error: {e}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_permission_delete(args, app):
|
||||||
|
"""Delete a permission."""
|
||||||
|
username = args.username
|
||||||
|
pattern = args.hostname_pattern
|
||||||
|
zone = args.zone
|
||||||
|
|
||||||
|
try:
|
||||||
|
user = get_user(username)
|
||||||
|
except DoesNotExist:
|
||||||
|
print(f"Error: User '{username}' not found.")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
perm = Permission.get(
|
||||||
|
(Permission.user == user) &
|
||||||
|
(Permission.hostname_pattern == pattern) &
|
||||||
|
(Permission.zone == zone)
|
||||||
|
)
|
||||||
|
perm.delete_instance()
|
||||||
|
print(f"Permission deleted: {username} {pattern} {zone}")
|
||||||
|
except DoesNotExist:
|
||||||
|
print("Error: Permission not found.")
|
||||||
|
return 1
|
||||||
|
except DatabaseError as e:
|
||||||
|
print(f"Database error: {e}")
|
||||||
|
return 1
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -141,9 +142,12 @@ def load_config(config_path):
|
|||||||
cfg["daemon"].setdefault("ssl", False)
|
cfg["daemon"].setdefault("ssl", False)
|
||||||
cfg["daemon"].setdefault("proxy_header", "")
|
cfg["daemon"].setdefault("proxy_header", "")
|
||||||
cfg["daemon"].setdefault("trusted_proxies", [])
|
cfg["daemon"].setdefault("trusted_proxies", [])
|
||||||
|
cfg["daemon"].setdefault("thread_pool_size", 10)
|
||||||
|
cfg["daemon"].setdefault("request_timeout", 10)
|
||||||
|
|
||||||
cfg.setdefault("database", {})
|
cfg.setdefault("database", {})
|
||||||
cfg["database"].setdefault("backend", "sqlite")
|
cfg["database"].setdefault("backend", "sqlite")
|
||||||
|
cfg["database"].setdefault("pool_size", 5)
|
||||||
|
|
||||||
cfg.setdefault("dns_service", {})
|
cfg.setdefault("dns_service", {})
|
||||||
cfg["dns_service"].setdefault("dns_server", "127.0.0.1")
|
cfg["dns_service"].setdefault("dns_server", "127.0.0.1")
|
||||||
@@ -175,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)
|
||||||
|
|||||||
+79
-6
@@ -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
|
||||||
@@ -37,6 +42,12 @@ def encode_dnsname(hostname):
|
|||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
EncodingError: If hostname is invalid.
|
EncodingError: If hostname is invalid.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> encode_dnsname("münchen")
|
||||||
|
'xn--mnchen-3ya'
|
||||||
|
>>> encode_dnsname("example.com.")
|
||||||
|
'example.com'
|
||||||
"""
|
"""
|
||||||
hostname = hostname.lower().strip()
|
hostname = hostname.lower().strip()
|
||||||
|
|
||||||
@@ -47,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
|
||||||
@@ -65,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):
|
||||||
@@ -84,6 +96,24 @@ def encode_dnsname(hostname):
|
|||||||
|
|
||||||
|
|
||||||
def detect_ip_type(ip):
|
def detect_ip_type(ip):
|
||||||
|
"""
|
||||||
|
Detect IP address type and normalize.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ip: IP address string.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (record_type, normalized_ip).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If IP address is invalid.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> detect_ip_type("192.168.1.1")
|
||||||
|
('A', '192.168.1.1')
|
||||||
|
>>> detect_ip_type("2001:db8::1")
|
||||||
|
('AAAA', '2001:db8::1')
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
addr = ipaddress.ip_address(ip)
|
addr = ipaddress.ip_address(ip)
|
||||||
if isinstance(addr, ipaddress.IPv4Address):
|
if isinstance(addr, ipaddress.IPv4Address):
|
||||||
@@ -127,6 +157,16 @@ def parse_bind_key_file(path):
|
|||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
DNSError: If parsing fails.
|
DNSError: If parsing fails.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
Key file contents::
|
||||||
|
|
||||||
|
key "ddns-key." {
|
||||||
|
algorithm hmac-sha256;
|
||||||
|
secret "base64secret==";
|
||||||
|
};
|
||||||
|
|
||||||
|
>>> keyring, algo = parse_bind_key_file("/etc/bind/ddns.key")
|
||||||
"""
|
"""
|
||||||
if not path:
|
if not path:
|
||||||
return None, None
|
return None, None
|
||||||
@@ -174,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:
|
||||||
@@ -319,18 +359,51 @@ 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.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
hostname: Fully qualified hostname.
|
hostname: Hostname (without zone suffix).
|
||||||
zone: DNS zone name.
|
zone: DNS zone name.
|
||||||
ip: IP address to set.
|
ip: IP address to set.
|
||||||
ttl: DNS record TTL.
|
ttl: DNS record TTL.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
DNSError: If update fails.
|
DNSError: If update fails.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> dns_service.update_record("myhost", "example.com", "192.168.1.1", 60)
|
||||||
|
>>> dns_service.update_record("myhost", "example.com", "2001:db8::1", 60)
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
record_type, normalized_ip = detect_ip_type(ip)
|
record_type, normalized_ip = detect_ip_type(ip)
|
||||||
|
|||||||
@@ -76,11 +76,10 @@ class EmailService:
|
|||||||
smtp_host = self.config["smtp_host"]
|
smtp_host = self.config["smtp_host"]
|
||||||
smtp_port = self.config["smtp_port"]
|
smtp_port = self.config["smtp_port"]
|
||||||
|
|
||||||
server = smtplib.SMTP(smtp_host, smtp_port)
|
with smtplib.SMTP(smtp_host, smtp_port) as server:
|
||||||
if self.config.get("smtp_starttls", False):
|
if self.config.get("smtp_starttls", False):
|
||||||
server.starttls()
|
server.starttls()
|
||||||
|
|
||||||
try:
|
|
||||||
if self.config.get("smtp_user"):
|
if self.config.get("smtp_user"):
|
||||||
server.login(
|
server.login(
|
||||||
self.config["smtp_user"],
|
self.config["smtp_user"],
|
||||||
@@ -89,8 +88,6 @@ class EmailService:
|
|||||||
server.sendmail(msg["From"], [to], msg.as_string())
|
server.sendmail(msg["From"], [to], msg.as_string())
|
||||||
logging.info(f"Email sent: to={to} subject={subject}")
|
logging.info(f"Email sent: to={to} subject={subject}")
|
||||||
return True
|
return True
|
||||||
finally:
|
|
||||||
server.quit()
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Email send failed: to={to} error={e}")
|
logging.error(f"Email send failed: to={to} error={e}")
|
||||||
@@ -101,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.
|
||||||
@@ -134,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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
|
|||||||
+30
-24
@@ -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,22 +152,14 @@ 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)
|
app = Application(config, config_path)
|
||||||
|
|
||||||
# Initialize database
|
# Initialize database
|
||||||
try:
|
try:
|
||||||
|
|||||||
+207
-48
@@ -2,28 +2,55 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
from . import utc_now
|
from . import datetime_naive_utc, datetime_aware_utc, now_utc
|
||||||
|
from .dns import encode_dnsname, EncodingError
|
||||||
from peewee import (
|
from peewee import (
|
||||||
AutoField,
|
AutoField,
|
||||||
CharField,
|
CharField,
|
||||||
|
DatabaseProxy,
|
||||||
|
Model,
|
||||||
DateTimeField,
|
DateTimeField,
|
||||||
DoesNotExist,
|
DoesNotExist,
|
||||||
ForeignKeyField,
|
ForeignKeyField,
|
||||||
IntegerField,
|
IntegerField,
|
||||||
Model,
|
|
||||||
MySQLDatabase,
|
|
||||||
SqliteDatabase,
|
SqliteDatabase,
|
||||||
|
PeeweeException as DatabaseError,
|
||||||
)
|
)
|
||||||
|
from playhouse.pool import PooledMySQLDatabase
|
||||||
|
|
||||||
# Database instance (initialized later)
|
|
||||||
db = SqliteDatabase(None)
|
# Re-export PeeweeException as DatabaseError, DoesNotExist and
|
||||||
|
# EncodingError for convenience
|
||||||
|
__all__ = [
|
||||||
|
'db',
|
||||||
|
'DATABASE_VERSION',
|
||||||
|
'User',
|
||||||
|
'Hostname',
|
||||||
|
'Permission',
|
||||||
|
'Version',
|
||||||
|
'init_database',
|
||||||
|
'create_tables',
|
||||||
|
'get_hostname',
|
||||||
|
'get_hostname_for_user',
|
||||||
|
'get_permission',
|
||||||
|
'get_user',
|
||||||
|
'close_database',
|
||||||
|
'DoesNotExist',
|
||||||
|
'EncodingError',
|
||||||
|
'DatabaseError',
|
||||||
|
]
|
||||||
|
|
||||||
|
# Database proxy (initialized later with actual backend)
|
||||||
|
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': {
|
||||||
@@ -38,16 +65,39 @@ MIGRATION_COLUMN_MAPS = {
|
|||||||
'last_ipv6': 'last_ipv6',
|
'last_ipv6': 'last_ipv6',
|
||||||
'last_ipv6_update': 'last_ipv6_update',
|
'last_ipv6_update': 'last_ipv6_update',
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
3: {} # New permissions table, populated from hostnames
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class DateTimeFieldUTC(DateTimeField):
|
||||||
|
def db_value(self, value):
|
||||||
|
if value:
|
||||||
|
value = datetime_naive_utc(value)
|
||||||
|
return super().db_value(value)
|
||||||
|
|
||||||
|
def python_value(self, value):
|
||||||
|
value = super().python_value(value)
|
||||||
|
if value:
|
||||||
|
return datetime_aware_utc(value)
|
||||||
|
|
||||||
|
|
||||||
class BaseModel(Model):
|
class BaseModel(Model):
|
||||||
"""Base model with database binding."""
|
"""Base model with database binding and save retry."""
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
database = db
|
database = db
|
||||||
|
|
||||||
|
def save(self, *args, max_retries=3, retry_delay=0.1, **kwargs):
|
||||||
|
"""Save with retry on DatabaseError (exponential backoff)."""
|
||||||
|
for attempt in range(max_retries):
|
||||||
|
try:
|
||||||
|
return super().save(*args, **kwargs)
|
||||||
|
except DatabaseError:
|
||||||
|
if attempt == max_retries - 1:
|
||||||
|
raise
|
||||||
|
time.sleep(retry_delay * (2 ** attempt))
|
||||||
|
|
||||||
|
|
||||||
class User(BaseModel):
|
class User(BaseModel):
|
||||||
"""User model for authentication."""
|
"""User model for authentication."""
|
||||||
@@ -56,7 +106,7 @@ class User(BaseModel):
|
|||||||
username = CharField(max_length=64, unique=True)
|
username = CharField(max_length=64, unique=True)
|
||||||
password_hash = CharField(max_length=128)
|
password_hash = CharField(max_length=128)
|
||||||
email = CharField(max_length=255)
|
email = CharField(max_length=255)
|
||||||
created_at = DateTimeField(default=utc_now)
|
created_at = DateTimeFieldUTC(default=now_utc)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
table_name = "users"
|
table_name = "users"
|
||||||
@@ -72,9 +122,9 @@ class Hostname(BaseModel):
|
|||||||
dns_ttl = IntegerField()
|
dns_ttl = IntegerField()
|
||||||
expiry_ttl = IntegerField()
|
expiry_ttl = IntegerField()
|
||||||
last_ipv4 = CharField(max_length=15, null=True)
|
last_ipv4 = CharField(max_length=15, null=True)
|
||||||
last_ipv4_update = DateTimeField(null=True)
|
last_ipv4_update = DateTimeFieldUTC(null=True)
|
||||||
last_ipv6 = CharField(max_length=45, null=True)
|
last_ipv6 = CharField(max_length=45, null=True)
|
||||||
last_ipv6_update = DateTimeField(null=True)
|
last_ipv6_update = DateTimeFieldUTC(null=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
table_name = "hostnames"
|
table_name = "hostnames"
|
||||||
@@ -83,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."""
|
||||||
|
|
||||||
@@ -96,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.
|
||||||
|
|
||||||
@@ -109,7 +176,6 @@ def init_database(config: dict):
|
|||||||
Raises:
|
Raises:
|
||||||
ValueError: If unknown database backend.
|
ValueError: If unknown database backend.
|
||||||
"""
|
"""
|
||||||
global db
|
|
||||||
|
|
||||||
backend = config["database"].get("backend", "sqlite")
|
backend = config["database"].get("backend", "sqlite")
|
||||||
|
|
||||||
@@ -118,21 +184,24 @@ 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)
|
||||||
db.init(db_path)
|
actual_db = SqliteDatabase(db_path, pragmas={
|
||||||
|
'journal_mode': 'wal',
|
||||||
|
'busy_timeout': 5000,
|
||||||
|
'foreign_keys': 1,
|
||||||
|
})
|
||||||
|
db.initialize(actual_db)
|
||||||
logging.debug(f"Database backend: SQLite path={db_path}")
|
logging.debug(f"Database backend: SQLite path={db_path}")
|
||||||
|
|
||||||
elif backend == "mariadb":
|
elif backend == "mariadb":
|
||||||
db = MySQLDatabase(
|
actual_db = PooledMySQLDatabase(
|
||||||
config["database"]["database"],
|
config["database"]["database"],
|
||||||
host=config["database"].get("host", "localhost"),
|
host=config["database"].get("host", "localhost"),
|
||||||
port=config["database"].get("port", 3306),
|
port=config["database"].get("port", 3306),
|
||||||
user=config["database"]["user"],
|
user=config["database"]["user"],
|
||||||
password=config["database"]["password"],
|
password=config["database"]["password"],
|
||||||
|
max_connections=config["database"].get("pool_size", 5),
|
||||||
)
|
)
|
||||||
# Re-bind models to new database
|
db.initialize(actual_db)
|
||||||
User._meta.database = db
|
|
||||||
Hostname._meta.database = db
|
|
||||||
Version._meta.database = db
|
|
||||||
db_name = config['database']['database']
|
db_name = config['database']['database']
|
||||||
logging.debug(f"Database backend: MariaDB db={db_name}")
|
logging.debug(f"Database backend: MariaDB db={db_name}")
|
||||||
|
|
||||||
@@ -142,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.
|
||||||
|
|
||||||
@@ -189,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]
|
||||||
@@ -201,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')
|
||||||
@@ -209,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():
|
||||||
@@ -243,12 +328,12 @@ def create_tables():
|
|||||||
check_and_migrate()
|
check_and_migrate()
|
||||||
return
|
return
|
||||||
|
|
||||||
db.create_tables([User, Hostname, Version])
|
db.create_tables([User, Hostname, Permission, Version])
|
||||||
Version.create(version=DATABASE_VERSION)
|
Version.create(version=DATABASE_VERSION)
|
||||||
logging.debug("Database tables created")
|
logging.debug("Database tables created")
|
||||||
|
|
||||||
|
|
||||||
def get_user(username: str):
|
def get_user(username):
|
||||||
"""
|
"""
|
||||||
Get user by username.
|
Get user by username.
|
||||||
|
|
||||||
@@ -260,10 +345,61 @@ def get_user(username: str):
|
|||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
DoesNotExist: If user not found.
|
DoesNotExist: If user not found.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> user = get_user("alice")
|
||||||
|
>>> print(user.email)
|
||||||
|
'alice@example.com'
|
||||||
"""
|
"""
|
||||||
return User.get(User.username == username)
|
return User.get(User.username == username)
|
||||||
|
|
||||||
|
|
||||||
|
def get_permission(user, fqdn):
|
||||||
|
"""
|
||||||
|
Get permission for user to access FQDN.
|
||||||
|
Patterns:
|
||||||
|
'*' - matches any hostname
|
||||||
|
'*.suffix' - matches hostname ends with .suffix
|
||||||
|
'exact' - matches hostname == pattern
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User to check permission for.
|
||||||
|
fqdn: Full qualified domain name.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Permission instance.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
DoesNotExist: If permission not found.
|
||||||
|
EncodingError: If fqdn is invalid.
|
||||||
|
"""
|
||||||
|
|
||||||
|
fqdn = encode_dnsname(fqdn)
|
||||||
|
|
||||||
|
permissions = Permission.select().where(Permission.user == user)
|
||||||
|
|
||||||
|
for perm in permissions:
|
||||||
|
if not fqdn.endswith(f".{perm.zone}"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
pattern = perm.hostname_pattern
|
||||||
|
|
||||||
|
if pattern == '*':
|
||||||
|
return perm
|
||||||
|
|
||||||
|
hostname = fqdn.removesuffix(f".{perm.zone}")
|
||||||
|
|
||||||
|
if pattern.startswith('*.'):
|
||||||
|
suffix = pattern[1:] # Remove '*'
|
||||||
|
if hostname.endswith(suffix):
|
||||||
|
return perm
|
||||||
|
|
||||||
|
elif hostname == pattern:
|
||||||
|
return perm
|
||||||
|
|
||||||
|
raise DoesNotExist
|
||||||
|
|
||||||
|
|
||||||
def get_hostname(hostname, zone):
|
def get_hostname(hostname, zone):
|
||||||
"""
|
"""
|
||||||
Get hostname by name and zone.
|
Get hostname by name and zone.
|
||||||
@@ -277,41 +413,64 @@ def get_hostname(hostname, zone):
|
|||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
DoesNotExist: If hostname not found.
|
DoesNotExist: If hostname not found.
|
||||||
|
EncodingError: If hostname or zone is invalid.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> host = get_hostname("myhost", "example.com")
|
||||||
|
>>> print(host.last_ipv4)
|
||||||
|
'192.168.1.1'
|
||||||
"""
|
"""
|
||||||
return Hostname.get(
|
return Hostname.get(
|
||||||
(Hostname.hostname == hostname) & (Hostname.zone == zone)
|
(Hostname.hostname == encode_dnsname(hostname)) &
|
||||||
|
(Hostname.zone == encode_dnsname(zone))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_hostname_for_user(hostname: str, user: User):
|
def get_hostname_for_user(
|
||||||
|
user: User, hostname: str, zone: str, dns_ttl: int, expiry_ttl: int):
|
||||||
"""
|
"""
|
||||||
Get hostname owned by specific user.
|
Get hostname if it exists or create a new instance.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
hostname: Hostname to look up.
|
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:
|
Example:
|
||||||
DoesNotExist: If hostname not found or not owned by user.
|
>>> host, created = get_hostname_or_create(
|
||||||
|
... user, 'myhost', 'example.com', 60, 3600)
|
||||||
"""
|
"""
|
||||||
fqdn = Hostname.hostname + '.' + Hostname.zone
|
try:
|
||||||
return Hostname.get((fqdn == hostname) & (Hostname.user == user))
|
return (
|
||||||
|
Hostname.get(
|
||||||
|
(Hostname.user == user) &
|
||||||
|
(Hostname.hostname == hostname) &
|
||||||
|
(Hostname.zone == zone)
|
||||||
|
),
|
||||||
|
False
|
||||||
|
)
|
||||||
|
except DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return (
|
||||||
|
Hostname(
|
||||||
|
user=user,
|
||||||
|
hostname=hostname,
|
||||||
|
zone=zone,
|
||||||
|
dns_ttl=dns_ttl,
|
||||||
|
expiry_ttl=expiry_ttl,
|
||||||
|
),
|
||||||
|
True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Re-export DoesNotExist for convenience
|
def close_database():
|
||||||
__all__ = [
|
"""Close database connection."""
|
||||||
'db',
|
if not db.is_closed():
|
||||||
'DATABASE_VERSION',
|
db.close()
|
||||||
'User',
|
logging.debug("Database connection closed")
|
||||||
'Hostname',
|
|
||||||
'Version',
|
|
||||||
'init_database',
|
|
||||||
'create_tables',
|
|
||||||
'get_user',
|
|
||||||
'get_hostname',
|
|
||||||
'get_hostname_for_user',
|
|
||||||
'DoesNotExist',
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -85,11 +85,11 @@ class GoodLimiter(BaseLimiter):
|
|||||||
Args:
|
Args:
|
||||||
config: Full configuration dictionary.
|
config: Full configuration dictionary.
|
||||||
"""
|
"""
|
||||||
rl = config.get("rate_limit", {})
|
rl = config["rate_limit"]
|
||||||
super().__init__(
|
super().__init__(
|
||||||
rl.get("good_window_seconds", 60),
|
rl["good_window_seconds"],
|
||||||
rl.get("good_max_requests", 30),
|
rl["good_max_requests"],
|
||||||
rl.get("enabled", False),
|
rl["enabled"],
|
||||||
False,
|
False,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -104,10 +104,10 @@ class BadLimiter(BaseLimiter):
|
|||||||
Args:
|
Args:
|
||||||
config: Full configuration dictionary.
|
config: Full configuration dictionary.
|
||||||
"""
|
"""
|
||||||
rl = config.get("rate_limit", {})
|
rl = config["rate_limit"]
|
||||||
super().__init__(
|
super().__init__(
|
||||||
rl.get("bad_window_seconds", 60),
|
rl["bad_window_seconds"],
|
||||||
rl.get("bad_max_requests", 5),
|
rl["bad_max_requests"],
|
||||||
rl.get("enabled", False),
|
rl["enabled"],
|
||||||
True,
|
True,
|
||||||
)
|
)
|
||||||
|
|||||||
+474
-180
@@ -7,17 +7,40 @@ import ipaddress
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import signal
|
import signal
|
||||||
|
import socket
|
||||||
import ssl
|
import ssl
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from . import (
|
||||||
|
now_utc,
|
||||||
|
datetime_str,
|
||||||
|
STATUS_GOOD,
|
||||||
|
STATUS_NOCHG,
|
||||||
|
STATUS_BADAUTH,
|
||||||
|
STATUS_NOHOST,
|
||||||
|
STATUS_DNSERR,
|
||||||
|
STATUS_ABUSE,
|
||||||
|
STATUS_BADIP,
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
get_hostname_for_user,
|
||||||
|
get_user,
|
||||||
|
get_permission,
|
||||||
|
)
|
||||||
|
from argon2.exceptions import VerifyMismatchError
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
from urllib.parse import parse_qs, urlparse
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
import argon2
|
# Graceful shutdown timeout (seconds)
|
||||||
|
SHUTDOWN_TIMEOUT = 5
|
||||||
from . import datetime_str, utc_now
|
|
||||||
from .cleanup import ExpiredRecordsCleanupThread, RateLimitCleanupThread
|
|
||||||
from .logging import clear_txn_id, set_txn_id
|
|
||||||
from .models import DoesNotExist, get_hostname_for_user, get_user
|
|
||||||
from .dns import detect_ip_type, encode_dnsname, EncodingError
|
|
||||||
|
|
||||||
|
|
||||||
def extract_param(params, aliases):
|
def extract_param(params, aliases):
|
||||||
@@ -64,7 +87,7 @@ def _is_trusted_proxy(client_ip, trusted_networks):
|
|||||||
|
|
||||||
|
|
||||||
class DDNSServer(ThreadingHTTPServer):
|
class DDNSServer(ThreadingHTTPServer):
|
||||||
"""HTTP server with Application instance."""
|
"""HTTP server with Application instance and thread pool."""
|
||||||
|
|
||||||
def __init__(self, app, address):
|
def __init__(self, app, address):
|
||||||
"""
|
"""
|
||||||
@@ -79,8 +102,71 @@ class DDNSServer(ThreadingHTTPServer):
|
|||||||
self.trusted_networks = _parse_trusted_proxies(
|
self.trusted_networks = _parse_trusted_proxies(
|
||||||
app.config["daemon"].get("trusted_proxies", [])
|
app.config["daemon"].get("trusted_proxies", [])
|
||||||
)
|
)
|
||||||
|
self.pool_size = app.config["daemon"]["thread_pool_size"]
|
||||||
|
self.request_timeout = app.config["daemon"]["request_timeout"]
|
||||||
|
self.executor = ThreadPoolExecutor(max_workers=self.pool_size)
|
||||||
|
self.active_requests = 0
|
||||||
|
self.requests_lock = threading.Lock()
|
||||||
|
self.requests_done = threading.Condition(self.requests_lock)
|
||||||
super().__init__(address, DDNSRequestHandler)
|
super().__init__(address, DDNSRequestHandler)
|
||||||
|
|
||||||
|
def process_request(self, request, client_address):
|
||||||
|
"""Submit request to thread pool."""
|
||||||
|
with self.requests_lock:
|
||||||
|
self.active_requests += 1
|
||||||
|
request.settimeout(self.request_timeout)
|
||||||
|
self.executor.submit(self._handle_request_wrapper, request, client_address)
|
||||||
|
|
||||||
|
def _handle_request_wrapper(self, request, client_address):
|
||||||
|
"""Wrap request handling to track active requests."""
|
||||||
|
try:
|
||||||
|
self.process_request_thread(request, client_address)
|
||||||
|
finally:
|
||||||
|
with self.requests_lock:
|
||||||
|
self.active_requests -= 1
|
||||||
|
if self.active_requests == 0:
|
||||||
|
self.requests_done.notify_all()
|
||||||
|
|
||||||
|
def wait_for_requests(self, timeout=5):
|
||||||
|
"""Wait for active requests to complete."""
|
||||||
|
with self.requests_lock:
|
||||||
|
if self.active_requests > 0:
|
||||||
|
logging.info(f"Waiting for {self.active_requests} active request(s)")
|
||||||
|
self.requests_done.wait(timeout=timeout)
|
||||||
|
if self.active_requests > 0:
|
||||||
|
logging.warning(
|
||||||
|
f"Shutdown timeout, {self.active_requests} request(s) still active"
|
||||||
|
)
|
||||||
|
|
||||||
|
def server_close(self):
|
||||||
|
"""Shutdown thread pool and close server."""
|
||||||
|
self.executor.shutdown(wait=True)
|
||||||
|
super().server_close()
|
||||||
|
|
||||||
|
|
||||||
|
class DDNSError(Exception):
|
||||||
|
def __init__(self, message, status, **kwargs):
|
||||||
|
super().__init__(self, message)
|
||||||
|
self.message = message
|
||||||
|
self.status = status
|
||||||
|
self.kwargs = kwargs
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
if not self.kwargs:
|
||||||
|
return self.message
|
||||||
|
|
||||||
|
string = f"{self.message}:"
|
||||||
|
for key, value in self.kwargs.items():
|
||||||
|
string += f" {key}={value}"
|
||||||
|
|
||||||
|
return string
|
||||||
|
|
||||||
|
|
||||||
|
class DDNSClientError(DDNSError):
|
||||||
|
def __init__(self, message, code, status, **kwargs):
|
||||||
|
super().__init__(message, status, **kwargs)
|
||||||
|
self.code = code
|
||||||
|
|
||||||
|
|
||||||
class DDNSRequestHandler(BaseHTTPRequestHandler):
|
class DDNSRequestHandler(BaseHTTPRequestHandler):
|
||||||
"""HTTP request handler for DDNS updates."""
|
"""HTTP request handler for DDNS updates."""
|
||||||
@@ -158,131 +244,14 @@ class DDNSRequestHandler(BaseHTTPRequestHandler):
|
|||||||
pass
|
pass
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
def do_GET(self):
|
def _parse_ip_params(self, params, endpoint, client_ip, username,
|
||||||
"""Handle GET requests."""
|
hostname_param):
|
||||||
set_txn_id()
|
"""Parse and validate IP address parameters."""
|
||||||
try:
|
|
||||||
self._handle_get_request()
|
|
||||||
finally:
|
|
||||||
clear_txn_id()
|
|
||||||
|
|
||||||
def _handle_get_request(self):
|
|
||||||
"""Handle GET request logic."""
|
|
||||||
try:
|
|
||||||
client_ip = self.get_client_ip()
|
|
||||||
except ProxyHeaderError as e:
|
|
||||||
logging.error(f"Proxy header error: {e}")
|
|
||||||
self.send_response_body(400, "Bad Request")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Bad rate limit check
|
|
||||||
if self.app.bad_limiter:
|
|
||||||
blocked, retry_at = self.app.bad_limiter.is_blocked(client_ip)
|
|
||||||
if blocked:
|
|
||||||
logging.warning(
|
|
||||||
f"Rate limited (bad): client={client_ip}, "
|
|
||||||
f"retry_at={datetime_str(retry_at)}")
|
|
||||||
self.respond(429, "abuse")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Parse URL
|
|
||||||
parsed = urlparse(self.path)
|
|
||||||
|
|
||||||
# Find matching endpoint
|
|
||||||
endpoint = self.app.config["_endpoint_map"].get(parsed.path)
|
|
||||||
if endpoint is None:
|
|
||||||
self.send_response_body(404, "Not Found")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Parse query parameters
|
|
||||||
params = parse_qs(parsed.query)
|
|
||||||
|
|
||||||
# Get credentials
|
|
||||||
username, password = self.parse_basic_auth()
|
|
||||||
if username is None:
|
|
||||||
username = extract_param(params, endpoint["params"]["username"])
|
|
||||||
password = extract_param(params, endpoint["params"]["password"])
|
|
||||||
|
|
||||||
if not username or not password:
|
|
||||||
logging.warning(f"Auth failed: client={client_ip} user=anonymous")
|
|
||||||
self._handle_bad_request(client_ip, 401, "badauth")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Validate credentials
|
|
||||||
try:
|
|
||||||
user = get_user(username)
|
|
||||||
self.app.password_hasher.verify(user.password_hash, password)
|
|
||||||
except (DoesNotExist, argon2.exceptions.VerifyMismatchError):
|
|
||||||
logging.warning(f"Auth failed: client={client_ip} user={username}")
|
|
||||||
self._handle_bad_request(client_ip, 401, "badauth")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Get hostname parameter
|
|
||||||
hostname_param = extract_param(params, endpoint["params"]["hostname"])
|
|
||||||
if not hostname_param:
|
|
||||||
logging.warning(f"Missing hostname: client={client_ip} user={username}")
|
|
||||||
self._handle_bad_request(client_ip, 400, "nohost")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Validate and encode hostname
|
|
||||||
try:
|
|
||||||
hostname_param = encode_dnsname(hostname_param)
|
|
||||||
except EncodingError:
|
|
||||||
logging.warning(
|
|
||||||
f"Invalid hostname: client={client_ip}, "
|
|
||||||
f"hostname={hostname_param}")
|
|
||||||
self._handle_bad_request(client_ip, 400, "nohost")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Check hostname ownership
|
|
||||||
try:
|
|
||||||
hostname = get_hostname_for_user(hostname_param, user)
|
|
||||||
except DoesNotExist:
|
|
||||||
logging.warning(
|
|
||||||
f"Access denied: client={client_ip} user={username} "
|
|
||||||
f"hostname={hostname_param}"
|
|
||||||
)
|
|
||||||
self._handle_bad_request(client_ip, 403, "nohost")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Good rate limit check
|
|
||||||
if self.app.good_limiter:
|
|
||||||
blocked, retry_at = self.app.good_limiter.is_blocked(client_ip)
|
|
||||||
if blocked:
|
|
||||||
logging.warning(
|
|
||||||
f"Rate limited: client={client_ip}, "
|
|
||||||
f"retry_at={datetime_str(retry_at)}")
|
|
||||||
self.respond(429, "abuse")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Record good request
|
|
||||||
if self.app.good_limiter:
|
|
||||||
self.app.good_limiter.record(client_ip)
|
|
||||||
|
|
||||||
# Determine IPs to update
|
|
||||||
result = self._process_ip_update(hostname, params, endpoint, client_ip)
|
|
||||||
if result:
|
|
||||||
code, status, *kwargs = result
|
|
||||||
if kwargs:
|
|
||||||
self.respond(code, status, **kwargs[0])
|
|
||||||
else:
|
|
||||||
self.respond(code, status)
|
|
||||||
|
|
||||||
def _handle_bad_request(self, client_ip, code, status):
|
|
||||||
"""Handle bad request and record in rate limiter."""
|
|
||||||
if self.app.bad_limiter:
|
|
||||||
self.app.bad_limiter.record(client_ip)
|
|
||||||
self.respond(code, status)
|
|
||||||
|
|
||||||
def _process_ip_update(self, hostname, params, endpoint, client_ip):
|
|
||||||
"""Process IP update for hostname."""
|
|
||||||
myip = extract_param(params, endpoint["params"]["ipv4"])
|
|
||||||
myip6 = extract_param(params, endpoint["params"]["ipv6"])
|
|
||||||
|
|
||||||
ipv4 = None
|
ipv4 = None
|
||||||
ipv6 = None
|
ipv6 = None
|
||||||
|
|
||||||
# Process myip parameter
|
# Process myip parameter
|
||||||
|
myip = extract_param(params, endpoint["params"]["ipv4"])
|
||||||
if myip:
|
if myip:
|
||||||
try:
|
try:
|
||||||
rtype, myip = detect_ip_type(myip)
|
rtype, myip = detect_ip_type(myip)
|
||||||
@@ -291,34 +260,295 @@ class DDNSRequestHandler(BaseHTTPRequestHandler):
|
|||||||
else:
|
else:
|
||||||
ipv6 = myip
|
ipv6 = myip
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return (400, "badip")
|
raise DDNSClientError(
|
||||||
|
"Bad IP address", 400, STATUS_BADIP,
|
||||||
|
client=client_ip, username=username,
|
||||||
|
hostname=hostname_param, ip=myip
|
||||||
|
)
|
||||||
|
|
||||||
# Process myip6 parameter
|
# Process myip6 parameter
|
||||||
|
myip6 = extract_param(params, endpoint["params"]["ipv6"])
|
||||||
if myip6:
|
if myip6:
|
||||||
try:
|
try:
|
||||||
rtype, myip6 = detect_ip_type(myip6)
|
rtype, myip6 = detect_ip_type(myip6)
|
||||||
if rtype == "AAAA":
|
if rtype != "AAAA":
|
||||||
ipv6 = myip6
|
raise ValueError
|
||||||
else:
|
ipv6 = myip6
|
||||||
return (400, "badip")
|
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return (400, "badip")
|
raise DDNSClientError(
|
||||||
|
"Bad IPv6 address", 400, STATUS_BADIP,
|
||||||
|
client=client_ip, username=username,
|
||||||
|
hostname=hostname_param, ipv6=myip6
|
||||||
|
)
|
||||||
|
|
||||||
# Auto-detect from client IP if no params
|
# Auto-detect from client IP if no params
|
||||||
if ipv4 is None and ipv6 is None:
|
if ipv4 is None and ipv6 is None:
|
||||||
|
rtype, ip = detect_ip_type(client_ip)
|
||||||
|
if rtype == "A":
|
||||||
|
ipv4 = ip
|
||||||
|
else:
|
||||||
|
ipv6 = ip
|
||||||
|
|
||||||
|
return ipv4, ipv6
|
||||||
|
|
||||||
|
def _parse_expiry_ttl(self, params, endpoint, client_ip, username,
|
||||||
|
hostname_param):
|
||||||
|
"""Parse and validate expiry_ttl parameter."""
|
||||||
|
expiry_ttl_param = extract_param(params, endpoint["params"]["expiry_ttl"])
|
||||||
|
if not expiry_ttl_param:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
expiry_ttl = int(expiry_ttl_param)
|
||||||
|
if expiry_ttl < 0:
|
||||||
|
raise ValueError
|
||||||
|
except ValueError:
|
||||||
|
raise DDNSClientError(
|
||||||
|
"Invalid expiry_ttl", 400, STATUS_NOHOST,
|
||||||
|
client=client_ip, username=username,
|
||||||
|
hostname=hostname_param, expiry_ttl=expiry_ttl_param
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate bounds
|
||||||
|
defaults = self.app.config["defaults"]
|
||||||
|
|
||||||
|
if expiry_ttl == 0:
|
||||||
|
if not defaults["expiry_ttl_allow_zero"]:
|
||||||
|
raise DDNSClientError(
|
||||||
|
"Zero expiry_ttl not allowed", 400, STATUS_NOHOST,
|
||||||
|
client=client_ip, username=username,
|
||||||
|
hostname=hostname_param, expiry_ttl=expiry_ttl
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
ttl_min = defaults["expiry_ttl_min"]
|
||||||
|
if ttl_min is not None and expiry_ttl < ttl_min:
|
||||||
|
raise DDNSClientError(
|
||||||
|
"expiry_ttl below minimum", 400, STATUS_NOHOST,
|
||||||
|
client=client_ip, username=username,
|
||||||
|
hostname=hostname_param, expiry_ttl=expiry_ttl, min=ttl_min
|
||||||
|
)
|
||||||
|
ttl_max = defaults["expiry_ttl_max"]
|
||||||
|
if ttl_max is not None and expiry_ttl > ttl_max:
|
||||||
|
raise DDNSClientError(
|
||||||
|
"expiry_ttl above maximum", 400, STATUS_NOHOST,
|
||||||
|
client=client_ip, username=username,
|
||||||
|
hostname=hostname_param, expiry_ttl=expiry_ttl, max=ttl_max
|
||||||
|
)
|
||||||
|
|
||||||
|
return expiry_ttl
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
"""Handle GET requests."""
|
||||||
|
set_txn_id()
|
||||||
|
try:
|
||||||
|
client_ip = self.get_client_ip()
|
||||||
|
except ProxyHeaderError as e:
|
||||||
|
logging.error(f"Proxy header error: {e}")
|
||||||
|
self.respond(400, "Bad Request")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._handle_get_request(client_ip)
|
||||||
|
except DDNSClientError as e:
|
||||||
|
if self.app.bad_limiter:
|
||||||
|
self.app.bad_limiter.record(client_ip)
|
||||||
|
logging.warning(e)
|
||||||
|
self.respond(e.code, e.status)
|
||||||
|
except DDNSError as e:
|
||||||
|
logging.error(e)
|
||||||
|
self.respond(500, e.status)
|
||||||
|
except DatabaseError as e:
|
||||||
|
logging.error(f"Database error: {e}")
|
||||||
|
self.respond(500, "Internal Server Error")
|
||||||
|
except Exception as e:
|
||||||
|
logging.exception(f"Uncaught exception: {e}")
|
||||||
|
self.respond(500, "Internal Server Error")
|
||||||
|
finally:
|
||||||
|
clear_txn_id()
|
||||||
|
|
||||||
|
def _handle_get_request(self, client_ip):
|
||||||
|
"""Handle GET request logic."""
|
||||||
|
# Parse URL
|
||||||
|
parsed = urlparse(self.path)
|
||||||
|
|
||||||
|
# Find matching endpoint
|
||||||
|
endpoint = self.app.config["_endpoint_map"].get(parsed.path)
|
||||||
|
if endpoint is None:
|
||||||
|
self.respond(404, "Not Found")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Bad rate limit check
|
||||||
|
if self.app.bad_limiter:
|
||||||
|
blocked, retry_at = self.app.bad_limiter.is_blocked(client_ip)
|
||||||
|
if blocked:
|
||||||
|
raise DDNSClientError(
|
||||||
|
"Rate limited (bad requests)",
|
||||||
|
429,
|
||||||
|
STATUS_ABUSE,
|
||||||
|
client=client_ip,
|
||||||
|
retry_at=datetime_str(retry_at)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse query parameters
|
||||||
|
params = parse_qs(parsed.query)
|
||||||
|
|
||||||
|
# Process credentials parameters
|
||||||
|
username, password = self.parse_basic_auth()
|
||||||
|
if username is None:
|
||||||
|
username = extract_param(params, endpoint["params"]["username"])
|
||||||
|
password = extract_param(params, endpoint["params"]["password"])
|
||||||
|
|
||||||
|
if not username or not password:
|
||||||
|
raise DDNSClientError(
|
||||||
|
"Auth failed",
|
||||||
|
401,
|
||||||
|
STATUS_BADAUTH,
|
||||||
|
client_ip=client_ip
|
||||||
|
)
|
||||||
|
|
||||||
|
# Process hostname parameter
|
||||||
|
hostname_param = extract_param(params, endpoint["params"]["hostname"])
|
||||||
|
if not hostname_param:
|
||||||
|
raise DDNSClientError(
|
||||||
|
"Missing hostname",
|
||||||
|
400,
|
||||||
|
STATUS_NOHOST,
|
||||||
|
client=client_ip,
|
||||||
|
username=username
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse IP parameters
|
||||||
|
ipv4, ipv6 = self._parse_ip_params(
|
||||||
|
params, endpoint, client_ip, username, hostname_param)
|
||||||
|
|
||||||
|
# Process notify_change parameter
|
||||||
|
notify_change = extract_param(
|
||||||
|
params, endpoint["params"]["notify_change"])
|
||||||
|
notify_change = (notify_change.lower() in
|
||||||
|
["1", "y", "yes", "on", "true"]
|
||||||
|
if notify_change else False)
|
||||||
|
|
||||||
|
# Parse expiry_ttl parameter
|
||||||
|
expiry_ttl = self._parse_expiry_ttl(
|
||||||
|
params, endpoint, client_ip, username, hostname_param)
|
||||||
|
|
||||||
|
# Validate credentials
|
||||||
|
user = self._authenticate(client_ip, username, password)
|
||||||
|
|
||||||
|
# Check hostname permission
|
||||||
|
hostname, created = self._get_hostname_for_user(
|
||||||
|
client_ip,
|
||||||
|
user,
|
||||||
|
hostname_param
|
||||||
|
)
|
||||||
|
|
||||||
|
# Good rate limit check
|
||||||
|
if self.app.good_limiter:
|
||||||
|
blocked, retry_at = self.app.good_limiter.is_blocked(client_ip)
|
||||||
|
if blocked:
|
||||||
|
raise DDNSClientError(
|
||||||
|
"Rate limited (good requests)",
|
||||||
|
429,
|
||||||
|
STATUS_ABUSE,
|
||||||
|
client=client_ip,
|
||||||
|
username=username,
|
||||||
|
retry_at=datetime_str(retry_at)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Record good request
|
||||||
|
if self.app.good_limiter:
|
||||||
|
self.app.good_limiter.record(client_ip)
|
||||||
|
|
||||||
|
# Process update request
|
||||||
|
self._process_ip_update(
|
||||||
|
client_ip,
|
||||||
|
user,
|
||||||
|
hostname,
|
||||||
|
ipv4,
|
||||||
|
ipv6,
|
||||||
|
notify_change,
|
||||||
|
expiry_ttl,
|
||||||
|
created
|
||||||
|
)
|
||||||
|
|
||||||
|
def _authenticate(self, client_ip, username, password):
|
||||||
|
try:
|
||||||
try:
|
try:
|
||||||
rtype, ip = detect_ip_type(client_ip)
|
user = get_user(username)
|
||||||
if rtype == "A":
|
except DoesNotExist:
|
||||||
ipv4 = ip
|
# User does not exist, Hash fake password to prevent time-based attacks
|
||||||
else:
|
self.app.password_hasher.hash("FAKE-PASSWORD")
|
||||||
ipv6 = ip
|
raise DoesNotExist
|
||||||
except ValueError:
|
|
||||||
return (400, "badip")
|
|
||||||
|
|
||||||
now = utc_now()
|
self.app.password_hasher.verify(user.password_hash, password)
|
||||||
|
except (DoesNotExist, VerifyMismatchError):
|
||||||
|
raise DDNSClientError(
|
||||||
|
"Auth failed",
|
||||||
|
401,
|
||||||
|
STATUS_BADAUTH,
|
||||||
|
client=client_ip,
|
||||||
|
username=username
|
||||||
|
)
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
def _get_hostname_for_user(self, client_ip, user, hostname_param):
|
||||||
|
"""Check permissions and get/create hostname."""
|
||||||
|
code = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
perm = get_permission(user, hostname_param)
|
||||||
|
hostname_param = hostname_param.removesuffix(f".{perm.zone}")
|
||||||
|
return get_hostname_for_user(
|
||||||
|
user,
|
||||||
|
hostname_param,
|
||||||
|
perm.zone,
|
||||||
|
self.app.config["defaults"]["dns_ttl"],
|
||||||
|
self.app.config["defaults"]["expiry_ttl"]
|
||||||
|
)
|
||||||
|
except DoesNotExist:
|
||||||
|
code = 403
|
||||||
|
except EncodingError:
|
||||||
|
code = 400
|
||||||
|
|
||||||
|
raise DDNSClientError(
|
||||||
|
"Access denied",
|
||||||
|
code,
|
||||||
|
STATUS_NOHOST,
|
||||||
|
client=client_ip,
|
||||||
|
username=user.username,
|
||||||
|
hostname=hostname_param
|
||||||
|
)
|
||||||
|
|
||||||
|
def _rollback_dns(self, hostname, old_ip, record_type):
|
||||||
|
"""Roll back a DNS record to its previous value."""
|
||||||
|
try:
|
||||||
|
if old_ip:
|
||||||
|
self.app.dns_service.update_record(
|
||||||
|
hostname.hostname, hostname.zone,
|
||||||
|
old_ip, hostname.dns_ttl)
|
||||||
|
else:
|
||||||
|
self.app.dns_service.delete_record(
|
||||||
|
hostname.hostname, hostname.zone, record_type)
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"DNS rollback failed ({record_type}): {e}")
|
||||||
|
|
||||||
|
def _process_ip_update(self, client_ip, user, hostname, ipv4, ipv6,
|
||||||
|
notify_change, expiry_ttl, created):
|
||||||
|
"""Process IP update for hostname."""
|
||||||
|
now = now_utc()
|
||||||
|
|
||||||
|
old_ipv4 = hostname.last_ipv4
|
||||||
|
old_ipv6 = hostname.last_ipv6
|
||||||
ipv4_changed = False
|
ipv4_changed = False
|
||||||
ipv6_changed = False
|
ipv6_changed = False
|
||||||
|
|
||||||
|
# Apply expiry_ttl if provided
|
||||||
|
expiry_ttl_changed = False
|
||||||
|
if expiry_ttl is not None and expiry_ttl != hostname.expiry_ttl:
|
||||||
|
hostname.expiry_ttl = expiry_ttl
|
||||||
|
expiry_ttl_changed = True
|
||||||
|
|
||||||
if ipv4:
|
if ipv4:
|
||||||
hostname.last_ipv4_update = now
|
hostname.last_ipv4_update = now
|
||||||
if ipv4 != hostname.last_ipv4:
|
if ipv4 != hostname.last_ipv4:
|
||||||
@@ -330,15 +560,18 @@ class DDNSRequestHandler(BaseHTTPRequestHandler):
|
|||||||
ipv4,
|
ipv4,
|
||||||
hostname.dns_ttl
|
hostname.dns_ttl
|
||||||
)
|
)
|
||||||
hostname.last_ipv4 = ipv4
|
|
||||||
ipv4_changed = True
|
ipv4_changed = True
|
||||||
|
hostname.last_ipv4 = ipv4
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
hostname.save()
|
logging.error(f"DNS error: {e}")
|
||||||
logging.error(
|
raise DDNSError(
|
||||||
f"DNS update failed: client={client_ip} hostname={hostname.hostname} "
|
"Update failed",
|
||||||
f"zone={hostname.zone} ipv4={ipv4} error={e}"
|
STATUS_DNSERR,
|
||||||
|
client=client_ip,
|
||||||
|
hostname=hostname.hostname,
|
||||||
|
zone=hostname.zone,
|
||||||
|
ipv4=ipv4
|
||||||
)
|
)
|
||||||
return (500, "dnserr")
|
|
||||||
|
|
||||||
if ipv6:
|
if ipv6:
|
||||||
hostname.last_ipv6_update = now
|
hostname.last_ipv6_update = now
|
||||||
@@ -351,42 +584,68 @@ class DDNSRequestHandler(BaseHTTPRequestHandler):
|
|||||||
ipv6,
|
ipv6,
|
||||||
hostname.dns_ttl
|
hostname.dns_ttl
|
||||||
)
|
)
|
||||||
hostname.last_ipv6 = ipv6
|
|
||||||
ipv6_changed = True
|
ipv6_changed = True
|
||||||
|
hostname.last_ipv6 = ipv6
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
hostname.save()
|
logging.error(f"DNS error: {e}")
|
||||||
logging.error(
|
# Roll back IPv4 DNS if it was changed
|
||||||
f"DNS update failed: client={client_ip} hostname={hostname.hostname} "
|
if ipv4_changed:
|
||||||
f"zone={hostname.zone} ipv6={ipv6} error={e}"
|
self._rollback_dns(hostname, old_ipv4, "A")
|
||||||
|
raise DDNSError(
|
||||||
|
"Update failed",
|
||||||
|
STATUS_DNSERR,
|
||||||
|
client=client_ip,
|
||||||
|
hostname=hostname.hostname,
|
||||||
|
zone=hostname.zone,
|
||||||
|
ipv6=ipv6
|
||||||
)
|
)
|
||||||
return (500, "dnserr")
|
|
||||||
|
|
||||||
# Update database
|
# Update database
|
||||||
hostname.save()
|
try:
|
||||||
|
hostname.save()
|
||||||
|
except DatabaseError as e:
|
||||||
|
logging.error(
|
||||||
|
f"DB save failed after retries: hostname={hostname.hostname} "
|
||||||
|
f"zone={hostname.zone}: {e}"
|
||||||
|
)
|
||||||
|
if ipv4_changed:
|
||||||
|
self._rollback_dns(hostname, old_ipv4, "A")
|
||||||
|
if ipv6_changed:
|
||||||
|
self._rollback_dns(hostname, old_ipv6, "AAAA")
|
||||||
|
raise DDNSError(
|
||||||
|
"Update failed",
|
||||||
|
STATUS_DNSERR,
|
||||||
|
client=client_ip,
|
||||||
|
hostname=hostname.hostname,
|
||||||
|
zone=hostname.zone
|
||||||
|
)
|
||||||
|
|
||||||
notify_change_val = extract_param(params, endpoint["params"]["notify_change"])
|
if not ipv4_changed and not ipv6_changed and not expiry_ttl_changed:
|
||||||
notify_change = notify_change_val.lower() not in ["0", "n", "no", "off"] \
|
|
||||||
if notify_change_val else False
|
|
||||||
|
|
||||||
changed_addrs = ""
|
|
||||||
if ipv4_changed:
|
|
||||||
changed_addrs += f" ipv4={ipv4}"
|
|
||||||
if ipv6_changed:
|
|
||||||
changed_addrs += f" ipv6={ipv6}"
|
|
||||||
|
|
||||||
if not ipv4_changed and not ipv6_changed:
|
|
||||||
logging.info(
|
logging.info(
|
||||||
f"No change: client={client_ip} hostname={hostname.hostname} "
|
f"No change: client={client_ip} hostname={hostname.hostname} "
|
||||||
f"zone={hostname.zone}{changed_addrs} notify_change={str(notify_change).lower()}"
|
f"zone={hostname.zone} notify_change={str(notify_change).lower()}"
|
||||||
)
|
)
|
||||||
return (
|
self.respond(
|
||||||
200, "nochg",
|
200,
|
||||||
{"ipv4": hostname.last_ipv4, "ipv6": hostname.last_ipv6}
|
STATUS_NOCHG,
|
||||||
|
ipv4=hostname.last_ipv4,
|
||||||
|
ipv6=hostname.last_ipv6,
|
||||||
|
expiry_ttl=hostname.expiry_ttl,
|
||||||
|
created=created
|
||||||
)
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
action = "Created" if created else "Updated"
|
||||||
|
changed_info = ""
|
||||||
|
if ipv4_changed:
|
||||||
|
changed_info += f" ipv4={ipv4}"
|
||||||
|
if ipv6_changed:
|
||||||
|
changed_info += f" ipv6={ipv6}"
|
||||||
|
if expiry_ttl_changed:
|
||||||
|
changed_info += f" expiry_ttl={hostname.expiry_ttl}"
|
||||||
logging.info(
|
logging.info(
|
||||||
f"Updated: client={client_ip} hostname={hostname.hostname} "
|
f"{action}: client={client_ip} hostname={hostname.hostname} "
|
||||||
f"zone={hostname.zone}{changed_addrs} notify_change={str(notify_change).lower()}"
|
f"zone={hostname.zone}{changed_info} notify_change={str(notify_change).lower()}"
|
||||||
)
|
)
|
||||||
|
|
||||||
if notify_change:
|
if notify_change:
|
||||||
@@ -395,14 +654,19 @@ class DDNSRequestHandler(BaseHTTPRequestHandler):
|
|||||||
hostname.user.email,
|
hostname.user.email,
|
||||||
hostname,
|
hostname,
|
||||||
ipv4_changed,
|
ipv4_changed,
|
||||||
ipv6_changed
|
ipv6_changed,
|
||||||
|
expiry_ttl_changed
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Sending change notification error: {e}")
|
logging.error(f"Sending change notification error: {e}")
|
||||||
|
|
||||||
return (
|
self.respond(
|
||||||
200, "good",
|
200,
|
||||||
{"ipv4": hostname.last_ipv4, "ipv6": hostname.last_ipv6}
|
STATUS_GOOD,
|
||||||
|
ipv4=hostname.last_ipv4,
|
||||||
|
ipv6=hostname.last_ipv6,
|
||||||
|
expiry_ttl=hostname.expiry_ttl,
|
||||||
|
created=created
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -440,14 +704,40 @@ def run_daemon(app):
|
|||||||
expired_cleanup_thread = ExpiredRecordsCleanupThread(app)
|
expired_cleanup_thread = ExpiredRecordsCleanupThread(app)
|
||||||
expired_cleanup_thread.start()
|
expired_cleanup_thread.start()
|
||||||
|
|
||||||
|
|
||||||
# Setup signal handlers
|
# Setup signal handlers
|
||||||
def signal_handler(signum, frame):
|
def signal_handler(signum, frame):
|
||||||
logging.info(f"Signal received: {signum}, shutting down")
|
logging.info(f"Signal received: {signum}, shutting down")
|
||||||
app.signal_shutdown()
|
app.signal_shutdown()
|
||||||
|
|
||||||
|
def sighup_handler(signum, frame):
|
||||||
|
logging.info("SIGHUP received, reloading configuration")
|
||||||
|
try:
|
||||||
|
app.reload_config()
|
||||||
|
|
||||||
|
# Update server attributes
|
||||||
|
server.proxy_header = app.config["daemon"].get("proxy_header", "")
|
||||||
|
server.trusted_networks = _parse_trusted_proxies(
|
||||||
|
app.config["daemon"].get("trusted_proxies", [])
|
||||||
|
)
|
||||||
|
|
||||||
|
# Reload SSL if enabled
|
||||||
|
if app.config["daemon"]["ssl"]:
|
||||||
|
new_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||||
|
new_context.load_cert_chain(
|
||||||
|
app.config["daemon"]["ssl_cert_file"],
|
||||||
|
app.config["daemon"]["ssl_key_file"]
|
||||||
|
)
|
||||||
|
fd = server.socket.detach()
|
||||||
|
raw_socket = socket.socket(fileno=fd)
|
||||||
|
server.socket = new_context.wrap_socket(
|
||||||
|
raw_socket, server_side=True
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Config reload failed: {e}")
|
||||||
|
|
||||||
signal.signal(signal.SIGTERM, signal_handler)
|
signal.signal(signal.SIGTERM, signal_handler)
|
||||||
signal.signal(signal.SIGINT, signal_handler)
|
signal.signal(signal.SIGINT, signal_handler)
|
||||||
|
signal.signal(signal.SIGHUP, sighup_handler)
|
||||||
|
|
||||||
paths = ", ".join(ep["path"] for ep in config["endpoints"])
|
paths = ", ".join(ep["path"] for ep in config["endpoints"])
|
||||||
logging.info(f"Daemon started: {proto}://{host}:{port} endpoints=[{paths}]")
|
logging.info(f"Daemon started: {proto}://{host}:{port} endpoints=[{paths}]")
|
||||||
@@ -457,10 +747,14 @@ def run_daemon(app):
|
|||||||
while not app.is_shutting_down():
|
while not app.is_shutting_down():
|
||||||
server.handle_request()
|
server.handle_request()
|
||||||
|
|
||||||
|
# Graceful shutdown - wait for active requests
|
||||||
|
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