Skip to content

Commit ea21783

Browse files
authored
[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)
1 parent c63b8d3 commit ea21783

8 files changed

Lines changed: 252 additions & 14 deletions

File tree

README.md

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ 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
@@ -75,22 +77,39 @@ docker run --rm \
7577

7678
```yaml
7779
technitium:
78-
url: "http://localhost:5380"
80+
url: "http://your-technitium-server:5380"
7981
token: "your-admin-api-token"
8082
verify_ssl: true
8183

8284
tokens:
83-
# Full access to a zone
85+
# Full access to a single zone
8486
- name: "full-access"
8587
token: "client-secret-token"
8688
zones:
8789
- name: "example.com"
8890
allowed_record_types: ["A", "AAAA", "CNAME", "TXT"]
8991
allowed_operations: ["list", "get", "add", "update", "delete"]
9092

93+
# Shared policy for multiple specific zones
94+
- name: "multi-zone"
95+
token: "multi-zone-secret"
96+
zones:
97+
- names: ["example.com", "other.org", "third.io"]
98+
allowed_record_types: ["A", "AAAA", "CNAME"]
99+
allowed_operations: ["get", "add", "update", "delete"]
100+
101+
# ACME challenge token for all zones
102+
- name: "acme-client"
103+
token: "acme-secret"
104+
zones:
105+
- name: "*"
106+
allowed_record_types: ["TXT"]
107+
allowed_operations: ["add", "delete"]
108+
subdomain_filter: "^_acme-challenge\\."
109+
91110
# 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
111+
# Allows: app.example.com
112+
# Denies: www.example.com, mail.example.com, v2.app.example.com
94113
- name: "app-team"
95114
token: "app-team-secret"
96115
zones:
@@ -118,11 +137,14 @@ tokens:
118137

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

146+
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.
147+
126148
Empty lists mean "all allowed". Omit `allowed_record_types` to allow all record types, omit `allowed_operations` to allow all operations.
127149

128150
---

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: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,8 @@ async def api_proxy(
135135
response = await forward_upstream(request, endpoint_path)
136136

137137
# Filter zone list for scoped tokens
138+
# Wildcard zones ('*') won't match real zone names, so only explicitly
139+
# listed zones appear in the filtered list.
138140
if endpoint_path.lower().rstrip("/") == "/api/zones/list" and not token_config.global_read_only:
139141
response = _filter_zone_list_response(response, token_config)
140142

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

tests/test_config.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,3 +126,82 @@ def test_zone_policy_defaults(self) -> None:
126126
assert zp.allowed_record_types == []
127127
assert zp.allowed_operations == []
128128
assert zp.subdomain_filter is None
129+
130+
131+
class TestZonePolicyExpansion:
132+
"""Tests for 'names' shorthand expansion in zone policies."""
133+
134+
def test_names_expands_to_multiple_policies(self, tmp_path: Path) -> None:
135+
config_file = tmp_path / "config.yml"
136+
config_file.write_text(
137+
"""\
138+
technitium:
139+
url: "http://dns:5380"
140+
token: "admin-tok"
141+
tokens:
142+
- name: "acme"
143+
token: "acme-tok"
144+
zones:
145+
- names: ["example.com", "other.org", "third.io"]
146+
allowed_record_types: ["TXT"]
147+
allowed_operations: ["add", "delete"]
148+
subdomain_filter: "^_acme-challenge\\\\."
149+
"""
150+
)
151+
os.environ["CONFIG_PATH"] = str(config_file)
152+
cfg = load_config()
153+
154+
zones = cfg.tokens[0].zones
155+
assert len(zones) == 3
156+
assert [z.name for z in zones] == ["example.com", "other.org", "third.io"]
157+
for z in zones:
158+
assert z.allowed_record_types == ["TXT"]
159+
assert z.allowed_operations == ["add", "delete"]
160+
assert z.subdomain_filter == "^_acme-challenge\\."
161+
162+
def test_names_mixed_with_name(self, tmp_path: Path) -> None:
163+
config_file = tmp_path / "config.yml"
164+
config_file.write_text(
165+
"""\
166+
technitium:
167+
url: "http://dns:5380"
168+
token: "admin-tok"
169+
tokens:
170+
- name: "mixed"
171+
token: "mixed-tok"
172+
zones:
173+
- name: "single.com"
174+
allowed_record_types: ["A"]
175+
- names: ["multi1.com", "multi2.com"]
176+
allowed_record_types: ["TXT"]
177+
"""
178+
)
179+
os.environ["CONFIG_PATH"] = str(config_file)
180+
cfg = load_config()
181+
182+
zones = cfg.tokens[0].zones
183+
assert len(zones) == 3
184+
assert zones[0].name == "single.com"
185+
assert zones[0].allowed_record_types == ["A"]
186+
assert zones[1].name == "multi1.com"
187+
assert zones[1].allowed_record_types == ["TXT"]
188+
assert zones[2].name == "multi2.com"
189+
assert zones[2].allowed_record_types == ["TXT"]
190+
191+
def test_neither_name_nor_names_raises(self, tmp_path: Path) -> None:
192+
config_file = tmp_path / "config.yml"
193+
config_file.write_text(
194+
"""\
195+
technitium:
196+
url: "http://dns:5380"
197+
token: "admin-tok"
198+
tokens:
199+
- name: "bad"
200+
token: "bad-tok"
201+
zones:
202+
- allowed_record_types: ["TXT"]
203+
"""
204+
)
205+
os.environ["CONFIG_PATH"] = str(config_file)
206+
with pytest.raises(ValueError, match="name.*names"):
207+
load_config()

0 commit comments

Comments
 (0)