Add Snacks app: automated video library encoder with hardware acceleration (#6)
Co-authored-by: Joachim Friberg <joachim.friberg@ip-solutions.se> Reviewed-on: phirna/zima-apps#6
This commit is contained in:
@@ -10,6 +10,20 @@ class Settings:
|
||||
docker_api_url: str
|
||||
docker_timeout_seconds: float
|
||||
app_port: int
|
||||
dns_provider: str
|
||||
dns_base_domain: str
|
||||
dns_ttl_seconds: int
|
||||
dns_sync_interval_seconds: float
|
||||
adguard_url: str
|
||||
adguard_username: str
|
||||
adguard_password: str
|
||||
adguard_api_token: str
|
||||
rfc2136_server: str
|
||||
rfc2136_zone: str
|
||||
rfc2136_port: int
|
||||
rfc2136_tsig_key_name: str
|
||||
rfc2136_tsig_secret: str
|
||||
rfc2136_tsig_algorithm: str
|
||||
|
||||
|
||||
def get_settings() -> Settings:
|
||||
@@ -18,4 +32,18 @@ def get_settings() -> Settings:
|
||||
docker_api_url=os.getenv("DOCKER_API_URL", "unix:///var/run/docker.sock"),
|
||||
docker_timeout_seconds=float(os.getenv("DOCKER_TIMEOUT_SECONDS", "3")),
|
||||
app_port=int(os.getenv("APP_PORT", "31810")),
|
||||
dns_provider=os.getenv("DNS_PROVIDER", "none"),
|
||||
dns_base_domain=os.getenv("DNS_BASE_DOMAIN", ""),
|
||||
dns_ttl_seconds=int(os.getenv("DNS_TTL_SECONDS", "120")),
|
||||
dns_sync_interval_seconds=float(os.getenv("DNS_SYNC_INTERVAL_SECONDS", "15")),
|
||||
adguard_url=os.getenv("ADGUARD_URL", ""),
|
||||
adguard_username=os.getenv("ADGUARD_USERNAME", ""),
|
||||
adguard_password=os.getenv("ADGUARD_PASSWORD", ""),
|
||||
adguard_api_token=os.getenv("ADGUARD_API_TOKEN", ""),
|
||||
rfc2136_server=os.getenv("RFC2136_SERVER", ""),
|
||||
rfc2136_zone=os.getenv("RFC2136_ZONE", ""),
|
||||
rfc2136_port=int(os.getenv("RFC2136_PORT", "53")),
|
||||
rfc2136_tsig_key_name=os.getenv("RFC2136_TSIG_KEY_NAME", ""),
|
||||
rfc2136_tsig_secret=os.getenv("RFC2136_TSIG_SECRET", ""),
|
||||
rfc2136_tsig_algorithm=os.getenv("RFC2136_TSIG_ALGORITHM", "hmac-sha256"),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import base64
|
||||
import http.client
|
||||
import json
|
||||
from typing import Protocol
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
class DnsSyncError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class DnsProvider(Protocol):
|
||||
def upsert_a_record(self, fqdn: str, ip: str, ttl: int) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
def delete_a_record(self, fqdn: str) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
def to_fqdn(entry_name: str, base_domain: str) -> str:
|
||||
label = _sanitize_label(entry_name)
|
||||
domain = base_domain.strip().lower().strip(".")
|
||||
if not domain:
|
||||
raise DnsSyncError("DNS_BASE_DOMAIN is required when DNS is enabled")
|
||||
return f"{label}.{domain}"
|
||||
|
||||
|
||||
def _sanitize_label(value: str) -> str:
|
||||
source = value.strip().lower()
|
||||
if not source:
|
||||
raise DnsSyncError("Entry name is required to create DNS record")
|
||||
|
||||
cleaned: list[str] = []
|
||||
prev_dash = False
|
||||
for ch in source:
|
||||
if "a" <= ch <= "z" or "0" <= ch <= "9":
|
||||
cleaned.append(ch)
|
||||
prev_dash = False
|
||||
continue
|
||||
if ch in {" ", "_", "-"} and not prev_dash:
|
||||
cleaned.append("-")
|
||||
prev_dash = True
|
||||
|
||||
label = "".join(cleaned).strip("-")
|
||||
if not label:
|
||||
raise DnsSyncError(f"Entry name cannot produce DNS-safe label: {value!r}")
|
||||
if len(label) > 63:
|
||||
raise DnsSyncError("DNS label derived from entry name is too long (max 63)")
|
||||
return label
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AdguardConfig:
|
||||
url: str
|
||||
username: str
|
||||
password: str
|
||||
timeout_seconds: float
|
||||
|
||||
|
||||
class AdguardDnsProvider:
|
||||
def __init__(self, config: AdguardConfig):
|
||||
parsed = urlparse(config.url)
|
||||
if parsed.scheme not in {"http", "https"}:
|
||||
raise ValueError("ADGUARD_URL must use http or https")
|
||||
if not parsed.netloc:
|
||||
raise ValueError("ADGUARD_URL must include host")
|
||||
|
||||
self._https = parsed.scheme == "https"
|
||||
self._host = parsed.hostname or "localhost"
|
||||
self._port = parsed.port
|
||||
self._base_path = parsed.path.rstrip("/")
|
||||
self._username = config.username
|
||||
self._password = config.password
|
||||
self._timeout = config.timeout_seconds
|
||||
self._session_cookie: str | None = None
|
||||
|
||||
def upsert_a_record(self, fqdn: str, ip: str, ttl: int) -> None:
|
||||
del ttl # AdGuard rewrite records do not expose TTL controls.
|
||||
rewrites = self._list_rewrites()
|
||||
for item in rewrites:
|
||||
if item.get("domain") == fqdn and item.get("answer") == ip:
|
||||
return
|
||||
if item.get("domain") == fqdn and item.get("answer") != ip:
|
||||
self._request("POST", "/control/rewrite/delete", {"domain": fqdn, "answer": item.get("answer", "")})
|
||||
self._request("POST", "/control/rewrite/add", {"domain": fqdn, "answer": ip})
|
||||
|
||||
def delete_a_record(self, fqdn: str) -> None:
|
||||
rewrites = self._list_rewrites()
|
||||
for item in rewrites:
|
||||
if item.get("domain") != fqdn:
|
||||
continue
|
||||
self._request("POST", "/control/rewrite/delete", {"domain": fqdn, "answer": item.get("answer", "")})
|
||||
|
||||
def _list_rewrites(self) -> list[dict]:
|
||||
payload = self._request("GET", "/control/rewrite/list", None)
|
||||
if not isinstance(payload, list):
|
||||
raise DnsSyncError("AdGuard returned unexpected rewrite list format")
|
||||
output: list[dict] = []
|
||||
for item in payload:
|
||||
if isinstance(item, dict):
|
||||
output.append(item)
|
||||
return output
|
||||
|
||||
def _request(self, method: str, path: str, payload: dict | None) -> object:
|
||||
if self._session_cookie is None:
|
||||
self._login()
|
||||
return self._request_with_session(method, path, payload, retry_on_auth=True)
|
||||
|
||||
def _login(self) -> None:
|
||||
body = {"name": self._username, "password": self._password}
|
||||
payload, headers = self._raw_request("POST", "/control/login", body, include_auth=False)
|
||||
if headers is None:
|
||||
raise DnsSyncError("AdGuard login failed: missing response headers")
|
||||
cookie = headers.get("set-cookie", "")
|
||||
session = ""
|
||||
for piece in cookie.split(";"):
|
||||
piece = piece.strip()
|
||||
if piece.startswith("agh_session="):
|
||||
session = piece
|
||||
break
|
||||
if not session:
|
||||
raise DnsSyncError("AdGuard login failed: no agh_session cookie")
|
||||
self._session_cookie = session
|
||||
del payload
|
||||
|
||||
def _request_with_session(self, method: str, path: str, payload: dict | None, retry_on_auth: bool) -> object:
|
||||
body, _ = self._raw_request(method, path, payload, include_auth=True)
|
||||
if isinstance(body, dict) and body.get("message") == "unauthorized":
|
||||
if retry_on_auth:
|
||||
self._session_cookie = None
|
||||
self._login()
|
||||
return self._request_with_session(method, path, payload, retry_on_auth=False)
|
||||
raise DnsSyncError("AdGuard request unauthorized")
|
||||
return body
|
||||
|
||||
def _raw_request(
|
||||
self, method: str, path: str, payload: dict | None, include_auth: bool
|
||||
) -> tuple[object, dict[str, str] | None]:
|
||||
conn: http.client.HTTPConnection | http.client.HTTPSConnection
|
||||
if self._https:
|
||||
conn = http.client.HTTPSConnection(self._host, self._port, timeout=self._timeout)
|
||||
else:
|
||||
conn = http.client.HTTPConnection(self._host, self._port, timeout=self._timeout)
|
||||
|
||||
request_path = f"{self._base_path}{path}"
|
||||
raw = ""
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if include_auth and self._session_cookie:
|
||||
headers["Cookie"] = self._session_cookie
|
||||
if payload is not None:
|
||||
raw = json.dumps(payload)
|
||||
|
||||
try:
|
||||
conn.request(method, request_path, body=raw, headers=headers)
|
||||
response = conn.getresponse()
|
||||
body_text = response.read().decode("utf-8", errors="replace")
|
||||
response_headers = {k.lower(): v for k, v in response.getheaders()}
|
||||
except OSError as exc:
|
||||
raise DnsSyncError(f"AdGuard request failed for {path}: {exc}") from exc
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if response.status < 200 or response.status >= 300:
|
||||
raise DnsSyncError(
|
||||
f"AdGuard request failed for {path}: HTTP {response.status} {response.reason}; body={body_text[:400]}"
|
||||
)
|
||||
|
||||
if not body_text.strip():
|
||||
return {}, response_headers
|
||||
try:
|
||||
return json.loads(body_text), response_headers
|
||||
except json.JSONDecodeError:
|
||||
return body_text, response_headers
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Rfc2136Config:
|
||||
server: str
|
||||
zone: str
|
||||
port: int
|
||||
timeout_seconds: float
|
||||
tsig_key_name: str
|
||||
tsig_secret: str
|
||||
tsig_algorithm: str
|
||||
|
||||
|
||||
class Rfc2136DnsProvider:
|
||||
def __init__(self, config: Rfc2136Config):
|
||||
if not config.server.strip():
|
||||
raise ValueError("RFC2136_SERVER is required")
|
||||
if not config.zone.strip():
|
||||
raise ValueError("RFC2136_ZONE is required")
|
||||
|
||||
self._server = config.server.strip()
|
||||
self._zone = config.zone.strip().rstrip(".")
|
||||
self._port = config.port
|
||||
self._timeout = config.timeout_seconds
|
||||
self._key_name = config.tsig_key_name.strip()
|
||||
self._secret = config.tsig_secret.strip()
|
||||
self._algorithm = config.tsig_algorithm.strip() or "hmac-sha256"
|
||||
|
||||
def upsert_a_record(self, fqdn: str, ip: str, ttl: int) -> None:
|
||||
rcode, tsigkeyring, update, query = self._dns_modules()
|
||||
zone_text = self._zone_with_dot()
|
||||
keyring = self._keyring_or_none(tsigkeyring)
|
||||
target = self._absolute_name(fqdn)
|
||||
try:
|
||||
req = update.Update(zone_text, keyring=keyring, keyname=self._key_name or None, keyalgorithm=self._algorithm)
|
||||
req.delete(target, "A")
|
||||
req.add(target, int(ttl), "A", ip)
|
||||
response = query.tcp(req, self._server, port=self._port, timeout=self._timeout)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise DnsSyncError(f"RFC2136 upsert failed for {fqdn} -> {ip}: {exc}") from exc
|
||||
if response.rcode() != rcode.NOERROR:
|
||||
text = rcode.to_text(response.rcode())
|
||||
raise DnsSyncError(f"RFC2136 upsert failed for {fqdn}: {text}")
|
||||
|
||||
def delete_a_record(self, fqdn: str) -> None:
|
||||
rcode, tsigkeyring, update, query = self._dns_modules()
|
||||
zone_text = self._zone_with_dot()
|
||||
keyring = self._keyring_or_none(tsigkeyring)
|
||||
target = self._absolute_name(fqdn)
|
||||
try:
|
||||
req = update.Update(zone_text, keyring=keyring, keyname=self._key_name or None, keyalgorithm=self._algorithm)
|
||||
req.delete(target, "A")
|
||||
response = query.tcp(req, self._server, port=self._port, timeout=self._timeout)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise DnsSyncError(f"RFC2136 delete failed for {fqdn}: {exc}") from exc
|
||||
if response.rcode() != rcode.NOERROR:
|
||||
text = rcode.to_text(response.rcode())
|
||||
raise DnsSyncError(f"RFC2136 delete failed for {fqdn}: {text}")
|
||||
|
||||
def _dns_modules(self):
|
||||
try:
|
||||
import dns.query as query
|
||||
import dns.rcode as rcode
|
||||
import dns.tsigkeyring as tsigkeyring
|
||||
import dns.update as update
|
||||
except ImportError as exc:
|
||||
raise DnsSyncError("dnspython is required for RFC2136 mode") from exc
|
||||
return rcode, tsigkeyring, update, query
|
||||
|
||||
def _keyring_or_none(self, tsigkeyring):
|
||||
if not self._key_name and not self._secret:
|
||||
return None
|
||||
if not self._key_name or not self._secret:
|
||||
raise DnsSyncError("RFC2136 TSIG requires both key name and secret")
|
||||
key_name = self._key_name if self._key_name.endswith(".") else f"{self._key_name}."
|
||||
try:
|
||||
base64.b64decode(self._secret, validate=True)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise DnsSyncError("RFC2136_TSIG_SECRET must be valid base64") from exc
|
||||
if self._algorithm not in {"hmac-sha256", "hmac-sha512", "hmac-sha1", "hmac-md5.sig-alg.reg.int"}:
|
||||
raise DnsSyncError(f"Unsupported TSIG algorithm: {self._algorithm}")
|
||||
return tsigkeyring.from_text({key_name: self._secret})
|
||||
|
||||
def _zone_with_dot(self) -> str:
|
||||
return self._zone if self._zone.endswith(".") else f"{self._zone}."
|
||||
|
||||
def _absolute_name(self, fqdn: str) -> str:
|
||||
return fqdn if fqdn.endswith(".") else f"{fqdn}."
|
||||
|
||||
|
||||
def build_dns_provider(
|
||||
provider_name: str,
|
||||
*,
|
||||
adguard_url: str,
|
||||
adguard_username: str,
|
||||
adguard_password: str,
|
||||
rfc2136_server: str,
|
||||
rfc2136_zone: str,
|
||||
rfc2136_port: int,
|
||||
rfc2136_tsig_key_name: str,
|
||||
rfc2136_tsig_secret: str,
|
||||
rfc2136_tsig_algorithm: str,
|
||||
timeout_seconds: float,
|
||||
) -> DnsProvider | None:
|
||||
mode = provider_name.strip().lower()
|
||||
if not mode or mode == "none":
|
||||
return None
|
||||
if mode == "adguard":
|
||||
if not adguard_url.strip():
|
||||
raise DnsSyncError("ADGUARD_URL is required for DNS_PROVIDER=adguard")
|
||||
if not adguard_username.strip() or not adguard_password.strip():
|
||||
raise DnsSyncError("ADGUARD_USERNAME and ADGUARD_PASSWORD are required for DNS_PROVIDER=adguard")
|
||||
return AdguardDnsProvider(
|
||||
AdguardConfig(
|
||||
url=adguard_url,
|
||||
username=adguard_username,
|
||||
password=adguard_password,
|
||||
timeout_seconds=timeout_seconds,
|
||||
)
|
||||
)
|
||||
if mode == "rfc2136":
|
||||
return Rfc2136DnsProvider(
|
||||
Rfc2136Config(
|
||||
server=rfc2136_server,
|
||||
zone=rfc2136_zone,
|
||||
port=rfc2136_port,
|
||||
timeout_seconds=timeout_seconds,
|
||||
tsig_key_name=rfc2136_tsig_key_name,
|
||||
tsig_secret=rfc2136_tsig_secret,
|
||||
tsig_algorithm=rfc2136_tsig_algorithm,
|
||||
)
|
||||
)
|
||||
raise DnsSyncError(f"Unsupported DNS_PROVIDER: {provider_name}")
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import threading
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
@@ -8,6 +9,7 @@ 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,
|
||||
@@ -25,11 +27,34 @@ def build_service() -> EntryService:
|
||||
docker_client = DockerApiClient(settings.docker_api_url, timeout_seconds=settings.docker_timeout_seconds)
|
||||
usage_resolver = DockerUsageResolver(docker_client)
|
||||
ip_manager = IpAddressManager()
|
||||
return EntryService(storage=storage, usage_resolver=usage_resolver, ip_manager=ip_manager)
|
||||
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")
|
||||
@@ -41,6 +66,39 @@ def startup_reconcile() -> None:
|
||||
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("/")
|
||||
@@ -139,3 +197,8 @@ def delete_entry(entry_id: str) -> dict:
|
||||
@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)})
|
||||
|
||||
@@ -46,6 +46,8 @@ class EntryView:
|
||||
used: bool
|
||||
containers: list[str]
|
||||
usage_known: bool
|
||||
dns_desired: bool = False
|
||||
dns_last_error: str | None = None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
@@ -59,4 +61,6 @@ class EntryView:
|
||||
"used": self.used,
|
||||
"containers": self.containers,
|
||||
"usage_known": self.usage_known,
|
||||
"dns_desired": self.dns_desired,
|
||||
"dns_last_error": self.dns_last_error,
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ from typing import Callable
|
||||
from uuid import uuid4
|
||||
|
||||
from app.docker_api import DockerApiError, DockerUsageResolver
|
||||
from app.dns_sync import DnsProvider, DnsSyncError, to_fqdn
|
||||
from app.interfaces import list_host_interfaces
|
||||
from app.ip_commands import CommandError, IpAddressManager
|
||||
from app.models import EntryView, IpEntry
|
||||
@@ -50,12 +51,19 @@ class EntryService:
|
||||
usage_resolver: DockerUsageResolver,
|
||||
ip_manager: IpAddressManager,
|
||||
interface_provider: Callable[[], list[str]] = list_host_interfaces,
|
||||
dns_provider: DnsProvider | None = None,
|
||||
dns_base_domain: str = "",
|
||||
dns_ttl_seconds: int = 120,
|
||||
):
|
||||
self._storage = storage
|
||||
self._usage_resolver = usage_resolver
|
||||
self._ip_manager = ip_manager
|
||||
self._interface_provider = interface_provider
|
||||
self._dns_provider = dns_provider
|
||||
self._dns_base_domain = dns_base_domain
|
||||
self._dns_ttl_seconds = dns_ttl_seconds
|
||||
self._lock = threading.Lock()
|
||||
self._dns_errors_by_id: dict[str, str] = {}
|
||||
|
||||
def list_interfaces(self) -> list[str]:
|
||||
interfaces = self._interface_provider()
|
||||
@@ -89,6 +97,8 @@ class EntryService:
|
||||
used=used,
|
||||
containers=containers,
|
||||
usage_known=usage_known,
|
||||
dns_desired=bool(self._dns_provider) and usage_known and used and entry.enabled,
|
||||
dns_last_error=self._dns_errors_by_id.get(entry.id),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -100,6 +110,7 @@ class EntryService:
|
||||
entries = self._storage.list_entries()
|
||||
self._assert_device_exists(parsed["device"])
|
||||
self._assert_unique_binding(entries, ip=parsed["ip"], cidr=parsed["cidr"], device=parsed["device"])
|
||||
self._assert_unique_name(entries, name=parsed["name"])
|
||||
|
||||
created = IpEntry(
|
||||
id=uuid4().hex,
|
||||
@@ -129,6 +140,7 @@ class EntryService:
|
||||
device=parsed["device"],
|
||||
ignore_entry_id=entry_id,
|
||||
)
|
||||
self._assert_unique_name(entries, name=parsed["name"], ignore_entry_id=entry_id)
|
||||
updated = IpEntry(
|
||||
id=current.id,
|
||||
name=parsed["name"],
|
||||
@@ -145,6 +157,7 @@ class EntryService:
|
||||
with self._lock:
|
||||
entries = self._storage.list_entries()
|
||||
index, entry = _find_entry(entries, entry_id)
|
||||
previous_enabled = entry.enabled
|
||||
|
||||
if enabled:
|
||||
self._ip_manager.ensure_present(entry.ip, entry.cidr, entry.device)
|
||||
@@ -154,6 +167,12 @@ class EntryService:
|
||||
self._ip_manager.ensure_absent(entry.ip, entry.cidr, entry.device)
|
||||
entry.enabled = False
|
||||
|
||||
try:
|
||||
self._sync_dns_for_entry_locked(entry, strict=True)
|
||||
except Exception: # noqa: BLE001
|
||||
self._rollback_enable_change(entry, previous_enabled)
|
||||
raise
|
||||
|
||||
entries[index] = entry
|
||||
self._storage.save_entries(entries)
|
||||
return entry
|
||||
@@ -166,8 +185,10 @@ class EntryService:
|
||||
raise ConflictError("Disable entry before deleting")
|
||||
|
||||
self._assert_not_used(entry)
|
||||
self._delete_dns_for_entry_locked(entry, strict=True)
|
||||
remaining = [candidate for candidate in entries if candidate.id != entry_id]
|
||||
self._storage.save_entries(remaining)
|
||||
self._dns_errors_by_id.pop(entry.id, None)
|
||||
|
||||
def reconcile_enabled_entries(self) -> list[str]:
|
||||
errors: list[str] = []
|
||||
@@ -186,6 +207,84 @@ class EntryService:
|
||||
self._storage.save_entries(entries)
|
||||
return errors
|
||||
|
||||
def reconcile_dns_records(self) -> list[str]:
|
||||
if not self._dns_provider:
|
||||
return []
|
||||
|
||||
errors: list[str] = []
|
||||
with self._lock:
|
||||
entries = self._storage.list_entries()
|
||||
usage_map, usage_known, usage_error = self._resolve_usage(entries)
|
||||
if not usage_known:
|
||||
msg = f"Docker usage check failed for DNS reconcile: {usage_error or 'unknown error'}"
|
||||
for entry in entries:
|
||||
self._dns_errors_by_id[entry.id] = msg
|
||||
return [msg]
|
||||
|
||||
for entry in entries:
|
||||
used = bool(usage_map.get(entry.ip, set()))
|
||||
desired = entry.enabled and used
|
||||
try:
|
||||
self._apply_dns_state_locked(entry, desired)
|
||||
self._dns_errors_by_id.pop(entry.id, None)
|
||||
except (DnsSyncError, DependencyError, ConflictError) as exc:
|
||||
self._dns_errors_by_id[entry.id] = str(exc)
|
||||
errors.append(f"{entry.name}: {exc}")
|
||||
return errors
|
||||
|
||||
def _rollback_enable_change(self, entry: IpEntry, previous_enabled: bool) -> None:
|
||||
try:
|
||||
if previous_enabled:
|
||||
self._ip_manager.ensure_present(entry.ip, entry.cidr, entry.device)
|
||||
entry.enabled = True
|
||||
else:
|
||||
self._ip_manager.ensure_absent(entry.ip, entry.cidr, entry.device)
|
||||
entry.enabled = False
|
||||
except CommandError:
|
||||
pass
|
||||
|
||||
def _sync_dns_for_entry_locked(self, entry: IpEntry, strict: bool) -> None:
|
||||
if not self._dns_provider:
|
||||
return
|
||||
|
||||
usage_map, usage_known, usage_error = self._resolve_usage([entry])
|
||||
if not usage_known:
|
||||
msg = f"Docker usage check failed: {usage_error or 'unknown error'}"
|
||||
self._dns_errors_by_id[entry.id] = msg
|
||||
if strict:
|
||||
raise DependencyError(msg)
|
||||
return
|
||||
|
||||
desired = entry.enabled and bool(usage_map.get(entry.ip, set()))
|
||||
self._apply_dns_state_locked(entry, desired)
|
||||
self._dns_errors_by_id.pop(entry.id, None)
|
||||
|
||||
def _delete_dns_for_entry_locked(self, entry: IpEntry, strict: bool) -> None:
|
||||
if not self._dns_provider:
|
||||
return
|
||||
|
||||
try:
|
||||
fqdn = to_fqdn(entry.name, self._dns_base_domain)
|
||||
self._dns_provider.delete_a_record(fqdn)
|
||||
self._dns_errors_by_id.pop(entry.id, None)
|
||||
except DnsSyncError as exc:
|
||||
self._dns_errors_by_id[entry.id] = str(exc)
|
||||
if strict:
|
||||
raise ConflictError(f"DNS delete failed for {entry.name}: {exc}") from exc
|
||||
|
||||
def _apply_dns_state_locked(self, entry: IpEntry, desired: bool) -> None:
|
||||
if not self._dns_provider:
|
||||
return
|
||||
|
||||
try:
|
||||
fqdn = to_fqdn(entry.name, self._dns_base_domain)
|
||||
if desired:
|
||||
self._dns_provider.upsert_a_record(fqdn, entry.ip, self._dns_ttl_seconds)
|
||||
else:
|
||||
self._dns_provider.delete_a_record(fqdn)
|
||||
except DnsSyncError as exc:
|
||||
raise ConflictError(f"DNS sync failed for {entry.name}: {exc}") from exc
|
||||
|
||||
def _assert_not_used(self, entry: IpEntry) -> None:
|
||||
try:
|
||||
usage = self._usage_resolver.resolve_ip_usage({entry.ip})
|
||||
@@ -223,6 +322,14 @@ class EntryService:
|
||||
if entry.ip == ip and entry.cidr == cidr and entry.device == device:
|
||||
raise ConflictError("Entry with same ip/cidr/device already exists")
|
||||
|
||||
def _assert_unique_name(self, entries: list[IpEntry], name: str, ignore_entry_id: str | None = None) -> None:
|
||||
target = name.strip().lower()
|
||||
for entry in entries:
|
||||
if ignore_entry_id and entry.id == ignore_entry_id:
|
||||
continue
|
||||
if entry.name.strip().lower() == target:
|
||||
raise ConflictError("Entry name must be unique")
|
||||
|
||||
def _assert_device_exists(self, device: str) -> None:
|
||||
interfaces = self.list_interfaces()
|
||||
if device not in interfaces:
|
||||
@@ -258,15 +365,15 @@ def _parse_payload(payload: dict) -> dict:
|
||||
if any(ch.isspace() for ch in device):
|
||||
raise ValidationError("Field 'device' cannot contain whitespace")
|
||||
|
||||
raw_cidr = payload.get("cidr")
|
||||
if raw_cidr is None:
|
||||
cidr_raw = payload.get("cidr")
|
||||
if cidr_raw is None:
|
||||
raise ValidationError("Field 'cidr' is required")
|
||||
try:
|
||||
cidr = int(raw_cidr)
|
||||
cidr = int(cidr_raw)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValidationError("Field 'cidr' must be an integer") from exc
|
||||
if cidr < 0 or cidr > 32:
|
||||
raise ValidationError("Field 'cidr' must be in range 0..32")
|
||||
raise ValidationError("Field 'cidr' must be between 0 and 32")
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
fastapi==0.116.1
|
||||
uvicorn==0.35.0
|
||||
dnspython==2.7.0
|
||||
|
||||
Reference in New Issue
Block a user