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
36 changes: 30 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,15 @@ 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
- Global read-only tokens that allow full read access across all zones without write permissions
- Tiered endpoint classification where only record and zone-list endpoints are proxied; zone management and admin endpoints are blocked
- Zone list filtering where `/api/zones/list` responses only show zones the token is allowed to access
- Hot reload of configuration on file change (no restart required)
- Structured audit logging via structlog
- Multi-arch Docker images (linux/amd64, linux/arm64)
- Standalone binary builds via PyInstaller
Expand Down Expand Up @@ -75,27 +78,44 @@ 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:
- name: "example.com"
subdomain_filter: "^app\\."
subdomain_filter: '^app\.'
allowed_record_types: ["A", "AAAA", "CNAME"]
allowed_operations: ["list", "get", "add", "update", "delete"]

Expand All @@ -118,11 +138,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 Expand Up @@ -156,6 +179,7 @@ bin/start.sh
| `HOST` | `0.0.0.0` | Host/IP to bind |
| `PORT` | `31399` | Port to bind |
| `LOG_LEVEL` | `info` | Log level (`debug`, `info`, `warning`, `error`) |
| `RELOAD_INTERVAL` | `5` | Seconds between config file change checks (0 to disable) |

---

Expand Down
27 changes: 22 additions & 5 deletions config.example.yml
Original file line number Diff line number Diff line change
@@ -1,25 +1,42 @@
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:
- name: "example.com"
subdomain_filter: "^app\\."
subdomain_filter: '^app\.'
allowed_record_types: ["A", "AAAA", "CNAME"]
allowed_operations: ["list", "get", "add", "update", "delete"]

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)
28 changes: 28 additions & 0 deletions proxy/main.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
from __future__ import annotations

import asyncio
import json
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any, AsyncIterator

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

audit_log = structlog.get_logger("proxy.audit")
_reload_log = structlog.get_logger("proxy.reload")

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

_CONFIG_PATH = Path(os.environ.get("CONFIG_PATH", "config.yml"))
_RELOAD_INTERVAL = int(os.environ.get("RELOAD_INTERVAL", "5"))


async def _watch_config(app: FastAPI) -> None:
"""Poll config file for changes and hot-reload on modification."""
last_mtime: float = _CONFIG_PATH.stat().st_mtime if _CONFIG_PATH.exists() else 0
while True:
await asyncio.sleep(_RELOAD_INTERVAL)
try:
current_mtime = _CONFIG_PATH.stat().st_mtime
if current_mtime <= last_mtime:
continue
last_mtime = current_mtime
new_config = load_config()
app.state.config = new_config
_reload_log.info("config_reloaded", config_path=str(_CONFIG_PATH))
except Exception as exc:
_reload_log.error("config_reload_failed", error=str(exc), config_path=str(_CONFIG_PATH))


@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
app.state.config = _config
app.state.http_client = httpx.AsyncClient(verify=_config.technitium.verify_ssl)
watcher = asyncio.create_task(_watch_config(app)) if _RELOAD_INTERVAL > 0 else None
yield
if watcher is not None:
watcher.cancel()
await app.state.http_client.aclose()


Expand Down Expand Up @@ -135,6 +161,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
2 changes: 1 addition & 1 deletion tests/e2e/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ def _build_config_yaml(admin_token: str) -> str:
token: "{TOKENS['subdomain_filtered']}"
zones:
- name: "{ZONE_ALLOWED}"
subdomain_filter: "^app\\."
subdomain_filter: '^app\\.'

- name: "readonly"
token: "{TOKENS['readonly']}"
Expand Down
Loading
Loading