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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ Clients use the standard [Technitium API](https://github.com/TechnitiumSoftware/
- 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 @@ -178,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
26 changes: 26 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
Loading