diff --git a/README.md b/README.md index 98b2fa0..e808f43 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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) | --- diff --git a/proxy/main.py b/proxy/main.py index 28a9944..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()