From 1b35702b1b85f7ef94dfc3997849a4fd20ee375b Mon Sep 17 00:00:00 2001 From: Joachim Friberg Date: Mon, 20 Apr 2026 20:09:08 +0200 Subject: [PATCH] Add Snacks app: automated video library encoder with hardware acceleration (#6) Co-authored-by: Joachim Friberg Reviewed-on: https://git.phirna.uk/phirna/zima-apps/pulls/6 --- Apps/docker-ip-addr-manager/HOW_TO_VERIFY.md | 48 ++- Apps/docker-ip-addr-manager/README.md | 41 ++- .../backend/app/config.py | 28 ++ .../backend/app/dns_sync.py | 309 ++++++++++++++++++ .../backend/app/main.py | 65 +++- .../backend/app/models.py | 4 + .../backend/app/service.py | 115 ++++++- .../backend/requirements.txt | 1 + .../docker-compose.yaml | 43 ++- .../tests/integration_tests.py | 112 ++++++- Apps/snacks/README.md | 143 ++++++++ Apps/snacks/docker-compose.yaml | 103 ++++++ apps.md | 15 + dist/phirna-appstore.zip | Bin 303044 -> 313943 bytes 14 files changed, 1012 insertions(+), 15 deletions(-) create mode 100644 Apps/docker-ip-addr-manager/backend/app/dns_sync.py create mode 100644 Apps/snacks/README.md create mode 100644 Apps/snacks/docker-compose.yaml create mode 100644 apps.md diff --git a/Apps/docker-ip-addr-manager/HOW_TO_VERIFY.md b/Apps/docker-ip-addr-manager/HOW_TO_VERIFY.md index 1cf63dd..0f12c2d 100644 --- a/Apps/docker-ip-addr-manager/HOW_TO_VERIFY.md +++ b/Apps/docker-ip-addr-manager/HOW_TO_VERIFY.md @@ -67,7 +67,27 @@ Förväntat resultat: - posten med `10.0.4.2` har `used=true`. - `containers` innehåller `ip-verify-nginx`. -### Test C: Disable/Delete efter frigöring +### Test C: DNS create när posten är enabled + used + +Förutsätter DNS config i appen, exempel för AdGuard: + +- `DNS_PROVIDER=adguard` +- `DNS_BASE_DOMAIN=home.arpa` +- `ADGUARD_URL=http://:3000` +- `ADGUARD_USERNAME=` +- `ADGUARD_PASSWORD=` + +Verifiera att record skapats: + +```bash +dig +short lan-test.home.arpa @ +``` + +Förväntat resultat: + +- returnerar `10.0.4.2`. + +### Test D: Disable/Delete efter frigöring Stoppa testcontainer: @@ -103,7 +123,7 @@ Förväntat resultat: ## 3) Negativt / fail-closed testfall -### Test D: Blockera disable när IP används +### Test E: Blockera disable när IP används 1. Skapa + enable som i Test A. 2. Starta container som i Test B. @@ -120,19 +140,39 @@ Förväntat resultat: - HTTP `409`. - feltext som anger att posten används av container. +### Test F: Fail-closed vid DNS-fel + +1. Se till att en post är `enabled` och `used` (Test A+B). +2. Sabotera DNS-auth tillfälligt, exempel: + - ändra `ADGUARD_PASSWORD` till fel värde och starta om appen. +3. Försök disable/delete på posten. + +```bash +curl -sS -o /tmp/dns-fail.out -w '%{http_code}\n' \ + -X POST "http://127.0.0.1:31810/api/entries/${ENTRY_ID}/disable" +cat /tmp/dns-fail.out +``` + +Förväntat resultat: + +- HTTP `409` eller `503`. +- feltext som indikerar DNS-synkfel. +- posten ska inte lämna systemet i delvis uppdaterat läge. + ## 4) DNS / nät / TLS verifiering ### DNS (om hostname används i LAN) ```bash DNS_SERVER="" -HOSTNAME_TO_TEST="" +HOSTNAME_TO_TEST="lan-test.home.arpa" dig +short "${HOSTNAME_TO_TEST}" @"${DNS_SERVER}" ``` Förväntat resultat: -- returnerar avsedd LAN-IP. +- returnerar avsedd LAN-IP när posten är `enabled && used`. +- ingen träff när posten inte längre är `used` eller är `disabled`. ### Nätverk (lyssning och routning) diff --git a/Apps/docker-ip-addr-manager/README.md b/Apps/docker-ip-addr-manager/README.md index 61e8ff1..998b0ee 100644 --- a/Apps/docker-ip-addr-manager/README.md +++ b/Apps/docker-ip-addr-manager/README.md @@ -13,12 +13,16 @@ Exempel: istället för att köra `ip addr add 10.0.4.2/16 dev eth0` via SSH, ka - Sorterbar tabell: namn, IP-adress, used/unused, containernamn, device, enable/disable. - Used/unused-kontroll via Docker API (`NetworkSettings.Ports`) med exakt `HostIp`-match. - Include stopped containers i used-kontroll. +- DNS-livscykel (opt-in): skapar A-record när `enabled=true` och `used=true`, tar bort record när villkoret inte längre gäller. +- DNS-namn byggs från `name` + `DNS_BASE_DOMAIN` => `.` (DNS-säkrad label). - Fail-closed: - disable blockeras om IP används av minst en container, - delete blockeras om posten är enabled eller used, - - disable/delete blockeras om Docker-usage inte kan verifieras. + - disable/delete blockeras om Docker-usage inte kan verifieras, + - state-ändringar blockeras om nödvändig DNS-synk misslyckas. - Startup reconcile: enabled-poster återappliceras vid appstart. -- Manuell refresh-knapp (ingen websocket i v1). +- DNS reconcile körs i bakgrunden med poll-interval. +- Manuell refresh-knapp för UI-status (ingen websocket i v1). ## Portar @@ -77,6 +81,33 @@ Viktiga environment-variabler: - alternativt `http://127.0.0.1:2375` för socket-proxy. - `DOCKER_TIMEOUT_SECONDS` (default `3`) - `STATE_FILE` (default `/data/entries.json`) +- `DNS_PROVIDER` (`none`, `adguard`, `rfc2136`; default `none`) +- `DNS_BASE_DOMAIN` (exempel: `home.arpa`) +- `DNS_TTL_SECONDS` (default `120`) +- `DNS_SYNC_INTERVAL_SECONDS` (default `15`) + +AdGuard (`DNS_PROVIDER=adguard`): + +- `ADGUARD_URL` (exempel: `http://127.0.0.1:3000`) +- `ADGUARD_USERNAME` +- `ADGUARD_PASSWORD` +- `ADGUARD_API_TOKEN` (framtida alternativ, inte aktiv auth-väg i v1) + +RFC2136 (`DNS_PROVIDER=rfc2136`): + +- `RFC2136_SERVER` +- `RFC2136_ZONE` +- `RFC2136_PORT` (default `53`) +- `RFC2136_TSIG_KEY_NAME` (valfri om osignerade updates tillåts) +- `RFC2136_TSIG_SECRET` (base64, valfri utan TSIG) +- `RFC2136_TSIG_ALGORITHM` (default `hmac-sha256`) + +## DNS-beteende + +- Villkor för record: endast när posten är `enabled` och `used`. +- När posten inte längre är `used` tas DNS-record bort i bakgrundsreconcile. +- Vid enable/disable/delete görs direkt DNS-synk och operationen failar vid synkfel (fail-closed). +- Om Docker usage-kontroll är okänd i bakgrundsloop görs inga DNS-mutationer i den cykeln. ## Integrationstester @@ -91,7 +122,9 @@ Testerna mockar Docker API och `ip`-kommandoflöde och verifierar: - exakt `HostIp`-matchning, - fail-closed disable/delete, - blockering vid enabled/used, -- startup reconcile av enabled-poster. +- startup reconcile av enabled-poster, +- DNS create/delete på `enabled && used`, +- fail-closed rollback vid DNS-synkfel. ## Auth-notis @@ -102,4 +135,4 @@ Auth/autorisering ska implementeras i en senare version och är en uttalad roadm ## Roadmap (ej v1) - WebSocket-baserad live-uppdatering av used-status. -- DNS-integration (Cloudflare/lokal DNS) kopplat till IP-poster och hostnamn. +- Alternativ auth för AdGuard via API-token. diff --git a/Apps/docker-ip-addr-manager/backend/app/config.py b/Apps/docker-ip-addr-manager/backend/app/config.py index 5e668f4..a3aa090 100644 --- a/Apps/docker-ip-addr-manager/backend/app/config.py +++ b/Apps/docker-ip-addr-manager/backend/app/config.py @@ -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"), ) diff --git a/Apps/docker-ip-addr-manager/backend/app/dns_sync.py b/Apps/docker-ip-addr-manager/backend/app/dns_sync.py new file mode 100644 index 0000000..f792b86 --- /dev/null +++ b/Apps/docker-ip-addr-manager/backend/app/dns_sync.py @@ -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}") diff --git a/Apps/docker-ip-addr-manager/backend/app/main.py b/Apps/docker-ip-addr-manager/backend/app/main.py index aff4693..e449ac0 100644 --- a/Apps/docker-ip-addr-manager/backend/app/main.py +++ b/Apps/docker-ip-addr-manager/backend/app/main.py @@ -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)}) diff --git a/Apps/docker-ip-addr-manager/backend/app/models.py b/Apps/docker-ip-addr-manager/backend/app/models.py index 018c135..0521175 100644 --- a/Apps/docker-ip-addr-manager/backend/app/models.py +++ b/Apps/docker-ip-addr-manager/backend/app/models.py @@ -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, } diff --git a/Apps/docker-ip-addr-manager/backend/app/service.py b/Apps/docker-ip-addr-manager/backend/app/service.py index 75a3aac..d84a715 100644 --- a/Apps/docker-ip-addr-manager/backend/app/service.py +++ b/Apps/docker-ip-addr-manager/backend/app/service.py @@ -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, diff --git a/Apps/docker-ip-addr-manager/backend/requirements.txt b/Apps/docker-ip-addr-manager/backend/requirements.txt index 135a119..53d1137 100644 --- a/Apps/docker-ip-addr-manager/backend/requirements.txt +++ b/Apps/docker-ip-addr-manager/backend/requirements.txt @@ -1,2 +1,3 @@ fastapi==0.116.1 uvicorn==0.35.0 +dnspython==2.7.0 diff --git a/Apps/docker-ip-addr-manager/docker-compose.yaml b/Apps/docker-ip-addr-manager/docker-compose.yaml index 6e2658b..8b15f2c 100644 --- a/Apps/docker-ip-addr-manager/docker-compose.yaml +++ b/Apps/docker-ip-addr-manager/docker-compose.yaml @@ -19,6 +19,20 @@ services: STATE_FILE: /data/entries.json DOCKER_API_URL: unix:///var/run/docker.sock DOCKER_TIMEOUT_SECONDS: "3" + DNS_PROVIDER: none + DNS_BASE_DOMAIN: home.arpa + DNS_TTL_SECONDS: "120" + DNS_SYNC_INTERVAL_SECONDS: "15" + ADGUARD_URL: http://127.0.0.1:3000 + ADGUARD_USERNAME: "" + ADGUARD_PASSWORD: "" + ADGUARD_API_TOKEN: "" + RFC2136_SERVER: "" + RFC2136_ZONE: "" + RFC2136_PORT: "53" + RFC2136_TSIG_KEY_NAME: "" + RFC2136_TSIG_SECRET: "" + RFC2136_TSIG_ALGORITHM: hmac-sha256 volumes: - type: bind source: /DATA/AppData/$AppID/data @@ -38,6 +52,33 @@ services: - container: DOCKER_TIMEOUT_SECONDS description: en_us: Timeout in seconds for Docker API requests + - container: DNS_PROVIDER + description: + en_us: DNS backend (none, adguard, rfc2136) + - container: DNS_BASE_DOMAIN + description: + en_us: Base domain for generated hostnames like . + - container: DNS_TTL_SECONDS + description: + en_us: TTL in seconds for DNS A records + - container: DNS_SYNC_INTERVAL_SECONDS + description: + en_us: Background DNS reconcile interval in seconds + - container: ADGUARD_URL + description: + en_us: AdGuard Home URL for DNS_PROVIDER=adguard + - container: ADGUARD_USERNAME + description: + en_us: AdGuard Home username for DNS_PROVIDER=adguard + - container: ADGUARD_PASSWORD + description: + en_us: AdGuard Home password for DNS_PROVIDER=adguard + - container: RFC2136_SERVER + description: + en_us: RFC2136 nameserver host or IP + - container: RFC2136_ZONE + description: + en_us: RFC2136 zone name (for example home.arpa) volumes: - container: /data description: @@ -91,7 +132,7 @@ x-casaos: be validated. Start by adding a new IP Entry in this app and connect it to the appropriate Device. Then install, or update, a zima app and choose network: bridge. - * Enter a name for the app, will be used to create dns records in future release, add a non-used IP Address, CIDR, and choose device + * Enter a name for the app, and the app can create DNS records as . when DNS sync is enabled * Click add, and a new row appears under. * Click "Enable" to have this app setup the host to listen to this IP Address * To to the ZimaOS App Store, choose an app, and do a "Custom Install" diff --git a/Apps/docker-ip-addr-manager/tests/integration_tests.py b/Apps/docker-ip-addr-manager/tests/integration_tests.py index 3ce12bb..9d439f9 100644 --- a/Apps/docker-ip-addr-manager/tests/integration_tests.py +++ b/Apps/docker-ip-addr-manager/tests/integration_tests.py @@ -11,6 +11,7 @@ BACKEND_DIR = ROOT_DIR / "backend" sys.path.insert(0, str(BACKEND_DIR)) from app.docker_api import DockerApiError, DockerUsageResolver +from app.dns_sync import DnsSyncError from app.models import IpEntry from app.service import ConflictError, DependencyError, EntryService from app.storage import EntryStorage @@ -75,12 +76,40 @@ class FakeIpManager: self.present.discard((ip, cidr, device)) +class FakeDnsProvider: + def __init__(self, fail_upsert=False, fail_delete=False): + self.records = {} + self.upserts = [] + self.deletes = [] + self.fail_upsert = fail_upsert + self.fail_delete = fail_delete + + def upsert_a_record(self, fqdn: str, ip: str, ttl: int): + if self.fail_upsert: + raise DnsSyncError("upsert failed") + self.records[fqdn] = ip + self.upserts.append((fqdn, ip, ttl)) + + def delete_a_record(self, fqdn: str): + if self.fail_delete: + raise DnsSyncError("delete failed") + self.records.pop(fqdn, None) + self.deletes.append(fqdn) + + def assert_true(condition, message): if not condition: raise AssertionError(message) -def build_service(tmp_path: Path, entries=None, usage_resolver=None, ip_manager=None): +def build_service( + tmp_path: Path, + entries=None, + usage_resolver=None, + ip_manager=None, + dns_provider=None, + dns_base_domain="home.arpa", +): storage = EntryStorage(str(tmp_path / "entries.json")) if entries: storage.save_entries(entries) @@ -93,6 +122,9 @@ def build_service(tmp_path: Path, entries=None, usage_resolver=None, ip_manager= usage_resolver=resolver, ip_manager=ipm, interface_provider=lambda: ["eth0", "eth1"], + dns_provider=dns_provider, + dns_base_domain=dns_base_domain, + dns_ttl_seconds=120, ) @@ -163,6 +195,79 @@ def test_reconcile_reapplies_enabled(tmp_path: Path): assert_true(("10.0.4.10", 16, "eth0") in ip_manager.present, "enabled IP must be re-applied on startup reconcile") +def test_dns_upsert_on_enable_when_used(tmp_path: Path): + entry = IpEntry(id="dns1", name="Lan App", ip="10.0.4.20", cidr=16, device="eth0", enabled=False) + resolver = FakeUsageResolver(mapping={"10.0.4.20": {"nginx"}}) + ip_manager = FakeIpManager() + dns = FakeDnsProvider() + service = build_service(tmp_path, entries=[entry], usage_resolver=resolver, ip_manager=ip_manager, dns_provider=dns) + + service.set_enabled("dns1", enabled=True) + + assert_true(dns.records.get("lan-app.home.arpa") == "10.0.4.20", "DNS record should be created on enable+used") + + +def test_dns_no_upsert_on_enable_when_unused(tmp_path: Path): + entry = IpEntry(id="dns2", name="Lan App 2", ip="10.0.4.21", cidr=16, device="eth0", enabled=False) + resolver = FakeUsageResolver(mapping={}) + ip_manager = FakeIpManager() + dns = FakeDnsProvider() + service = build_service(tmp_path, entries=[entry], usage_resolver=resolver, ip_manager=ip_manager, dns_provider=dns) + + service.set_enabled("dns2", enabled=True) + + assert_true("lan-app-2.home.arpa" not in dns.records, "Unused entries must not create DNS record") + + +def test_dns_reconcile_deletes_when_no_longer_used(tmp_path: Path): + entry = IpEntry(id="dns3", name="Lan App 3", ip="10.0.4.22", cidr=16, device="eth0", enabled=True) + resolver = FakeUsageResolver(mapping={"10.0.4.22": {"nginx"}}) + dns = FakeDnsProvider() + service = build_service(tmp_path, entries=[entry], usage_resolver=resolver, dns_provider=dns) + + service.reconcile_dns_records() + assert_true(dns.records.get("lan-app-3.home.arpa") == "10.0.4.22", "record should exist after used reconcile") + + resolver._mapping = {} + service.reconcile_dns_records() + assert_true("lan-app-3.home.arpa" not in dns.records, "record should be removed when usage disappears") + + +def test_dns_fail_closed_rolls_back_enable(tmp_path: Path): + entry = IpEntry(id="dns4", name="Lan App 4", ip="10.0.4.23", cidr=16, device="eth0", enabled=False) + resolver = FakeUsageResolver(mapping={"10.0.4.23": {"nginx"}}) + ip_manager = FakeIpManager() + dns = FakeDnsProvider(fail_upsert=True) + service = build_service(tmp_path, entries=[entry], usage_resolver=resolver, ip_manager=ip_manager, dns_provider=dns) + + failed = False + try: + service.set_enabled("dns4", enabled=True) + except ConflictError: + failed = True + + assert_true(failed, "enable must fail-closed when DNS upsert fails") + current = service.list_entries().items[0] + assert_true(not current.enabled, "entry must roll back to disabled on DNS failure") + assert_true(not ip_manager.is_present("10.0.4.23", 16, "eth0"), "IP presence must roll back on DNS failure") + + +def test_dns_fail_closed_blocks_delete(tmp_path: Path): + entry = IpEntry(id="dns5", name="Lan App 5", ip="10.0.4.24", cidr=16, device="eth0", enabled=False) + resolver = FakeUsageResolver(mapping={}) + dns = FakeDnsProvider(fail_delete=True) + service = build_service(tmp_path, entries=[entry], usage_resolver=resolver, dns_provider=dns) + + failed = False + try: + service.delete_entry("dns5") + except ConflictError: + failed = True + + assert_true(failed, "delete must fail-closed when DNS cleanup fails") + assert_true(len(service.list_entries().items) == 1, "entry must remain when delete fails") + + def main(): test_exact_hostip_match_only() @@ -172,6 +277,11 @@ def main(): test_disable_blocked_when_docker_check_fails(tmp_path) test_delete_blocked_when_enabled(tmp_path) test_reconcile_reapplies_enabled(tmp_path) + test_dns_upsert_on_enable_when_used(tmp_path) + test_dns_no_upsert_on_enable_when_unused(tmp_path) + test_dns_reconcile_deletes_when_no_longer_used(tmp_path) + test_dns_fail_closed_rolls_back_enable(tmp_path) + test_dns_fail_closed_blocks_delete(tmp_path) print("Integration tests passed") diff --git a/Apps/snacks/README.md b/Apps/snacks/README.md new file mode 100644 index 0000000..876b04b --- /dev/null +++ b/Apps/snacks/README.md @@ -0,0 +1,143 @@ +# Snacks + +Automated video library encoder with hardware acceleration (NVENC, QSV, VAAPI, AMF). + +## Purpose + +Snacks batch-transcodes video libraries using FFmpeg with hardware acceleration. +It monitors directories, skips already-encoded files, retries with fallbacks, and supports distributed cluster encoding across multiple ZimaOS nodes. + +## Port + +- `6767/tcp` — Web UI at `http://localhost:6767` + +## Volumes + +| Host path | Container path | Description | +|---|---|---| +| `/DATA/AppData/$AppID/media` | `/app/work/uploads` | Media library — source files to encode | +| `/DATA/AppData/$AppID/logs` | `/app/work/logs` | Transcoding logs | +| `/DATA/AppData/$AppID/config` | `/app/work/config` | Settings and SQLite database | + +## Hardware Acceleration + +Snacks uses GPU encoding via `/dev/dri`: + +| Driver | Codecs | Devices | +|---|---|---| +| VAAPI (Linux) | H.265, H.264 | Intel iHD/i965, AMD VAAPI | +| QSV (Intel) | H.265, H.264 | Intel Quick Sync Video | +| NVENC (NVIDIA) | H.265, H.264 | NVIDIA GPUs via CUDA | +| AMF (AMD) | H.265, H.264 | AMD GPUs | + +Auto-detection runs on first encode and picks the best available encoder. + +## Cluster Mode + +Snacks supports distributed encoding across multiple ZimaOS nodes. + +- Nodes discover each other via UDP broadcast on the LAN +- One instance acts as coordinator; others are workers +- Jobs are assigned automatically; failed nodes are re-assigned +- A shared secret authenticates intra-cluster communication + +**UDP broadcast requirement**: Cluster mode requires `network_mode: host` — bridge mode blocks LAN broadcast discovery, making nodes invisible to each other. + +## Health Check + +`http://localhost:6767/Home/Health` — returns HTTP 200 when the backend is ready. + +## Privilegier och säkerhet + +Aktiva säkerhetsinställningar i denna app: + +- `security_opt: ["no-new-privileges:true"]` +- `cap_drop: ["ALL"]` +- `privileged: true` +- `network_mode: host` +- Device mount: `/dev/dri:/dev/dri` + +Motivering: + +- `no-new-privileges:true` och `cap_drop: ["ALL"]` kompenserar med lägsta möjliga capability-yta. +- Isolerad data-path under `/DATA/AppData/$AppID/...`. + +## Säkerhetsavvikelser + +### 1. `network_mode: host` + +**Varför det behövs:** + +- Snacks cluster nodes discover each other via UDP broadcast on the local network. +- Bridge mode only forwards unicast traffic; broadcast packets never reach other nodes. +- Without host networking, cluster mode is non-functional. + +**Alternativ som utvärderats:** + +- Bridge mode with port exposure: broadcasts are not forwarded by the Docker bridge. +- Static IP configuration: requires manual node addressing and is error-prone. +- Multicast DNS (mDNS): not supported by Docker bridge in all deployments. + +**Risker:** + +- Container has full access to all host ports. +- No network isolation between Snacks and other services on the host. +- If the container is compromised, the attacker has host network access. + +**Riskreducering:** + +- `cap_drop: ["ALL"]` minimizes syscall surface. +- `no-new-privileges:true` prevents privilege escalation. +- No sensitive host directories are mounted beyond the app-specific volumes. + +--- + +### 2. `privileged: true` + +**Varför det behävs:** + +- `/dev/dri` (Direct Rendering Infrastructure) is required for VAAPI/QSV hardware acceleration. +- On standard Linux, this device is accessible without privileged mode if the user is in the `video` or `render` group. +- ZimaOS does not reliably provide these groups in the container runtime context, making `privileged: true` the only reliable way to grant device access. + +**Alternativ som utvärderats:** + +- `security_opt: ["apparmor:..."]` with specific `/dev/dri` access: not reliably portable across ZimaOS kernel configurations. +- Pre-create device nodes with specific permissions: does not work dynamically when the device appears. +- Skip hardware acceleration (software encoding only): defeats the primary purpose of the app. + +**Risker:** + +- Container has full root capabilities on the host. +- If container is compromised, attacker has theoretical access to all host resources. +- Hardware acceleration devices can be accessed directly. + +**Riskreducering:** + +- `cap_drop: ["ALL"]` drops all capabilities even when privileged. +- Only the specific `/dev/dri` device is mounted; no other host devices. +- Data volumes are scoped to `/DATA/AppData/$AppID/...`. + +--- + +### 3. Device mount: `/dev/dri:/dev/dri` + +**Varför det behövs:** + +- VAAPI and QSV hardware encoding require direct access to the GPU render nodes in `/dev/dri`. +- Without this mount, FFmpeg falls back to software encoding which is 10–50x slower on 4K content. + +**Alternativ som utvärderats:** + +- Specific device nodes (e.g., `/dev/dri/renderD128`): device names can vary by driver version and host kernel. +- No hardware acceleration: software fallback is too slow for practical use. + +**Risker:** + +- The container can enumerate and use all graphics devices on the host. +- On multi-user systems, other users' GPU resources may be accessible. + +**Riskreducering:** + +- `privileged: true` combined with `cap_drop: ["ALL"]` ensures the container cannot load additional kernel modules or escalate privileges. +- Only the render nodes are exposed; no other host devices are passed through. \ No newline at end of file diff --git a/Apps/snacks/docker-compose.yaml b/Apps/snacks/docker-compose.yaml new file mode 100644 index 0000000..93e29e1 --- /dev/null +++ b/Apps/snacks/docker-compose.yaml @@ -0,0 +1,103 @@ +name: snacks + +services: + snacks: + image: derekshreds/snacks-docker:2.3.1 + container_name: snacks + restart: unless-stopped + deploy: + resources: + reservations: + memory: 1G + + environment: + - TZ=Europe/Stockholm + - PUID=1000 + - PGID=1000 + - ASPNETCORE_ENVIRONMENT=Production + - SNACKS_WORK_DIR=/app/work + - FFMPEG_PATH=/usr/lib/jellyfin-ffmpeg/ffmpeg + - FFPROBE_PATH=/usr/lib/jellyfin-ffmpeg/ffprobe + + network_mode: host + + volumes: + - type: bind + source: /DATA/AppData/$AppID/media + target: /app/work/uploads + - type: bind + source: /DATA/AppData/$AppID/logs + target: /app/work/logs + - type: bind + source: /DATA/AppData/$AppID/config + target: /app/work/config + + devices: + - /dev/dri:/dev/dri + + privileged: true + + security_opt: + - no-new-privileges:true + + cap_drop: + - ALL + + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:6767/Home/Health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + + x-casaos: + envs: + - container: TZ + description: + en_US: Timezone, for example Europe/Stockholm + - container: PUID + description: + en_US: User ID for filesystem permissions + - container: PGID + description: + en_US: Group ID for filesystem permissions + - container: FFMPEG_PATH + description: + en_US: "FFmpeg binary path (default: /usr/lib/jellyfin-ffmpeg/ffmpeg). Use /usr/bin/ffmpeg on systems without jellyfin-ffmpeg." + - container: FFPROBE_PATH + description: + en_US: "FFprobe binary path (default: /usr/lib/jellyfin-ffmpeg/ffprobe). Use /usr/bin/ffprobe on systems without jellyfin-ffmpeg." + ports: + - container: "6767" + description: + en_US: Web UI port + volumes: + - container: /app/work/uploads + description: + en_US: Media library — source files to be encoded + - container: /app/work/logs + description: + en_US: Transcoding logs directory + - container: /app/work/config + description: + en_US: Application configuration and SQLite database + +x-casaos: + architectures: + - amd64 + main: snacks + category: phirna + author: Joachim Friberg + developer: Joachim Friberg + icon: https://cdn.simpleicons.org/snacks + tagline: + en_US: Automated video library encoder with hardware acceleration + description: + en_US: >- + Batch transcode your video library with hardware acceleration (NVENC, QSV, VAAPI, AMF). + Monitors directories, skips already-encoded files, and supports distributed cluster encoding. + Web UI at http://localhost:6767 + title: + en_US: Snacks + index: / + port_map: "6767" \ No newline at end of file diff --git a/apps.md b/apps.md new file mode 100644 index 0000000..58a0159 --- /dev/null +++ b/apps.md @@ -0,0 +1,15 @@ +## Backlog + +| # | Done | Name | Source | What | Agent instructions | +|---|---|---|---|---|---| +| 1 | [x] | Snacks | https://github.com/derekshreds/snacks | Automated video library encoder | Branch `snacks/initial/add-video-encoder`; implemented in `Apps/snacks/` | + +## Adding a new app + +1. Copy `Apps/_template/` → `Apps//` +2. Set `name` in compose (lowercase + hyphen only) +3. Pin image to explicit version/tag (no `:latest`); verify tag exists in registry +4. Add `x-casaos` metadata (title, description, icon, category, author, port_map) +5. Write `README.md` with purpose, ports, volumes, envs, and risk justifications +6. Validate: `./scripts/validate-appstore.sh` +7. Run final validation before release: `./scripts/validate-appstore.sh --enforce-risk-docs` \ No newline at end of file diff --git a/dist/phirna-appstore.zip b/dist/phirna-appstore.zip index 543edfa11f5d7b54ffe13905a54fb016d6f8318b..c24864521ea1a6b2a6b914238261bcf3f1ae6a50 100644 GIT binary patch delta 26991 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 Z<>MkF6ClO0M6yJB%8P`w3DreH`akv3op=BM delta 16398 zcmaJ|Wk8hM)*fK!Zlp^D1*B72x*KVb?rs>QQ9>MG5RmRrx}>|Lq!E-xLP1Id8defKv8=A{1R zkP0>TZ>&0sdJq|AMw{`$4Vc3C_?MZVcNl+zQ0dvZhFJ~u-?8J|a8}qUnP7ql+Qz>@ zu$8tM3>(zehGEe9`IkRZI*V{XnZ$FH{k*|dYHp7YR{W;f?s;p!!S*+U%X${{wvfQ zUe9jyDf;ME8HRWG{;h)t9O=gi!{7S-ja$I5 z8vppebO?e&1Af`732=eofBPmE=y$^%2-JniGX#a*V6TIgVX?lj4X>PrjvOzb)#U=_ z3^IZ^R|3iYiZhrdcpt`u4KV-wsX;4M3GDC$>p$$KKMA_AK^Fps*{u(`=|HY%{I_(qMYQ}cW<`wdf0(%!>-3u#niU(F6^j7Fkz#6aZVBi#&8^%3updSN3JRs&Pom*&pMO%QK{47(e(2lEjUg{vSwdL@@XbE|BX6 z-ogd;q2fcsV~B5?!YzUV08|qK0Jk9sv_KLFe>0Hix;a2iUC&ANN2EaAn_}Jn4==K* zfj1$5EK>t(Vdh@a0QvrddPoBlzwtqc7I=sX<8m+q1%AU2XJ+6xSjAbeKpp6I)Y3-4(|H09Ya8oIo1LCJT@lbUnab|2%y#0SLfjXw!zxulc%{8VfRbS0<{J z3=^D60&`4^`Kbk(1j3;P7}UFr%8ID3^1*=#9vWXqaKiTa_Az`B+VoCU2)sBZm4$rB z0-KmI(CqHuA!IC1a^I4SU-@IM}L6;iUtP|r}}X5tx6S>v>Rp#aL#m%>{&>ttL; z&Q@oXmqDiDz9s&E`0>5`jTz>wCtWlTa{G`*UHSO3T9DstWUgziR!)aJ<4lT^x_gUj zASoh7`nl^9-7Q3y?3XdN{ifhoQtd~1#16jS75e-D=p~wJ!_Mu^!(oE+_c54EgV;KX zvTdJIOFxe!Y!@!JwFp}RRgFhAf8jkn7r{GB(~#ZYa%=l2wnLMN75FW$WyZ+}dF$*= z;}}=HpTqZ~>2{(w0e$EprVppO(F?hd2}%)^YS=HVs#^|QW;)yIoNIaS#1!~_8*lz3 zfbw+iLf@uDf{R!App)O_f=fWON&pCP%4ld!*0iFMK=ZE4%Dngb3=|V$rO-!~DL5NU zx75*!8yZG388HN;%E$5x)SwlJ)67WO@egEble+TZ2L@C2;l|d;*ZWT~FN1_qOsx9{f@S-;1>5kmthBgVh_*HK7IT*e-c}oLHl8!Y%dw6- zvHS%4tM-5Sk&+hAZvRU6-9);9&`AM%oB=iaSqCQVTX8pza74QoAOIGwlEpsy8X5WC zba7A?*LB@Z~ zKJtV}?ar+8*V+^PpT?NG2_H}JwhJvQ?y&YoF-3^sPaPQyuqg7{16Un0nTJZf*n`ne z%1oaIi}3f5q~PF#K7?c=?lOibZ}1nO(jv8C9b$ek`7yRUSdlwt#u9?d+Dj4fx%lAhd61cmR|M^h!0=%$EB_S)^+P2J{b#AX+DMSSW4#L!CNBl6K=N^|4il<%pQ2(b@ke-g z=n5X(HcMrA`^A2aipDsPZ(YEpbQpu%&@V8nQvi>p&(>f2G03pqGs#-Oc4TgI2&1vj2;nG>3GY?;lFq-m-r5>9@OyZJyYcM8AZt8|-3(j3$ z{^T`K$!j<9n2}Gt()M#*+v43dmj+NxtvK%OQIti(4;d~?4<+$?)`r>T1e|d2a&j*} zHd(cid!b4Z74s6PxX|Y)b@%O)?V#S;6K!ff?@huKTU`Z(C)E=5Nbo1+7|vSBHTMfB z`CXQ1Q<+U}nINWp4%e{4uh!I}zu2&1;mQxr(c?5#F?uo6Ke7n)H6u%i?W1e+srgQ5{5MEBeWyYZvL#n2 z2O2a!OQ)zI594rZ-aShxOpUKa04p&Q2iDsAweqyeB!jhU##OFxSI~gleu&qQ< zTqFs2qSj!}JVB}K`_w=>{k%X#kI`pM?0bi4xJ*MjC>(5+Vts2S$)6^{G@dTKfEGAq9?kQcr0(~bo!Wskce0(UD{CPc1}T3UFX%ukkg5~ zCr#!$Y-T{5L(ZTat(}c1`7m1j?Fzdo!_@dnbF_@YiuZORrqgFat{VLp7dc)SVzLBS z$*DYR$_(#qS&6J7Ib5b&^5=G(7f^T8naKGYi&CCGgp97-CW&5!OvYQPRq>B?eA^kP=z~&faO5l%8Vz0v}%Mxo*X_Gzw)oKLrs5l2G0pkoPH*#ET59(5(D} zRHK*la(;we`=GDS>5soLEHR2q`s+ofp9XMKZqJy!=F>M~YiK|`kXxHBQ*h&=L>brHz)be<{KD#eMXve43^V7`F zi$@IdRIV`!E};`f56;$G*FR4_l=8LC+uWe7(QD&+S zuHQwtD`i4Tnag_%?_#}z5qT!UDxV_;Q%z`Eja4@6S=sEGxO!dUbJhMFR!pqV*0oQ} zdK^H^)QkJk0TSz$Q1b9w)xH_Eh@ta*O$F z#Wy|xp+HfDW^%+V?zRQnRkQ!G@cI_TM%y}}iC3L`su0B;D*I;Hw<9^1f z$iKS9sS#sO`^NB{nWm>Qov{oBf(hGT<$hDcQmx79c*T2cA?#9H)I_%q?EF@>=~+__ z&7`mQn|g_g=Zl^G{EV zLGpV2kh!~3UwJ<}5%<6qX7^d#ppw=0zeAB7Y$n8h8T_j@~s8q3woXvav}jM-O+tm!qrs*aF) zo1WYyp@^G3k&*&AOuv|yw_1_O#rLx(Lfx~U(#Afv%)6l1dEX=%Yp^d9jzGURj8jSV zECfeb()VmYtcfT=mc<06w!A_6BHBu?Er|c*D$p(DQBjqBp(82kBFjnB8u@1mQbb21 zbXT^;jw%thp1>WAtG3UzEy3ZD5n03!c=Bu6v5IaFv6c*hWHVNSI;Nkc&m91V2VcGX zekJrxR$Q(SfwJY(y)FdH@V;=ca}_b0bY*OylYW8_mrd_wBm0T3>j!CgiSUVcR00D(iecx~eFtQ=mlM#r8^*vB!C_^|h{CZuBUa8kMZQj1DbfHJSVLN%4_3sR@OLm}$e7Xpto(*;v|drJ$O@a5!$3>#YI zos>q}ceqWmMp%pVXz|AJs$tbskx2CsEAv!SEWP%)E1=e=CcExgHk)ORde#!<|)-1VD%v_6IRAa0`RsfO1HZJp1x?|qI`NZ@l&?9ATL zLKaqg$-j*F;QZXWE@ZbunfHgA$7=V2#A(j6FAJHGUrtPCD=s+Fov$KYUWWvckF+RW z;v&uWUOWQnBnr{~7>Td>0(5C`~FGnM&k z&EvS1W3k2=0c=@)?7o1hmfp8Z@1hXXF}Tk?F~8Dlthm0;%t)|oRDTt=wisp@pP888 zth4VuxzhLoiNbsD3$u1_YL?IH_LFb@=>!d$Tv8(Hpm!7~=q&?W6?30ZNMmNaUVnb; zVl;8w;>qX%8g2=()+Tq?!0@MJu1?6~D^dAL?!09-FD!EG@`&@1laEJ`7`QhRp`0R8FU8Z`?Ve8=mKIv7Q}#rH}qpMpe-Vd zZVU+>T0sKF)RhaFJZba0~|Qoq>z_6k#;zgg^gj?ISc zMh!EXFqTNB+??m7mP*;z-l-Ca)TZ>o61KhsP+V$1k30rnqUXEMHa|XIdYm77SJ^^0 zixetDsh%@#{T`H`jNZ51U8rleE8b1B*4d#BxYFm;!09UZQhJ(5y57#yV?X|Fv3<#9 z>9F?4Pfyl2pGQ4*7YqjXT&9&1`7JG_ZRZ4t`9&@&192|Zv@Vy`ylQXlD+HBJEnzE+ z(0~SUbUc!Uj(8p474a{2$G36%MM`x?=2I4Cbm_$(iB&n)>Z6N*KMp2Ob+Vc-A8j_K z%|3Y=&uW72gHjV&a8;SPbW&}8NPk?~YOk-OteP97j5%oAHQD?|E109y~>~!SzWM%1uRD1yl)tibW|DvizH|5tCjPZh? z_}eJf0&aii%7%Z37bN!(>JwD28E!Adnq5r z_<{5d+^nb1`xN;m+QY##{uAS$UvGY01R>k=>4hO zI-Q0_+MZF1WXXrn5_6V%&}^?avX+8mSZnYn0d;Yz;v8b80UEE_?vv%WE!iD_V1{@y zgh`@S{nFMng=uwmi(n3wok^F*#saY385+7Jc7TN10GIQ{gOIp-`u2RU*I4DB=6PyT$>ijPkGRwC=7k&w7K~yW`SnM{2J;Aopv94WD>vtz`O4e#X$@26 z>ph|Gj}C2>CjS?<$#(BVF7&@|@kX0b3umf`sp9aN#aP8`@hQD8dhx*gkRC?&^z-T8+6| z08F4KzU6%nP$Bv@Zs*Eib1}M^IBYHY>2o)t1Y7Ld!OFcMA=29+Xj&zb)dExs?3dMe z&f(-QO0IO$h4~f*9v+!|=mBdVJS+{&p4boEVqOmsuKa5Zi0L z$T_*bcHWRjqn!~zjcULfr%qf4EmQ27L!>M<)@?c4`$v-d;_4%Xa~ly$kwthoMUako z3mO78UmP@ZF5p9J3k>wy`_VQv#_iL0h!m#A<%=1th#XHUoAY~jneI0p4N^=Rl5`)c z-Irip!qjO`bjYaz4-bkn`5*rRv(mL?gS<(M0a3pBqRma>A** zt0cZ$y4IuM1)?!%`7kHhX)!i>^r@TWtO>CmhdfFr@5q6Xu?WRN!nS~THrXL1St8*r z;B-xXd_K(+!3S|?9>MoAu=u?>@jQBfLZpJLrug{eNx2oWKf{BM zx#p#|ZM$BYd7_Irbcq3Vyc%CXnRXV{1)BKo7?y!O`qheP{9h6@(baW#9J#&DC^k0h zygfC&W~}RKA^e+zCqArh9Y4+4X4fm6@QL@Mzhsv8X7a0?X`rerY;k_j2fKA|2&7F| zHAyE{X zRB=wIAkcfK`(JKxM7iyxR7 z;mPSV6}1q_SZ5<;EOdW|;J6MolA>2>)ow+|fmh|L9A6w*Nz~7#HGp)*vdk_DzuQNICr{C%1~dc4E60 z+n&bmNWbE@cWt6RoyA|}7o==6s0aO1XcYt4>4HUE75KUZj$Qz}fBxL|ejr@gmKywk zmPOkm^0PI0{O)GJx{>!YM{}K=pv;8F`?#1`x2L!!AF#j76V1J<5-yl}7ZJM(SSsgI z_6!Zm#aO~MDj*P38VB*BHoi3@BnI2R-~aad`FNsF3Spmk1gBI_uQaWHIyR;I_zOE< zG}>b0zH0LyL#|odkqpy0bA-y<)ov-W%#E}KL(CX&ac0UC^gG7t*Dyoo`%WQirT4|0 zsKx~Q5HkZR=K_xN)qi~RCgKWS>u!_i5~u5QxZ9B-Xw)excyOO-$Rn>m+F#g9{}Q%T zyU7k4N`n^Y?m-WWbmEC0OuT#^@gm2Gw`7jYyxxoFiW$b4 z3IxwgX;_*&hkheNvE+Tjk|L9ve ziR)FINs3H#RzOble)IEc(bN9>aH)7h(g%g_LZHERfF>gU1RD$F=xkx>;Lh&i3(M#H zk3*hyO$nziTD;~%j5RP@zHe~8R%4BZ*q9>GqwX>TEeclJ=#mo9eN3{2hMA0QshVo; zx4Srb@9nab-y7{|OTAa)j8QNc%+pGKl$}SHCxvaDTr0wjGR&yJA_?kuMfnM7jZvN0 z9!*s{MK{)bTpNFz!c@jQeO1TBNRzHymeMK5h*_da5%=}DbaBS_$#ELq;6U96(;h^H zjtw@Bb0=1tk!36^PemV0(2r<`b?m?YRUFEoRD9%>A?w614$#+S}wwr1RJz?YBVo`1xt9d~P8B#N>378N8uB$#WU#&zuh8Cw$uaK+MjhK2Y^HSWjdC9Dv-%0AVith_X^42s@v(Kk4 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