Skip to content

Commit 291e016

Browse files
authored
[Release] Version v0.0.1-rc.1 (#3)
* [Feature] Version v0.0.1 * [Feature] Version v0.0.1-rc.1 (#2) - Wildcard zone (`name: "*"`) so a single token can operate across all zones with record type, operation and subdomain filters still enforced (e.g. ACME DNS01 challenges) - Multi zone shorthand (`names: [...]`) to apply one policy to a list of specific zones without repeating the rules for each - Timing safe token comparison using `hmac.compare_digest instead` of `==` in `proxy/auth.py` - Config example and README updated: replaced confusing localhost placeholder (for docker usage), added examples for all three zone variants (single, multi, wildcard), fixed incorrect subdomain filter comment (`^app\.` doesn't match nested subdomains), documented `names` and `*` in the zone policy options table - new wildcard policy tests and new config expansion tests (names, mixed, validation error) * [Feature] Config hot-reloading (#5)
1 parent 79859ad commit 291e016

8 files changed

Lines changed: 280 additions & 14 deletions

File tree

README.md

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,15 @@ Clients use the standard [Technitium API](https://github.com/TechnitiumSoftware/
1010

1111
- YAML-driven configuration (`config.yml`) with per-token access policies
1212
- Zone-scoped tokens that restrict which DNS zones a token can access
13+
- Multi-zone policies via `names` to apply the same rules to multiple zones without repetition
14+
- Wildcard zone (`name: "*"`) for tokens that need access across all zones (e.g. ACME challenge automation)
1315
- Operation filtering to limit tokens to specific CRUD operations (`get`, `add`, `update`, `delete`)
1416
- Record type filtering to restrict tokens to specific DNS record types (`A`, `AAAA`, `CNAME`, `TXT`, etc.)
1517
- Subdomain filtering to limit tokens to manage records under a specific subdomain prefix
1618
- Global read-only tokens that allow full read access across all zones without write permissions
1719
- Tiered endpoint classification where only record and zone-list endpoints are proxied; zone management and admin endpoints are blocked
1820
- Zone list filtering where `/api/zones/list` responses only show zones the token is allowed to access
21+
- Hot reload of configuration on file change (no restart required)
1922
- Structured audit logging via structlog
2023
- Multi-arch Docker images (linux/amd64, linux/arm64)
2124
- Standalone binary builds via PyInstaller
@@ -75,22 +78,39 @@ docker run --rm \
7578

7679
```yaml
7780
technitium:
78-
url: "http://localhost:5380"
81+
url: "http://your-technitium-server:5380"
7982
token: "your-admin-api-token"
8083
verify_ssl: true
8184

8285
tokens:
83-
# Full access to a zone
86+
# Full access to a single zone
8487
- name: "full-access"
8588
token: "client-secret-token"
8689
zones:
8790
- name: "example.com"
8891
allowed_record_types: ["A", "AAAA", "CNAME", "TXT"]
8992
allowed_operations: ["list", "get", "add", "update", "delete"]
9093

94+
# Shared policy for multiple specific zones
95+
- name: "multi-zone"
96+
token: "multi-zone-secret"
97+
zones:
98+
- names: ["example.com", "other.org", "third.io"]
99+
allowed_record_types: ["A", "AAAA", "CNAME"]
100+
allowed_operations: ["get", "add", "update", "delete"]
101+
102+
# ACME challenge token for all zones
103+
- name: "acme-client"
104+
token: "acme-secret"
105+
zones:
106+
- name: "*"
107+
allowed_record_types: ["TXT"]
108+
allowed_operations: ["add", "delete"]
109+
subdomain_filter: "^_acme-challenge\\."
110+
91111
# Only manage records under app.example.com (regex pattern)
92-
# Allows: app.example.com, api.app.example.com, v2.app.example.com
93-
# Denies: www.example.com, mail.example.com
112+
# Allows: app.example.com
113+
# Denies: www.example.com, mail.example.com, v2.app.example.com
94114
- name: "app-team"
95115
token: "app-team-secret"
96116
zones:
@@ -118,11 +138,14 @@ tokens:
118138

119139
| Field | Type | Default | Description |
120140
|-------|------|---------|-------------|
121-
| `name` | string | required | DNS zone name (e.g. `example.com`) |
141+
| `name` | string | - | Single DNS zone name (e.g. `example.com`), or `*` for all zones |
142+
| `names` | list | - | Multiple DNS zone names sharing the same policy |
122143
| `allowed_record_types` | list | `[]` (all) | Restrict to specific record types (`A`, `AAAA`, `CNAME`, `TXT`, `MX`, etc.) |
123144
| `allowed_operations` | list | `[]` (all) | Restrict to specific operations (`get`, `add`, `update`, `delete`) |
124145
| `subdomain_filter` | string | `null` | Regex pattern to match against the domain (case-insensitive) |
125146

147+
Each zone policy must have either `name` or `names` (not both). Use `names` to apply the same rules to multiple zones without repetition. Use `name: "*"` for tokens that need access across all zones (e.g. ACME DNS-01 challenges). Wildcard tokens only see explicitly listed zones in `/api/zones/list` responses.
148+
126149
Empty lists mean "all allowed". Omit `allowed_record_types` to allow all record types, omit `allowed_operations` to allow all operations.
127150

128151
---
@@ -156,6 +179,7 @@ bin/start.sh
156179
| `HOST` | `0.0.0.0` | Host/IP to bind |
157180
| `PORT` | `31399` | Port to bind |
158181
| `LOG_LEVEL` | `info` | Log level (`debug`, `info`, `warning`, `error`) |
182+
| `RELOAD_INTERVAL` | `5` | Seconds between config file change checks (0 to disable) |
159183

160184
---
161185

config.example.yml

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,37 @@
11
technitium:
2-
url: "http://localhost:5380"
2+
url: "http://your-technitium-server:5380"
33
token: "your-admin-api-token"
44
verify_ssl: true
55

66
tokens:
7-
# Full access to a zone
7+
# Full access to a single zone
88
- name: "full-access"
99
token: "client-secret-token"
1010
zones:
1111
- name: "example.com"
1212
allowed_record_types: ["A", "AAAA", "CNAME", "TXT"]
1313
allowed_operations: ["list", "get", "add", "update", "delete"]
1414

15+
# Shared policy for multiple specific zones
16+
- name: "multi-zone"
17+
token: "multi-zone-secret"
18+
zones:
19+
- names: ["example.com", "other.org", "third.io"]
20+
allowed_record_types: ["A", "AAAA", "CNAME"]
21+
allowed_operations: ["get", "add", "update", "delete"]
22+
23+
# ACME challenge token for all zones
24+
- name: "acme-client"
25+
token: "acme-secret"
26+
zones:
27+
- name: "*"
28+
allowed_record_types: ["TXT"]
29+
allowed_operations: ["add", "delete"]
30+
subdomain_filter: "^_acme-challenge\\."
31+
1532
# Only manage records under app.example.com
16-
# Allows: app.example.com, api.app.example.com, v2.app.example.com
17-
# Denies: www.example.com, mail.example.com
33+
# Allows: app.example.com
34+
# Denies: www.example.com, mail.example.com, v2.app.example.com
1835
- name: "app-team"
1936
token: "app-team-secret"
2037
zones:

proxy/auth.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from __future__ import annotations
22

3+
import hmac
4+
35
from fastapi import Header, Query, Request
46
from fastapi.responses import JSONResponse
57

@@ -31,7 +33,7 @@ def resolve_token(
3133

3234
config = request.app.state.config
3335
for tc in config.tokens:
34-
if tc.token == raw_token:
36+
if hmac.compare_digest(tc.token, raw_token):
3537
return tc
3638

3739
raise TokenError

proxy/config.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,40 @@ class ZonePolicy(BaseModel):
2020
subdomain_filter: str | None = None
2121

2222

23+
class ZonePolicyInput(BaseModel):
24+
"""Config input that accepts either 'name' (single) or 'names' (list)."""
25+
name: str | None = None
26+
names: list[str] | None = None
27+
allowed_record_types: list[str] = []
28+
allowed_operations: list[str] = []
29+
subdomain_filter: str | None = None
30+
31+
32+
def _expand_zone_policies(inputs: list[dict]) -> list[ZonePolicy]:
33+
"""Expand zone policy inputs: entries with 'names' become multiple ZonePolicy objects."""
34+
result: list[ZonePolicy] = []
35+
for raw in inputs:
36+
entry = ZonePolicyInput.model_validate(raw)
37+
if entry.names is not None:
38+
for n in entry.names:
39+
result.append(ZonePolicy(
40+
name=n,
41+
allowed_record_types=entry.allowed_record_types,
42+
allowed_operations=entry.allowed_operations,
43+
subdomain_filter=entry.subdomain_filter,
44+
))
45+
elif entry.name is not None:
46+
result.append(ZonePolicy(
47+
name=entry.name,
48+
allowed_record_types=entry.allowed_record_types,
49+
allowed_operations=entry.allowed_operations,
50+
subdomain_filter=entry.subdomain_filter,
51+
))
52+
else:
53+
raise ValueError("Zone policy must have either 'name' or 'names'")
54+
return result
55+
56+
2357
class TokenConfig(BaseModel):
2458
name: str
2559
token: str
@@ -36,4 +70,14 @@ def load_config() -> AppConfig:
3670
config_path = Path(os.environ.get("CONFIG_PATH", "config.yml"))
3771
with open(config_path) as f:
3872
raw = yaml.safe_load(f)
73+
74+
# Expand 'names' shorthand in zone policies before validation
75+
for token_raw in raw.get("tokens", []):
76+
if "zones" in token_raw:
77+
token_raw["zones"] = [
78+
{"name": zp.name, "allowed_record_types": zp.allowed_record_types,
79+
"allowed_operations": zp.allowed_operations, "subdomain_filter": zp.subdomain_filter}
80+
for zp in _expand_zone_policies(token_raw["zones"])
81+
]
82+
3983
return AppConfig.model_validate(raw)

proxy/main.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
from __future__ import annotations
22

3+
import asyncio
34
import json
45
from contextlib import asynccontextmanager
6+
from pathlib import Path
57
from typing import Any, AsyncIterator
68

79
import os
@@ -18,16 +20,40 @@
1820
from proxy.policy import Tier, classify_endpoint, evaluate_policy, extract_operation, is_read_only_endpoint, is_record_endpoint, resolve_zone
1921

2022
audit_log = structlog.get_logger("proxy.audit")
23+
_reload_log = structlog.get_logger("proxy.reload")
2124

2225
_config = load_config()
2326
setup_logging(os.environ.get("LOG_LEVEL", "info"))
2427

28+
_CONFIG_PATH = Path(os.environ.get("CONFIG_PATH", "config.yml"))
29+
_RELOAD_INTERVAL = int(os.environ.get("RELOAD_INTERVAL", "5"))
30+
31+
32+
async def _watch_config(app: FastAPI) -> None:
33+
"""Poll config file for changes and hot-reload on modification."""
34+
last_mtime: float = _CONFIG_PATH.stat().st_mtime if _CONFIG_PATH.exists() else 0
35+
while True:
36+
await asyncio.sleep(_RELOAD_INTERVAL)
37+
try:
38+
current_mtime = _CONFIG_PATH.stat().st_mtime
39+
if current_mtime <= last_mtime:
40+
continue
41+
last_mtime = current_mtime
42+
new_config = load_config()
43+
app.state.config = new_config
44+
_reload_log.info("config_reloaded", config_path=str(_CONFIG_PATH))
45+
except Exception as exc:
46+
_reload_log.error("config_reload_failed", error=str(exc), config_path=str(_CONFIG_PATH))
47+
2548

2649
@asynccontextmanager
2750
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
2851
app.state.config = _config
2952
app.state.http_client = httpx.AsyncClient(verify=_config.technitium.verify_ssl)
53+
watcher = asyncio.create_task(_watch_config(app)) if _RELOAD_INTERVAL > 0 else None
3054
yield
55+
if watcher is not None:
56+
watcher.cancel()
3157
await app.state.http_client.aclose()
3258

3359

@@ -135,6 +161,8 @@ async def api_proxy(
135161
response = await forward_upstream(request, endpoint_path)
136162

137163
# Filter zone list for scoped tokens
164+
# Wildcard zones ('*') won't match real zone names, so only explicitly
165+
# listed zones appear in the filtered list.
138166
if endpoint_path.lower().rstrip("/") == "/api/zones/list" and not token_config.global_read_only:
139167
response = _filter_zone_list_response(response, token_config)
140168

proxy/policy.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -104,15 +104,26 @@ def extract_operation(path: str) -> str | None:
104104
return None
105105

106106

107+
def has_wildcard_zone(zone_policies: list[ZonePolicy]) -> bool:
108+
"""Return True if any zone policy is a wildcard (name='*')."""
109+
return any(zp.name == "*" for zp in zone_policies)
110+
111+
107112
def find_zone_policy(
108113
zone: str, zone_policies: list[ZonePolicy],
109114
) -> ZonePolicy | None:
110-
"""Find the ZonePolicy matching the given zone name (case-insensitive)."""
115+
"""Find the ZonePolicy matching the given zone name (case-insensitive).
116+
117+
Falls back to a wildcard policy (name='*') if no exact match is found.
118+
"""
111119
zone_lower = zone.lower()
120+
wildcard: ZonePolicy | None = None
112121
for zp in zone_policies:
113122
if zp.name.lower() == zone_lower:
114123
return zp
115-
return None
124+
if zp.name == "*":
125+
wildcard = zp
126+
return wildcard
116127

117128

118129
def evaluate_policy(
@@ -156,16 +167,19 @@ def resolve_zone(
156167
157168
Priority: ?zone= takes precedence over ?domain=.
158169
For ?domain=, strips leftmost labels until a configured zone matches.
170+
If a wildcard ('*') is in configured_zones, accepts any zone/domain.
159171
Returns None if no zone can be determined.
160172
"""
173+
has_wildcard = "*" in configured_zones
174+
161175
if zone_param:
162176
return zone_param
163177

164178
if not domain_param:
165179
return None
166180

167181
# Strip leftmost labels from domain until we find a configured zone
168-
lower_zones = {z.lower(): z for z in configured_zones}
182+
lower_zones = {z.lower(): z for z in configured_zones if z != "*"}
169183
domain = domain_param.lower()
170184
while domain:
171185
if domain in lower_zones:
@@ -176,4 +190,8 @@ def resolve_zone(
176190
break
177191
domain = domain[dot_idx + 1 :]
178192

193+
# Wildcard: accept the domain as-is (Technitium resolves the actual zone)
194+
if has_wildcard:
195+
return domain_param
196+
179197
return None

0 commit comments

Comments
 (0)