95cd7d9ba8
- Apps/snacks/: docker-compose.yaml (2.3.1, host networking, privileged, /dev/dri) and README.md with full security exception documentation for: network_mode:host, privileged:true, device mount /dev/dri - apps.md: converted to agent-readable table backlog with instructions for future apps - Jellyfin-ffmpeg paths as defaults, 1G memory reservation, amd64 only (single-arch image) - Validation: ./scripts/validate-appstore.sh passes
205 lines
6.6 KiB
Python
205 lines
6.6 KiB
Python
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)})
|