from __future__ import annotations from pathlib import Path import threading from fastapi import FastAPI, HTTPException from fastapi.responses import FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles from app.config import get_settings from app.docker_api import DockerApiClient, DockerApiError, DockerUsageResolver from app.dns_sync import DnsSyncError, build_dns_provider from app.ip_commands import CommandError, IpAddressManager from app.service import ( ConflictError, DependencyError, EntryService, NotFoundError, ValidationError, ) from app.storage import EntryStorage def build_service() -> EntryService: settings = get_settings() storage = EntryStorage(settings.state_file) docker_client = DockerApiClient(settings.docker_api_url, timeout_seconds=settings.docker_timeout_seconds) usage_resolver = DockerUsageResolver(docker_client) ip_manager = IpAddressManager() dns_provider = build_dns_provider( settings.dns_provider, adguard_url=settings.adguard_url, adguard_username=settings.adguard_username, adguard_password=settings.adguard_password, rfc2136_server=settings.rfc2136_server, rfc2136_zone=settings.rfc2136_zone, rfc2136_port=settings.rfc2136_port, rfc2136_tsig_key_name=settings.rfc2136_tsig_key_name, rfc2136_tsig_secret=settings.rfc2136_tsig_secret, rfc2136_tsig_algorithm=settings.rfc2136_tsig_algorithm, timeout_seconds=settings.docker_timeout_seconds, ) return EntryService( storage=storage, usage_resolver=usage_resolver, ip_manager=ip_manager, dns_provider=dns_provider, dns_base_domain=settings.dns_base_domain, dns_ttl_seconds=settings.dns_ttl_seconds, ) service = build_service() app = FastAPI(title="Docker IP Addr Manager", version="0.1.0") settings = get_settings() stop_event = threading.Event() background_thread: threading.Thread | None = None static_dir = Path(__file__).parent / "static" app.mount("/static", StaticFiles(directory=static_dir), name="static") @app.on_event("startup") def startup_reconcile() -> None: errors = service.reconcile_enabled_entries() if errors: for error in errors: print(f"[startup-reconcile] {error}") dns_errors = service.reconcile_dns_records() if dns_errors: for error in dns_errors: print(f"[dns-reconcile-startup] {error}") _start_dns_background_loop() @app.on_event("shutdown") def shutdown_reconcile() -> None: stop_event.set() if background_thread and background_thread.is_alive(): background_thread.join(timeout=2.0) def _dns_background_worker(interval_seconds: float) -> None: while not stop_event.wait(interval_seconds): errors = service.reconcile_dns_records() for error in errors: print(f"[dns-reconcile] {error}") def _start_dns_background_loop() -> None: global background_thread if settings.dns_provider.strip().lower() in {"", "none"}: return if background_thread and background_thread.is_alive(): return background_thread = threading.Thread( target=_dns_background_worker, args=(max(settings.dns_sync_interval_seconds, 1.0),), daemon=True, ) background_thread.start() @app.get("/") def index() -> FileResponse: return FileResponse(static_dir / "index.html") @app.get("/healthz") def healthz() -> dict: return {"ok": True} @app.get("/api/interfaces") def list_interfaces() -> dict: try: return {"items": service.list_interfaces()} except DependencyError as exc: raise HTTPException(status_code=503, detail=str(exc)) from exc @app.get("/api/entries") def list_entries() -> dict: response = service.list_entries() return response.to_dict() @app.post("/api/refresh") def refresh_entries() -> dict: response = service.list_entries() return response.to_dict() @app.post("/api/entries") def create_entry(payload: dict) -> dict: try: entry = service.create_entry(payload) return entry.to_dict() except ValidationError as exc: raise HTTPException(status_code=422, detail=str(exc)) from exc except ConflictError as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc @app.put("/api/entries/{entry_id}") def update_entry(entry_id: str, payload: dict) -> dict: try: entry = service.update_entry(entry_id, payload) return entry.to_dict() except ValidationError as exc: raise HTTPException(status_code=422, detail=str(exc)) from exc except ConflictError as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc except NotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc @app.post("/api/entries/{entry_id}/enable") def enable_entry(entry_id: str) -> dict: try: entry = service.set_enabled(entry_id, enabled=True) return entry.to_dict() except NotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc except (ConflictError, CommandError) as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc except DependencyError as exc: raise HTTPException(status_code=503, detail=str(exc)) from exc @app.post("/api/entries/{entry_id}/disable") def disable_entry(entry_id: str) -> dict: try: entry = service.set_enabled(entry_id, enabled=False) return entry.to_dict() except NotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc except (ConflictError, CommandError) as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc except DependencyError as exc: raise HTTPException(status_code=503, detail=str(exc)) from exc @app.delete("/api/entries/{entry_id}") def delete_entry(entry_id: str) -> dict: try: service.delete_entry(entry_id) return {"deleted": True} except NotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc except ConflictError as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc except DependencyError as exc: raise HTTPException(status_code=503, detail=str(exc)) from exc @app.exception_handler(DockerApiError) async def docker_error_handler(_, exc: DockerApiError): return JSONResponse(status_code=503, content={"detail": str(exc)}) @app.exception_handler(DnsSyncError) async def dns_error_handler(_, exc: DnsSyncError): return JSONResponse(status_code=503, content={"detail": str(exc)})