Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 27 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ Clients use the standard [Technitium API](https://github.com/TechnitiumSoftware/

- YAML-driven configuration (`config.yml`) with per-token access policies
- Zone-scoped tokens that restrict which DNS zones a token can access
- Multi-zone policies via `names` to apply the same rules to multiple zones without repetition
- Wildcard zone (`name: "*"`) for tokens that need access across all zones (e.g. ACME challenge automation)
- Operation filtering to limit tokens to specific CRUD operations (`get`, `add`, `update`, `delete`)
- Record type filtering to restrict tokens to specific DNS record types (`A`, `AAAA`, `CNAME`, `TXT`, etc.)
- Subdomain filtering to limit tokens to manage records under a specific subdomain prefix
Expand Down Expand Up @@ -75,22 +77,39 @@ docker run --rm \

```yaml
technitium:
url: "http://localhost:5380"
url: "http://your-technitium-server:5380"
token: "your-admin-api-token"
verify_ssl: true

tokens:
# Full access to a zone
# Full access to a single zone
- name: "full-access"
token: "client-secret-token"
zones:
- name: "example.com"
allowed_record_types: ["A", "AAAA", "CNAME", "TXT"]
allowed_operations: ["list", "get", "add", "update", "delete"]

# Shared policy for multiple specific zones
- name: "multi-zone"
token: "multi-zone-secret"
zones:
- names: ["example.com", "other.org", "third.io"]
allowed_record_types: ["A", "AAAA", "CNAME"]
allowed_operations: ["get", "add", "update", "delete"]

# ACME challenge token for all zones
- name: "acme-client"
token: "acme-secret"
zones:
- name: "*"
allowed_record_types: ["TXT"]
allowed_operations: ["add", "delete"]
subdomain_filter: "^_acme-challenge\\."

# Only manage records under app.example.com (regex pattern)
# Allows: app.example.com, api.app.example.com, v2.app.example.com
# Denies: www.example.com, mail.example.com
# Allows: app.example.com
# Denies: www.example.com, mail.example.com, v2.app.example.com
- name: "app-team"
token: "app-team-secret"
zones:
Expand Down Expand Up @@ -118,11 +137,14 @@ tokens:

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

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.

Empty lists mean "all allowed". Omit `allowed_record_types` to allow all record types, omit `allowed_operations` to allow all operations.

---
Expand Down
25 changes: 21 additions & 4 deletions config.example.yml
Original file line number Diff line number Diff line change
@@ -1,20 +1,37 @@
technitium:
url: "http://localhost:5380"
url: "http://your-technitium-server:5380"
token: "your-admin-api-token"
verify_ssl: true

tokens:
# Full access to a zone
# Full access to a single zone
- name: "full-access"
token: "client-secret-token"
zones:
- name: "example.com"
allowed_record_types: ["A", "AAAA", "CNAME", "TXT"]
allowed_operations: ["list", "get", "add", "update", "delete"]

# Shared policy for multiple specific zones
- name: "multi-zone"
token: "multi-zone-secret"
zones:
- names: ["example.com", "other.org", "third.io"]
allowed_record_types: ["A", "AAAA", "CNAME"]
allowed_operations: ["get", "add", "update", "delete"]

# ACME challenge token for all zones
- name: "acme-client"
token: "acme-secret"
zones:
- name: "*"
allowed_record_types: ["TXT"]
allowed_operations: ["add", "delete"]
subdomain_filter: "^_acme-challenge\\."

# Only manage records under app.example.com
# Allows: app.example.com, api.app.example.com, v2.app.example.com
# Denies: www.example.com, mail.example.com
# Allows: app.example.com
# Denies: www.example.com, mail.example.com, v2.app.example.com
- name: "app-team"
token: "app-team-secret"
zones:
Expand Down
4 changes: 3 additions & 1 deletion proxy/auth.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from __future__ import annotations

import hmac

from fastapi import Header, Query, Request
from fastapi.responses import JSONResponse

Expand Down Expand Up @@ -31,7 +33,7 @@ def resolve_token(

config = request.app.state.config
for tc in config.tokens:
if tc.token == raw_token:
if hmac.compare_digest(tc.token, raw_token):
return tc

raise TokenError
44 changes: 44 additions & 0 deletions proxy/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,40 @@ class ZonePolicy(BaseModel):
subdomain_filter: str | None = None


class ZonePolicyInput(BaseModel):
"""Config input that accepts either 'name' (single) or 'names' (list)."""
name: str | None = None
names: list[str] | None = None
allowed_record_types: list[str] = []
allowed_operations: list[str] = []
subdomain_filter: str | None = None


def _expand_zone_policies(inputs: list[dict]) -> list[ZonePolicy]:
"""Expand zone policy inputs: entries with 'names' become multiple ZonePolicy objects."""
result: list[ZonePolicy] = []
for raw in inputs:
entry = ZonePolicyInput.model_validate(raw)
if entry.names is not None:
for n in entry.names:
result.append(ZonePolicy(
name=n,
allowed_record_types=entry.allowed_record_types,
allowed_operations=entry.allowed_operations,
subdomain_filter=entry.subdomain_filter,
))
elif entry.name is not None:
result.append(ZonePolicy(
name=entry.name,
allowed_record_types=entry.allowed_record_types,
allowed_operations=entry.allowed_operations,
subdomain_filter=entry.subdomain_filter,
))
else:
raise ValueError("Zone policy must have either 'name' or 'names'")
return result


class TokenConfig(BaseModel):
name: str
token: str
Expand All @@ -36,4 +70,14 @@ def load_config() -> AppConfig:
config_path = Path(os.environ.get("CONFIG_PATH", "config.yml"))
with open(config_path) as f:
raw = yaml.safe_load(f)

# Expand 'names' shorthand in zone policies before validation
for token_raw in raw.get("tokens", []):
if "zones" in token_raw:
token_raw["zones"] = [
{"name": zp.name, "allowed_record_types": zp.allowed_record_types,
"allowed_operations": zp.allowed_operations, "subdomain_filter": zp.subdomain_filter}
for zp in _expand_zone_policies(token_raw["zones"])
]

return AppConfig.model_validate(raw)
2 changes: 2 additions & 0 deletions proxy/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@ async def api_proxy(
response = await forward_upstream(request, endpoint_path)

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

Expand Down
24 changes: 21 additions & 3 deletions proxy/policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,15 +104,26 @@ def extract_operation(path: str) -> str | None:
return None


def has_wildcard_zone(zone_policies: list[ZonePolicy]) -> bool:
"""Return True if any zone policy is a wildcard (name='*')."""
return any(zp.name == "*" for zp in zone_policies)


def find_zone_policy(
zone: str, zone_policies: list[ZonePolicy],
) -> ZonePolicy | None:
"""Find the ZonePolicy matching the given zone name (case-insensitive)."""
"""Find the ZonePolicy matching the given zone name (case-insensitive).

Falls back to a wildcard policy (name='*') if no exact match is found.
"""
zone_lower = zone.lower()
wildcard: ZonePolicy | None = None
for zp in zone_policies:
if zp.name.lower() == zone_lower:
return zp
return None
if zp.name == "*":
wildcard = zp
return wildcard


def evaluate_policy(
Expand Down Expand Up @@ -156,16 +167,19 @@ def resolve_zone(

Priority: ?zone= takes precedence over ?domain=.
For ?domain=, strips leftmost labels until a configured zone matches.
If a wildcard ('*') is in configured_zones, accepts any zone/domain.
Returns None if no zone can be determined.
"""
has_wildcard = "*" in configured_zones

if zone_param:
return zone_param

if not domain_param:
return None

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

# Wildcard: accept the domain as-is (Technitium resolves the actual zone)
if has_wildcard:
return domain_param

return None
79 changes: 79 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,3 +126,82 @@ def test_zone_policy_defaults(self) -> None:
assert zp.allowed_record_types == []
assert zp.allowed_operations == []
assert zp.subdomain_filter is None


class TestZonePolicyExpansion:
"""Tests for 'names' shorthand expansion in zone policies."""

def test_names_expands_to_multiple_policies(self, tmp_path: Path) -> None:
config_file = tmp_path / "config.yml"
config_file.write_text(
"""\
technitium:
url: "http://dns:5380"
token: "admin-tok"
tokens:
- name: "acme"
token: "acme-tok"
zones:
- names: ["example.com", "other.org", "third.io"]
allowed_record_types: ["TXT"]
allowed_operations: ["add", "delete"]
subdomain_filter: "^_acme-challenge\\\\."
"""
)
os.environ["CONFIG_PATH"] = str(config_file)
cfg = load_config()

zones = cfg.tokens[0].zones
assert len(zones) == 3
assert [z.name for z in zones] == ["example.com", "other.org", "third.io"]
for z in zones:
assert z.allowed_record_types == ["TXT"]
assert z.allowed_operations == ["add", "delete"]
assert z.subdomain_filter == "^_acme-challenge\\."

def test_names_mixed_with_name(self, tmp_path: Path) -> None:
config_file = tmp_path / "config.yml"
config_file.write_text(
"""\
technitium:
url: "http://dns:5380"
token: "admin-tok"
tokens:
- name: "mixed"
token: "mixed-tok"
zones:
- name: "single.com"
allowed_record_types: ["A"]
- names: ["multi1.com", "multi2.com"]
allowed_record_types: ["TXT"]
"""
)
os.environ["CONFIG_PATH"] = str(config_file)
cfg = load_config()

zones = cfg.tokens[0].zones
assert len(zones) == 3
assert zones[0].name == "single.com"
assert zones[0].allowed_record_types == ["A"]
assert zones[1].name == "multi1.com"
assert zones[1].allowed_record_types == ["TXT"]
assert zones[2].name == "multi2.com"
assert zones[2].allowed_record_types == ["TXT"]

def test_neither_name_nor_names_raises(self, tmp_path: Path) -> None:
config_file = tmp_path / "config.yml"
config_file.write_text(
"""\
technitium:
url: "http://dns:5380"
token: "admin-tok"
tokens:
- name: "bad"
token: "bad-tok"
zones:
- allowed_record_types: ["TXT"]
"""
)
os.environ["CONFIG_PATH"] = str(config_file)
with pytest.raises(ValueError, match="name.*names"):
load_config()
Loading
Loading