diff --git a/README.md b/README.md index f3fc8ae..e808f43 100644 --- a/README.md +++ b/README.md @@ -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 @@ -75,12 +78,12 @@ 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: @@ -88,14 +91,31 @@ tokens: 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"] @@ -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. --- @@ -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) | --- diff --git a/config.example.yml b/config.example.yml index 5d6ca9c..641f053 100644 --- a/config.example.yml +++ b/config.example.yml @@ -1,10 +1,10 @@ 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: @@ -12,14 +12,31 @@ tokens: 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"] diff --git a/proxy/auth.py b/proxy/auth.py index bd61841..e93d88e 100644 --- a/proxy/auth.py +++ b/proxy/auth.py @@ -1,5 +1,7 @@ from __future__ import annotations +import hmac + from fastapi import Header, Query, Request from fastapi.responses import JSONResponse @@ -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 diff --git a/proxy/config.py b/proxy/config.py index c233bb5..fb03a0a 100644 --- a/proxy/config.py +++ b/proxy/config.py @@ -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 @@ -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) diff --git a/proxy/main.py b/proxy/main.py index 93f9ba8..3209cb2 100644 --- a/proxy/main.py +++ b/proxy/main.py @@ -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 @@ -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() @@ -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) diff --git a/proxy/policy.py b/proxy/policy.py index 00de3a2..29c9021 100644 --- a/proxy/policy.py +++ b/proxy/policy.py @@ -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( @@ -156,8 +167,11 @@ 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 @@ -165,7 +179,7 @@ def resolve_zone( 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: @@ -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 diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 6fc6c8f..ed99aed 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -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']}" diff --git a/tests/test_config.py b/tests/test_config.py index 029332f..f02f6c6 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -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() diff --git a/tests/test_policy.py b/tests/test_policy.py index 6f9619b..24a57c4 100644 --- a/tests/test_policy.py +++ b/tests/test_policy.py @@ -2,7 +2,7 @@ from __future__ import annotations from proxy.config import ZonePolicy -from proxy.policy import evaluate_policy, resolve_zone +from proxy.policy import evaluate_policy, find_zone_policy, has_wildcard_zone, resolve_zone class TestResolveZoneFromParam: @@ -167,3 +167,57 @@ def test_regex_pattern_multiple_subdomains(self) -> None: "example.com", policies, "/api/zones/records/get", "mail.example.com", None, ) assert result is not None + + +class TestWildcardZone: + """Tests for wildcard zone ('*') support.""" + + def test_has_wildcard_zone_true(self) -> None: + policies = [ZonePolicy(name="*", allowed_record_types=["TXT"])] + assert has_wildcard_zone(policies) is True + + def test_has_wildcard_zone_false(self) -> None: + policies = [ZonePolicy(name="example.com")] + assert has_wildcard_zone(policies) is False + + def test_find_zone_policy_wildcard_fallback(self) -> None: + wildcard = ZonePolicy(name="*", allowed_record_types=["TXT"]) + policies = [ZonePolicy(name="example.com"), wildcard] + assert find_zone_policy("example.com", policies).name == "example.com" + assert find_zone_policy("other.com", policies) is wildcard + + def test_resolve_zone_wildcard_with_domain(self) -> None: + result = resolve_zone(None, "_acme-challenge.example.com", ["*"]) + assert result == "_acme-challenge.example.com" + + def test_resolve_zone_wildcard_with_zone_param(self) -> None: + result = resolve_zone("example.com", None, ["*"]) + assert result == "example.com" + + def test_resolve_zone_wildcard_prefers_explicit_match(self) -> None: + result = resolve_zone(None, "_acme-challenge.example.com", ["*", "example.com"]) + assert result == "example.com" + + def test_evaluate_policy_wildcard_allows_any_zone(self) -> None: + policies = [ZonePolicy(name="*", allowed_record_types=["TXT"], allowed_operations=["add", "delete"])] + result = evaluate_policy("anydomain.com", policies, "/api/zones/records/add", None, "TXT") + assert result is None + + def test_evaluate_policy_wildcard_enforces_record_type(self) -> None: + policies = [ZonePolicy(name="*", allowed_record_types=["TXT"])] + result = evaluate_policy("anydomain.com", policies, "/api/zones/records/add", None, "A") + assert result is not None + assert "record type" in result + + def test_evaluate_policy_wildcard_enforces_subdomain_filter(self) -> None: + policies = [ZonePolicy(name="*", subdomain_filter=r"^_acme-challenge\.")] + result = evaluate_policy("example.com", policies, "/api/zones/records/add", "_acme-challenge.example.com", None) + assert result is None + result = evaluate_policy("example.com", policies, "/api/zones/records/add", "www.example.com", None) + assert result is not None + + def test_evaluate_policy_wildcard_enforces_operations(self) -> None: + policies = [ZonePolicy(name="*", allowed_operations=["add", "delete"])] + result = evaluate_policy("example.com", policies, "/api/zones/records/get", None, None) + assert result is not None + assert "operation" in result