From 9ea8ca421bd7796c5e30153d40434b179f8b36f6 Mon Sep 17 00:00:00 2001 From: Joachim Friberg Date: Wed, 18 Mar 2026 16:19:01 +0100 Subject: [PATCH] Add caddy-autogen app, tests, and agent policy updates --- AGENTS.md | 93 +++++ Apps/_template/README.md | 30 ++ Apps/_template/docker-compose.yaml | 67 ++++ Apps/caddy-autogen/README.md | 104 ++++++ Apps/caddy-autogen/agent/Dockerfile | 5 + Apps/caddy-autogen/agent/discovery_agent.py | 318 ++++++++++++++++++ Apps/caddy-autogen/caddy/Caddyfile | 7 + Apps/caddy-autogen/caddy/Dockerfile | 7 + Apps/caddy-autogen/config/defaults.yaml | 12 + Apps/caddy-autogen/docker-compose.yaml | 162 +++++++++ Apps/caddy-autogen/tests/integration_tests.py | 151 +++++++++ .../tests/run_integration_tests.sh | 5 + Apps/steam-headless/README.md | 72 ++++ Apps/steam-headless/docker-compose.yaml | 81 +++++ README.md | 54 +++ category-list.json | 8 + featured-apps.json | 1 + recommend-list.json | 1 + scripts/validate-appstore.sh | 95 ++++++ 19 files changed, 1273 insertions(+) create mode 100644 AGENTS.md create mode 100644 Apps/_template/README.md create mode 100644 Apps/_template/docker-compose.yaml create mode 100644 Apps/caddy-autogen/README.md create mode 100644 Apps/caddy-autogen/agent/Dockerfile create mode 100644 Apps/caddy-autogen/agent/discovery_agent.py create mode 100644 Apps/caddy-autogen/caddy/Caddyfile create mode 100644 Apps/caddy-autogen/caddy/Dockerfile create mode 100644 Apps/caddy-autogen/config/defaults.yaml create mode 100644 Apps/caddy-autogen/docker-compose.yaml create mode 100644 Apps/caddy-autogen/tests/integration_tests.py create mode 100755 Apps/caddy-autogen/tests/run_integration_tests.sh create mode 100644 Apps/steam-headless/README.md create mode 100644 Apps/steam-headless/docker-compose.yaml create mode 100644 README.md create mode 100644 category-list.json create mode 100644 featured-apps.json create mode 100644 recommend-list.json create mode 100755 scripts/validate-appstore.sh diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..14b209b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,93 @@ +# AGENTS.md + +Detta dokument styr hur agenter och utvecklare arbetar i detta repo. +Fokus: korrekthet, låg risk och underhållbarhet. + +## 1) Syfte och prioritet + +- Gäller hela repot. +- Kompletterar globala agentinstruktioner. +- Vid konflikt gäller striktare regel. + +## 2) Repo-invarianter + +- Appfiler ligger under `Apps//`. +- Varje app ska minst ha: + - `docker-compose.yaml` + - `README.md` (syfte, portar, volymer, privilegier, risker) +- Compose ska ha giltig top-level `name` (gemener + bindestreck). +- Endast `.yaml` används i repot (aldrig `.yml`). + +## 3) Säkerhetsbaseline (Compose) + +MUST: +- Pinna images till explicit version eller digest. +- Inte använda `:latest`. +- Hålla volymer snäva (`/DATA/AppData/$AppID/...`). + +SHOULD: +- `security_opt: ["no-new-privileges:true"]` +- `cap_drop: ["ALL"]` när appen tillåter det. + +Högrisk-inställningar är tillåtna men kräver varning och motivering: +- `privileged: true` +- `network_mode: host` +- mount av Docker-socket (`/var/run/docker.sock`) + +Om någon högrisk-inställning används måste appens `README.md` innehålla: +- varför det behövs, +- vilka alternativ som utvärderats, +- vilka risker det innebär. + +## 4) Arbetsflöde för ändringar + +- Små, reviewbara ändringar först. +- Beskriv säkerhetspåverkan i PR. +- Lokal validering är rekommenderad under utveckling: + +```bash +./scripts/validate-appstore.sh +``` + +- Lokal validering är inte blockerande för varje enskild commit. + +## 5) Obligatorisk release-checklista + +Inför release/publicering måste följande vara uppfyllt: + +1. `./scripts/validate-appstore.sh` körd med `Validation OK`. +2. Ingen app använder `image: ...:latest`. +3. `x-casaos` metadata är satt och konsekvent. +4. Appens `README.md` är uppdaterad. +5. Eventuella högrisk-inställningar är tydligt motiverade. + +## 6) Review-krav + +PR som ändrar appar ska explicit ange: +- vilka app-id:n som påverkas, +- säkerhetsrisk (låg/medel/hög), +- om högrisk-inställningar introduceras eller ändras. + +## 7) Testkrav för containerappar + +För nya appar eller större ändringar i befintliga appar (routing, discovery, auth, nätverk, secrets, cert-hantering) ska integrationstester ingå. + +Minimikrav: + +- Testa med mockade externa gränser (t.ex. Docker API, reverse proxy API, DNS/API-provider), så testerna är reproducerbara lokalt. +- Verifiera minst ett fail-closed-scenario (saknad/ogiltig secret, API-fel, otillåten metadata) där appen inte exponerar tjänster oavsiktligt. +- Verifiera att endast explicit tillåtna containrar/endpoints exponeras. + +Tester ska dokumenteras i appens `README.md` med exakt körkommando. + +## 8) Branch-namnstandard + +När en ny branch skapas ska formatet vara: + +`//` + +Regler för ``: + +- `initial`: max 5 ord som beskriver vad som byggs. +- `bugfix`: vad som fixas (kort och konkret). +- `update`: versionsuppdatering i formen `vX.Y.Z-to-vA.B.C`. diff --git a/Apps/_template/README.md b/Apps/_template/README.md new file mode 100644 index 0000000..e710b27 --- /dev/null +++ b/Apps/_template/README.md @@ -0,0 +1,30 @@ +# App Template + +Kopiera denna mapp till `Apps/`. + +Minimikrav per appmapp: + +- `docker-compose.yaml` +- `README.md` (vad appen gör, privilegier/mounts/portar) + +## Säkerhetsavvikelser + +Skriv denna sektion om appen kräver högrisk-inställningar, till exempel: + +- `privileged: true` +- `network_mode: host` +- mount av `/var/run/docker.sock` + +Beskriv: + +- varför det behövs, +- vilka alternativ som utvärderats, +- vilka risker det innebär. + +Vanliga extrafiler: + +- `icon.png` +- `screenshot-1.png` +- `screenshot-2.png` + +Notera: vissa publika appstores förväntar sig `docker-compose.yml`. Vi håller `.yaml` internt och hanterar eventuell anpassning i release-flödet. diff --git a/Apps/_template/docker-compose.yaml b/Apps/_template/docker-compose.yaml new file mode 100644 index 0000000..177dd51 --- /dev/null +++ b/Apps/_template/docker-compose.yaml @@ -0,0 +1,67 @@ +name: sample-app + +services: + app: + image: ghcr.io/example/sample-app:1.0.0 + container_name: sample-app + restart: unless-stopped + + environment: + TZ: ${TZ} + PUID: ${PUID} + PGID: ${PGID} + WEBUI_PORT: ${WEBUI_PORT:-8080} + + ports: + - target: 8080 + published: ${WEBUI_PORT:-8080} + protocol: tcp + + volumes: + - type: bind + source: /DATA/AppData/$AppID/config + target: /config + + # Secure-by-default baseline. Relax only if appen kräver det. + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + + 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 + ports: + - container: "8080" + description: + en_US: Web UI port + volumes: + - container: /config + description: + en_US: Application configuration directory + +x-casaos: + architectures: + - amd64 + - arm64 + main: app + category: Utilities + author: Zima Apps Team + developer: example + icon: https://example.invalid/icon.png + tagline: + en_US: Replace with a short one-line value proposition + description: + en_US: Replace with a clear description of what the app does + title: + en_US: Sample App + index: / + port_map: ${WEBUI_PORT:-8080} diff --git a/Apps/caddy-autogen/README.md b/Apps/caddy-autogen/README.md new file mode 100644 index 0000000..324807b --- /dev/null +++ b/Apps/caddy-autogen/README.md @@ -0,0 +1,104 @@ +# Caddy AutoGen + +Caddy AutoGen bygger Caddy-routes "on the fly" från körande containers i ZimaOS/CasaOS. + +Arkitektur: + +- `discovery-agent` läser Docker metadata via `socket-proxy`. +- Endast explicit opt-in exponeras (`LABEL_CADDY_ENABLE=true`). +- Caddy config laddas dynamiskt via Caddy Admin API (`POST /load`). +- TLS sker med Let's Encrypt DNS-01 via Cloudflare. + +## Säkerhetsmodell + +- Fail-closed default: om `REQUIRE_CLOUDFLARE=true` och token saknas laddas ingen ny extern config. +- Ingen auto-exponering av alla portar. +- Endast HTTP(S)-upstreams stöds i v1. +- Docker socket exponeras inte direkt till agenten, endast via `socket-proxy` med begränsade endpoint-flaggor. + +## Miljövariabler (appnivå) + +- `BASE_DOMAIN` (krävs), t.ex. `home.example.com` +- `CLOUDFLARE_API_TOKEN` (krävs i fail-closed läge) +- `WILDCARD_DOMAIN` (valfri), t.ex. `home.example.com` +- `REQUIRE_CLOUDFLARE` (default `true`) +- `ALLOW_INTERNAL_TLS_FALLBACK` (default `false`) +- `POLL_SECONDS` (default `15`) + +Cloudflare-token bör vara scoped till minsta möjliga rättigheter: + +- `Zone.Zone:Read` +- `Zone.DNS:Edit` + +## Miljövariabler på målcontainers (opt-in) + +Sätt dessa på varje container som ska exponeras: + +- `LABEL_CADDY_ENABLE=true` +- `LABEL_CADDY_TARGET_PORT=` + +Valfria: + +- `LABEL_CADDY_HOST=` +- `LABEL_CADDY_SCHEME=http|https` +- `LABEL_CADDY_PATH=/` +- `LABEL_CADDY_HEALTH_URI=/health` + +### Exempel (Frigate) + +För Frigate med portar `5000`, `8554`, `8555`: + +- Sätt `LABEL_CADDY_ENABLE=true` +- Sätt `LABEL_CADDY_TARGET_PORT=5000` + +Då skapas endpoint bara för web UI-porten (`5000`), inte för RTSP/WebRTC-portarna. + +Konkreta env-vars att sätta på Frigate-containern: + +```text +LABEL_CADDY_ENABLE=true +LABEL_CADDY_TARGET_PORT=5000 +LABEL_CADDY_HOST=frigate +LABEL_CADDY_SCHEME=http +LABEL_CADDY_PATH=/ +``` + +## Lokal DNS för split-horizon + +Rekommenderad stack: **AdGuard Home**. + +- Skapa lokala records/rewrite för `*.BASE_DOMAIN` och `BASE_DOMAIN` mot ZimaOS-serverns LAN-IP. +- Behåll samma publika zon i Cloudflare för DNS-01-validering. + +Alternativ som fungerar bra i ZimaOS: + +- Technitium DNS (mer avancerad DNS-kontroll) +- Pi-hole (+ ev. Unbound) + +## Säkerhetsavvikelser + +Följande högriskdetaljer är medvetna designval: + +- Använder Docker API via `socket-proxy` (inte direkt socket i discovery-agent). +- Caddy måste exponera 80/443 för ingress. + +Riskreduktion: + +- socket-proxy endpoint-whitelist (`CONTAINERS`, `EVENTS`, `INFO`, `NETWORKS`, `PING`, `VERSION`) +- `POST=0` +- `no-new-privileges` +- read-only där möjligt + +## Integrationstester + +Kör lokala integrationstester för discovery-agenten: + +```bash +./Apps/caddy-autogen/tests/run_integration_tests.sh +``` + +Testerna mockar Docker API-svar och Caddy `/load`-anrop och verifierar: + +- opt-in-regler (endast markerade containers exponeras), +- säker portselektionslogik (inga media/UDP-portar routas av misstag), +- fail-closed beteende när Cloudflare-token saknas. diff --git a/Apps/caddy-autogen/agent/Dockerfile b/Apps/caddy-autogen/agent/Dockerfile new file mode 100644 index 0000000..3cd1225 --- /dev/null +++ b/Apps/caddy-autogen/agent/Dockerfile @@ -0,0 +1,5 @@ +FROM python:3.12.8-alpine +WORKDIR /app +COPY discovery_agent.py /app/discovery_agent.py +USER 65532:65532 +CMD ["python3", "/app/discovery_agent.py"] diff --git a/Apps/caddy-autogen/agent/discovery_agent.py b/Apps/caddy-autogen/agent/discovery_agent.py new file mode 100644 index 0000000..086fad5 --- /dev/null +++ b/Apps/caddy-autogen/agent/discovery_agent.py @@ -0,0 +1,318 @@ +#!/usr/bin/env python3 +import hashlib +import json +import os +import re +import sys +import time +import urllib.error +import urllib.parse +import urllib.request + + +TRUE_VALUES = {"1", "true", "yes", "on"} +HOST_RE = re.compile(r"^[a-z0-9.-]+$") + + +def _to_bool(value: str, default: bool = False) -> bool: + if value is None: + return default + return value.strip().lower() in TRUE_VALUES + + +def _read_simple_yaml(path: str) -> dict: + data = {} + try: + with open(path, "r", encoding="utf-8") as f: + for line in f: + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + if ":" not in stripped: + continue + key, raw_val = stripped.split(":", 1) + key = key.strip() + val = raw_val.strip() + if val.startswith('"') and val.endswith('"'): + val = val[1:-1] + elif val.lower() in {"true", "false"}: + val = val.lower() == "true" + else: + try: + val = int(val) + except ValueError: + pass + data[key] = val + except FileNotFoundError: + pass + return data + + +def _cfg(name: str, defaults: dict, key: str, fallback=""): + val = os.getenv(name) + if val not in (None, ""): + return val + return defaults.get(key, fallback) + + +def _log(msg: str): + print(msg, flush=True) + + +def _get_json(url: str): + req = urllib.request.Request(url, headers={"Accept": "application/json"}) + with urllib.request.urlopen(req, timeout=10) as resp: + return json.loads(resp.read().decode("utf-8")) + + +def _post_caddyfile(url: str, caddyfile: str): + req = urllib.request.Request( + url, + data=caddyfile.encode("utf-8"), + method="POST", + headers={"Content-Type": "text/caddyfile"}, + ) + with urllib.request.urlopen(req, timeout=15) as resp: + return resp.status + + +def _env_map(inspect_obj: dict) -> dict: + out = {} + env_items = (inspect_obj.get("Config") or {}).get("Env") or [] + for item in env_items: + if "=" in item: + key, value = item.split("=", 1) + out[key] = value + return out + + +def _published_tcp_ports(inspect_obj: dict) -> dict: + ports = {} + net_settings = (inspect_obj.get("NetworkSettings") or {}).get("Ports") or {} + for container_port, mappings in net_settings.items(): + if not container_port.endswith("/tcp"): + continue + container_num = container_port.split("/", 1)[0] + if not mappings: + continue + host_port = mappings[0].get("HostPort") + if host_port: + ports[container_num] = host_port + return ports + + +def _normalize_name(raw_name: str) -> str: + cleaned = raw_name.strip().lstrip("/").lower() + cleaned = re.sub(r"[^a-z0-9-]", "-", cleaned) + cleaned = re.sub(r"-+", "-", cleaned).strip("-") + return cleaned or "app" + + +def _build_fqdn(host_hint: str, base_domain: str) -> str: + if "." in host_hint: + fqdn = host_hint.lower() + else: + fqdn = f"{host_hint.lower()}.{base_domain.lower()}" if base_domain else "" + return fqdn + + +def _collect_routes(docker_api_url: str, env_prefix: str, denylist: set, base_domain: str, default_scheme: str, default_path: str, default_health_uri: str): + routes = [] + containers = _get_json(f"{docker_api_url}/containers/json?all=0") + for c in containers: + cid = c.get("Id") + names = c.get("Names") or [] + primary_name = _normalize_name(names[0] if names else c.get("Image", "container")) + + if primary_name in denylist: + continue + + inspect_obj = _get_json(f"{docker_api_url}/containers/{cid}/json") + envs = _env_map(inspect_obj) + + enabled = _to_bool(envs.get(f"{env_prefix}ENABLE", "false")) + if not enabled: + continue + + target_port = envs.get(f"{env_prefix}TARGET_PORT", "").strip() + if not target_port.isdigit(): + _log(f"WARN: skip {primary_name}: invalid TARGET_PORT='{target_port}'") + continue + + port_map = _published_tcp_ports(inspect_obj) + host_port = port_map.get(target_port) + if not host_port: + _log(f"WARN: skip {primary_name}: target port {target_port} is not published as TCP") + continue + + host_hint = envs.get(f"{env_prefix}HOST", "").strip() or primary_name + fqdn = _build_fqdn(host_hint, base_domain) + if not fqdn or not HOST_RE.match(fqdn): + _log(f"WARN: skip {primary_name}: invalid fqdn '{fqdn}'") + continue + + scheme = (envs.get(f"{env_prefix}SCHEME", "") or default_scheme).strip().lower() + if scheme not in {"http", "https"}: + _log(f"WARN: skip {primary_name}: invalid scheme '{scheme}'") + continue + + path = (envs.get(f"{env_prefix}PATH", "") or default_path).strip() or "/" + if not path.startswith("/"): + path = "/" + path + + health_uri = (envs.get(f"{env_prefix}HEALTH_URI", "") or default_health_uri).strip() + routes.append( + { + "name": primary_name, + "fqdn": fqdn, + "scheme": scheme, + "upstream": f"host.docker.internal:{host_port}", + "path": path, + "health_uri": health_uri, + } + ) + routes.sort(key=lambda r: r["fqdn"]) + return routes + + +def _generate_caddyfile(routes, token: str, require_cloudflare: bool, allow_internal_tls_fallback: bool, wildcard_domain: str, cert_email: str): + if require_cloudflare and not token: + raise RuntimeError("CLOUDFLARE_API_TOKEN is required in fail-closed mode") + + out = ["{"] + if cert_email: + out.append(f" email {cert_email}") + if token: + out.append(" acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN}") + elif allow_internal_tls_fallback: + out.append(" local_certs") + out.append("}") + out.append("") + + if wildcard_domain and token: + out.append(f"{wildcard_domain}, *.{wildcard_domain} {{") + out.append(" tls {") + out.append(" dns cloudflare {env.CLOUDFLARE_API_TOKEN}") + out.append(" }") + out.append(" respond \"wildcard certificate anchor\" 204") + out.append("}") + out.append("") + + if not routes: + out.append(":80 {") + out.append(" respond \"no eligible containers for caddy-autogen\" 200") + out.append("}") + return "\n".join(out) + "\n" + + for route in routes: + out.append(f"{route['fqdn']} {{") + if token: + out.append(" tls {") + out.append(" dns cloudflare {env.CLOUDFLARE_API_TOKEN}") + out.append(" }") + elif allow_internal_tls_fallback: + out.append(" tls internal") + + if route["path"] != "/": + out.append(" @allowed path " + route["path"] + "*") + out.append(" handle @allowed {") + if route["health_uri"]: + out.append(f" reverse_proxy {route['scheme']}://{route['upstream']} {{") + out.append(f" health_uri {route['health_uri']}") + out.append(" }") + else: + out.append(f" reverse_proxy {route['scheme']}://{route['upstream']}") + out.append(" }") + out.append(" respond \"not found\" 404") + else: + if route["health_uri"]: + out.append(f" reverse_proxy {route['scheme']}://{route['upstream']} {{") + out.append(f" health_uri {route['health_uri']}") + out.append(" }") + else: + out.append(f" reverse_proxy {route['scheme']}://{route['upstream']}") + out.append("}") + out.append("") + + return "\n".join(out) + + +def main(): + defaults = _read_simple_yaml(os.getenv("CONFIG_FILE", "/app/config/defaults.yaml")) + + docker_api_url = _cfg("DOCKER_API_URL", defaults, "docker_api_url", "http://socket-proxy:2375") + caddy_load_url = _cfg("CADDY_LOAD_URL", defaults, "caddy_load_url", "http://caddy:2019/load") + base_domain = str(_cfg("BASE_DOMAIN", defaults, "base_domain", "")).strip() + wildcard_domain = str(_cfg("WILDCARD_DOMAIN", defaults, "wildcard_domain", "")).strip() + cert_email = str(_cfg("CERT_EMAIL", defaults, "cert_email", "")).strip() + env_prefix = str(_cfg("ENV_PREFIX", defaults, "env_prefix", "LABEL_CADDY_")).strip() + default_scheme = str(_cfg("DEFAULT_SCHEME", defaults, "default_scheme", "http")).strip().lower() + default_path = str(_cfg("DEFAULT_PATH", defaults, "default_path", "/")).strip() or "/" + default_health_uri = str(_cfg("DEFAULT_HEALTH_URI", defaults, "default_health_uri", "")).strip() + + poll_seconds_raw = _cfg("POLL_SECONDS", defaults, "poll_seconds", 15) + try: + poll_seconds = max(5, int(poll_seconds_raw)) + except (TypeError, ValueError): + poll_seconds = 15 + + denylist_raw = str(_cfg("CONTAINER_NAME_DENYLIST", defaults, "container_name_denylist", "")) + denylist = {item.strip().lower() for item in denylist_raw.split(",") if item.strip()} + + require_cloudflare = _to_bool(str(_cfg("REQUIRE_CLOUDFLARE", defaults, "require_cloudflare", "true")), True) + allow_internal_tls_fallback = _to_bool( + str(_cfg("ALLOW_INTERNAL_TLS_FALLBACK", defaults, "allow_internal_tls_fallback", "false")), + False, + ) + + token = os.getenv("CLOUDFLARE_API_TOKEN", "").strip() + last_digest = "" + + _log( + "INFO: starting caddy-autogen discovery-agent " + f"(docker_api_url={docker_api_url}, caddy_load_url={caddy_load_url}, poll_seconds={poll_seconds})" + ) + + while True: + try: + routes = _collect_routes( + docker_api_url=docker_api_url, + env_prefix=env_prefix, + denylist=denylist, + base_domain=base_domain, + default_scheme=default_scheme, + default_path=default_path, + default_health_uri=default_health_uri, + ) + caddyfile = _generate_caddyfile( + routes=routes, + token=token, + require_cloudflare=require_cloudflare, + allow_internal_tls_fallback=allow_internal_tls_fallback, + wildcard_domain=wildcard_domain, + cert_email=cert_email, + ) + + digest = hashlib.sha256(caddyfile.encode("utf-8")).hexdigest() + if digest != last_digest: + status = _post_caddyfile(caddy_load_url, caddyfile) + _log(f"INFO: applied config (routes={len(routes)}, status={status})") + last_digest = digest + else: + _log(f"INFO: no config changes (routes={len(routes)})") + except urllib.error.HTTPError as e: + _log(f"ERROR: http failure {e.code} {e.reason}") + except urllib.error.URLError as e: + _log(f"ERROR: connection failure: {e}") + except Exception as e: + _log(f"ERROR: {e}") + + time.sleep(poll_seconds) + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + _log("INFO: shutdown requested") + sys.exit(0) diff --git a/Apps/caddy-autogen/caddy/Caddyfile b/Apps/caddy-autogen/caddy/Caddyfile new file mode 100644 index 0000000..3213def --- /dev/null +++ b/Apps/caddy-autogen/caddy/Caddyfile @@ -0,0 +1,7 @@ +{ + admin {$CADDY_ADMIN} +} + +:80 { + respond "caddy-autogen initializing" 200 +} diff --git a/Apps/caddy-autogen/caddy/Dockerfile b/Apps/caddy-autogen/caddy/Dockerfile new file mode 100644 index 0000000..a837e46 --- /dev/null +++ b/Apps/caddy-autogen/caddy/Dockerfile @@ -0,0 +1,7 @@ +FROM caddy:2.10.2-builder-alpine AS builder +RUN xcaddy build \ + --with github.com/caddy-dns/cloudflare + +FROM caddy:2.10.2-alpine +COPY --from=builder /usr/bin/caddy /usr/bin/caddy +COPY Caddyfile /etc/caddy/Caddyfile diff --git a/Apps/caddy-autogen/config/defaults.yaml b/Apps/caddy-autogen/config/defaults.yaml new file mode 100644 index 0000000..f6b1636 --- /dev/null +++ b/Apps/caddy-autogen/config/defaults.yaml @@ -0,0 +1,12 @@ +# Caddy AutoGen defaults +base_domain: "" +wildcard_domain: "" +cert_email: "" +env_prefix: "LABEL_CADDY_" +default_scheme: "http" +default_path: "/" +default_health_uri: "" +poll_seconds: 15 +require_cloudflare: true +allow_internal_tls_fallback: false +container_name_denylist: "caddy-autogen,caddy-autogen-discovery,caddy-autogen-socket-proxy" diff --git a/Apps/caddy-autogen/docker-compose.yaml b/Apps/caddy-autogen/docker-compose.yaml new file mode 100644 index 0000000..a4b622e --- /dev/null +++ b/Apps/caddy-autogen/docker-compose.yaml @@ -0,0 +1,162 @@ +name: caddy-autogen + +services: + caddy: + build: + context: ./caddy + dockerfile: Dockerfile + container_name: caddy-autogen + restart: unless-stopped + environment: + TZ: ${TZ} + CADDY_ADMIN: ${CADDY_ADMIN:-0.0.0.0:2019} + CLOUDFLARE_API_TOKEN: ${CLOUDFLARE_API_TOKEN} + ports: + - target: 80 + published: ${HTTP_PORT:-80} + protocol: tcp + - target: 443 + published: ${HTTPS_PORT:-443} + protocol: tcp + volumes: + - type: bind + source: /DATA/AppData/$AppID/caddy/data + target: /data + - type: bind + source: /DATA/AppData/$AppID/caddy/config + target: /config + extra_hosts: + - host.docker.internal:host-gateway + security_opt: + - no-new-privileges:true + x-casaos: + envs: + - container: CADDY_ADMIN + description: + en_us: Caddy admin endpoint bind address + - container: CLOUDFLARE_API_TOKEN + description: + en_us: Cloudflare API token (Zone Read + DNS Edit) + ports: + - container: "80" + description: + en_us: HTTP ingress + - container: "443" + description: + en_us: HTTPS ingress + volumes: + - container: /data + description: + en_us: Caddy runtime data and certificates + - container: /config + description: + en_us: Caddy configuration state + + socket-proxy: + image: lscr.io/linuxserver/socket-proxy:version-24.02.26 + container_name: caddy-autogen-socket-proxy + restart: unless-stopped + environment: + TZ: ${TZ} + CONTAINERS: 1 + EVENTS: 1 + INFO: 1 + NETWORKS: 1 + PING: 1 + POST: 0 + VERSION: 1 + read_only: true + tmpfs: + - /run + volumes: + - type: bind + source: /var/run/docker.sock + target: /var/run/docker.sock + read_only: true + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + + discovery-agent: + build: + context: ./agent + dockerfile: Dockerfile + container_name: caddy-autogen-discovery + restart: unless-stopped + depends_on: + - caddy + - socket-proxy + environment: + TZ: ${TZ} + DOCKER_API_URL: ${DOCKER_API_URL:-http://socket-proxy:2375} + CADDY_LOAD_URL: ${CADDY_LOAD_URL:-http://caddy:2019/load} + BASE_DOMAIN: ${BASE_DOMAIN} + WILDCARD_DOMAIN: ${WILDCARD_DOMAIN:-} + CLOUDFLARE_API_TOKEN: ${CLOUDFLARE_API_TOKEN} + CERT_EMAIL: ${CERT_EMAIL:-} + REQUIRE_CLOUDFLARE: ${REQUIRE_CLOUDFLARE:-true} + ALLOW_INTERNAL_TLS_FALLBACK: ${ALLOW_INTERNAL_TLS_FALLBACK:-false} + ENV_PREFIX: ${ENV_PREFIX:-LABEL_CADDY_} + POLL_SECONDS: ${POLL_SECONDS:-15} + CONTAINER_NAME_DENYLIST: ${CONTAINER_NAME_DENYLIST:-caddy-autogen,caddy-autogen-discovery,caddy-autogen-socket-proxy} + DEFAULT_SCHEME: ${DEFAULT_SCHEME:-http} + DEFAULT_PATH: ${DEFAULT_PATH:-/} + DEFAULT_HEALTH_URI: ${DEFAULT_HEALTH_URI:-} + CONFIG_FILE: ${CONFIG_FILE:-/app/config/defaults.yaml} + volumes: + - type: bind + source: /DATA/AppData/$AppID/config + target: /app/config + read_only: true + read_only: true + tmpfs: + - /tmp + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + x-casaos: + envs: + - container: BASE_DOMAIN + description: + en_us: Base domain used for endpoints, e.g. home.example.com + - container: WILDCARD_DOMAIN + description: + en_us: Optional wildcard certificate domain, e.g. home.example.com + - container: REQUIRE_CLOUDFLARE + description: + en_us: Fail closed when Cloudflare token is missing + - container: ALLOW_INTERNAL_TLS_FALLBACK + description: + en_us: Enable internal Caddy certificates when Cloudflare is unavailable + - container: POLL_SECONDS + description: + en_us: Docker state reconciliation interval + volumes: + - container: /app/config + description: + en_us: Discovery defaults configuration (read-only) + +x-casaos: + architectures: + - amd64 + - arm64 + - arm + main: caddy + category: Network + author: Zima Apps Team + developer: Zima Apps Team + icon: https://caddyserver.com/resources/images/caddy-circle-lock.svg + tagline: + en_us: Auto-generate Caddy endpoints from running containers + description: + en_us: >- + Discovers ZimaOS containers through Docker API and generates Caddy routes on the fly. + Uses explicit env-based opt-in (LABEL_CADDY_*) with fail-closed defaults, Cloudflare DNS-01 + certificates, and local split-horizon DNS compatibility. + title: + en_us: Caddy AutoGen + index: / + port_map: ${HTTPS_PORT:-443} + scheme: https diff --git a/Apps/caddy-autogen/tests/integration_tests.py b/Apps/caddy-autogen/tests/integration_tests.py new file mode 100644 index 0000000..f35a836 --- /dev/null +++ b/Apps/caddy-autogen/tests/integration_tests.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +import importlib.util +from pathlib import Path + + +ROOT_DIR = Path(__file__).resolve().parents[1] +AGENT_PATH = ROOT_DIR / "agent" / "discovery_agent.py" + + +def load_agent_module(): + spec = importlib.util.spec_from_file_location("discovery_agent", AGENT_PATH) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +def assert_true(condition, message): + if not condition: + raise AssertionError(message) + + +def test_optin_route_selection(module): + containers = [ + {"Id": "frigate123", "Names": ["/frigate"], "Image": "ghcr.io/blakeblackshear/frigate:stable"}, + {"Id": "hidden123", "Names": ["/hidden-service"], "Image": "nginx:1.27"}, + ] + inspect_map = { + "frigate123": { + "Config": { + "Env": [ + "LABEL_CADDY_ENABLE=true", + "LABEL_CADDY_TARGET_PORT=5000", + "LABEL_CADDY_SCHEME=http", + ] + }, + "NetworkSettings": { + "Ports": { + "5000/tcp": [{"HostIp": "0.0.0.0", "HostPort": "5000"}], + "8554/tcp": [{"HostIp": "0.0.0.0", "HostPort": "8554"}], + "8555/tcp": [{"HostIp": "0.0.0.0", "HostPort": "8555"}], + "8555/udp": [{"HostIp": "0.0.0.0", "HostPort": "8555"}], + } + }, + }, + "hidden123": { + "Config": { + "Env": [ + "LABEL_CADDY_ENABLE=false", + "LABEL_CADDY_TARGET_PORT=8080", + ] + }, + "NetworkSettings": { + "Ports": { + "8080/tcp": [{"HostIp": "0.0.0.0", "HostPort": "8080"}], + } + }, + }, + } + + def fake_get_json(url: str): + if url.endswith("/containers/json?all=0"): + return containers + if "/containers/" in url and url.endswith("/json"): + container_id = url.rsplit("/containers/", 1)[1].rsplit("/json", 1)[0] + return inspect_map[container_id] + raise AssertionError(f"unexpected URL in test: {url}") + + original = module._get_json + module._get_json = fake_get_json + try: + routes = module._collect_routes( + docker_api_url="http://docker.test", + env_prefix="LABEL_CADDY_", + denylist={"caddy-autogen", "caddy-autogen-discovery", "caddy-autogen-socket-proxy"}, + base_domain="home.example.test", + default_scheme="http", + default_path="/", + default_health_uri="", + ) + finally: + module._get_json = original + + assert_true(len(routes) == 1, f"expected 1 route, got {len(routes)}") + route = routes[0] + assert_true(route["fqdn"] == "frigate.home.example.test", f"unexpected fqdn: {route['fqdn']}") + assert_true(route["upstream"] == "host.docker.internal:5000", f"unexpected upstream: {route['upstream']}") + + caddyfile = module._generate_caddyfile( + routes=routes, + token="dummy-token", + require_cloudflare=True, + allow_internal_tls_fallback=False, + wildcard_domain="home.example.test", + cert_email="", + ) + + assert_true("frigate.home.example.test" in caddyfile, "expected frigate host in caddyfile") + assert_true("reverse_proxy http://host.docker.internal:5000" in caddyfile, "expected web port route") + assert_true("8554" not in caddyfile and "8555" not in caddyfile, "media ports must not be routed") + assert_true("hidden-service.home.example.test" not in caddyfile, "non opt-in container must stay hidden") + + +def test_fail_closed_and_internal_fallback(module): + routes = [ + { + "name": "demo", + "fqdn": "demo.home.example.test", + "scheme": "http", + "upstream": "host.docker.internal:5000", + "path": "/", + "health_uri": "", + } + ] + + failed = False + try: + module._generate_caddyfile( + routes=routes, + token="", + require_cloudflare=True, + allow_internal_tls_fallback=False, + wildcard_domain="", + cert_email="", + ) + except RuntimeError as exc: + failed = True + assert_true("CLOUDFLARE_API_TOKEN" in str(exc), "expected explicit token error") + assert_true(failed, "expected fail-closed RuntimeError without Cloudflare token") + + fallback_caddyfile = module._generate_caddyfile( + routes=routes, + token="", + require_cloudflare=False, + allow_internal_tls_fallback=True, + wildcard_domain="", + cert_email="", + ) + assert_true("local_certs" in fallback_caddyfile, "expected local_certs in fallback mode") + assert_true("tls internal" in fallback_caddyfile, "expected internal tls in fallback mode") + + +def main(): + module = load_agent_module() + test_optin_route_selection(module) + test_fail_closed_and_internal_fallback(module) + print("Integration tests passed") + + +if __name__ == "__main__": + main() diff --git a/Apps/caddy-autogen/tests/run_integration_tests.sh b/Apps/caddy-autogen/tests/run_integration_tests.sh new file mode 100755 index 0000000..ffeb51f --- /dev/null +++ b/Apps/caddy-autogen/tests/run_integration_tests.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +PYTHONDONTWRITEBYTECODE=1 python3 "$ROOT_DIR/tests/integration_tests.py" diff --git a/Apps/steam-headless/README.md b/Apps/steam-headless/README.md new file mode 100644 index 0000000..057abd0 --- /dev/null +++ b/Apps/steam-headless/README.md @@ -0,0 +1,72 @@ +# Steam Headless + +Steam Headless kör en webbaserad Linux-desktop med Steam i container, baserat på LinuxServer image `lscr.io/linuxserver/steam`. + +## Syfte + +- Ge enkel Steam-access via webbläsare i ZimaOS. +- Hålla v1 med minsta möjliga privilegier. +- Förbereda en separat senare fas för Moonlight-fokuserad streaming. + +## Portar + +- `3000/tcp` (HTTP desktop): `${STEAM_HTTP_PORT:-3000}` +- `3001/tcp` (HTTPS desktop): `${STEAM_HTTPS_PORT:-3001}` + +## Volymer + +- `/DATA/AppData/$AppID/config -> /config` + +All Steam-data (profil, cache, installerade spel) lagras under appens egna AppData-sökväg. + +## Privilegier och säkerhet + +Aktiva säkerhetsinställningar i denna app: + +- `security_opt: ["seccomp:unconfined", "no-new-privileges:true"]` +- `cap_drop: ["ALL"]` +- Ingen `privileged: true` +- Ingen `network_mode: host` +- Ingen mount av `/var/run/docker.sock` + +Motivering: + +- LinuxServer Steam använder sandbox/bubblewrap-mönster som normalt kräver `seccomp:unconfined` för att spel/launcher ska fungera stabilt. +- `no-new-privileges:true` och `cap_drop: ["ALL"]` används för att kompensera med lägsta möjliga capability-yta i övrigt. + +Kända tradeoffs: + +- På vissa Debian/Ubuntu-hostar kan även `apparmor:unconfined` behövas. Detta är inte default här av least-privilege-skäl. +- Browser-vägen (KasmVNC) är enkel men ger inte samma latens/gamepad-egenskaper som Moonlight. + +## Säkerhetsavvikelser + +Denna app använder en avvikelse från strikt seccomp-default: + +- `seccomp:unconfined` + +Varför det behövs: + +- För kompatibilitet med LinuxServer Steam runtime och dess sandboxade processer. + +Alternativ som utvärderats: + +- Standard seccomp-profil: blockar delar av förväntad processmodell för Steam/spel. +- Full `privileged: true`: avvisat på grund av större attackyta. + +Risker: + +- Minskad syscall-filtrering jämfört med default seccomp-profil. +- Om container komprometteras finns större möjlighet att anropa kernel-funktioner än med strikt seccomp. + +Riskreducering: + +- Inga host-network eller docker-socket mounts. +- Capability-surface minimerad med `cap_drop: ["ALL"]`. +- Isolerad data-path under `/DATA/AppData/$AppID/...`. + +## Driftnoteringar + +- För GPU-acceleration kan extra device-mounts krävas beroende på host och drivrutiner. +- Om HTTPS används på `3001` kan webbläsaren visa certifikatvarning vid första anslutning. +- Rekommenderad nästa fas: separat Moonlight/Sunshine-spår som opt-in, med egen riskprofil. diff --git a/Apps/steam-headless/docker-compose.yaml b/Apps/steam-headless/docker-compose.yaml new file mode 100644 index 0000000..6b41490 --- /dev/null +++ b/Apps/steam-headless/docker-compose.yaml @@ -0,0 +1,81 @@ +name: steam-headless + +services: + steam: + image: lscr.io/linuxserver/steam:version-04.02.26 + container_name: steam-headless + restart: unless-stopped + shm_size: "1gb" + + environment: + TZ: ${TZ} + PUID: ${PUID} + PGID: ${PGID} + STEAM_HTTP_PORT: ${STEAM_HTTP_PORT:-3000} + STEAM_HTTPS_PORT: ${STEAM_HTTPS_PORT:-3001} + + ports: + - target: 3000 + published: ${STEAM_HTTP_PORT:-3000} + protocol: tcp + - target: 3001 + published: ${STEAM_HTTPS_PORT:-3001} + protocol: tcp + + volumes: + - type: bind + source: /DATA/AppData/$AppID/config + target: /config + + # Required by LinuxServer Steam for bubblewrap/game namespaces. + security_opt: + - seccomp:unconfined + - no-new-privileges:true + + # Keep capability surface minimal unless a specific game requires otherwise. + cap_drop: + - ALL + + 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 + ports: + - container: "3000" + description: + en_us: Steam desktop GUI over HTTP + - container: "3001" + description: + en_us: Steam desktop GUI over HTTPS + volumes: + - container: /config + description: + en_us: Steam home, configuration, and game files + +x-casaos: + architectures: + - amd64 + main: steam + category: Games + author: Zima Apps Team + developer: linuxserver.io + icon: https://cdn.simpleicons.org/steam + tagline: + en_us: Browser-based Steam desktop container for ZimaOS + description: + en_us: >- + Runs LinuxServer Steam as a web-accessible desktop session. + Optimized for amd64 and least-privilege defaults, with optional future + Moonlight-focused expansion in a later phase. + title: + en_us: Steam Headless + index: / + port_map: ${STEAM_HTTPS_PORT:-3001} + scheme: https diff --git a/README.md b/README.md new file mode 100644 index 0000000..13ff3ae --- /dev/null +++ b/README.md @@ -0,0 +1,54 @@ +# zima-apps + +Skelett för att bygga och underhålla ZimaOS/CasaOS-appar i ett eget appstore-repo. + +## Mål + +- Små, reviewbara appdefinitioner. +- Säkra default-värden (least privilege, pinned image-taggar, inga `latest`). +- Tydlig struktur för metadata, kategorier och rekommendationer. + +## Struktur + +```text +. +├── Apps/ +│ └── _template/ +│ ├── README.md +│ └── docker-compose.yaml +├── category-list.json +├── featured-apps.json +├── recommend-list.json +└── scripts/ + └── validate-appstore.sh +``` + +## Viktiga antaganden + +- Repo använder `docker-compose.yaml` enligt teamets standard. +- Upstream-exempel använder ofta `docker-compose.yml`. +- Om en extern pipeline kräver exakt `.yml` behöver vi lägga till en konverterings/rename-step vid publicering. + +## Snabbstart + +1. Kopiera `Apps/_template` till `Apps/`. +2. Sätt unikt `name` i compose-filen (endast gemener + `-`). +3. Pinna image till explicit version eller digest (inte `latest`). +4. Kör validering: + +```bash +./scripts/validate-appstore.sh +``` + +Inför release/publicering, kör strikt validering för högrisk-inställningar: + +```bash +./scripts/validate-appstore.sh --enforce-risk-docs +``` + +## Säkerhetsriktlinjer + +- Undvik privilegierad container, host network och `docker.sock` om det inte är absolut nödvändigt. +- Håll mounts snäva och appspecifika (`/DATA/AppData/$AppID/...`). +- Sätt `security_opt: ["no-new-privileges:true"]` där det är möjligt. +- Dokumentera avvikelser från säkra default-värden i appens `README.md`. diff --git a/category-list.json b/category-list.json new file mode 100644 index 0000000..f322938 --- /dev/null +++ b/category-list.json @@ -0,0 +1,8 @@ +[ + "Utilities", + "Developer", + "Media", + "Productivity", + "Network", + "System" +] diff --git a/featured-apps.json b/featured-apps.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/featured-apps.json @@ -0,0 +1 @@ +[] diff --git a/recommend-list.json b/recommend-list.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/recommend-list.json @@ -0,0 +1 @@ +[] diff --git a/scripts/validate-appstore.sh b/scripts/validate-appstore.sh new file mode 100755 index 0000000..9fed562 --- /dev/null +++ b/scripts/validate-appstore.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "$0")/.." && pwd)" +cd "$repo_root" + +status=0 +warnings=0 +enforce_risk_docs=0 + +usage() { + echo "Usage: $0 [--enforce-risk-docs]" +} + +if [[ "${1:-}" == "--enforce-risk-docs" ]]; then + enforce_risk_docs=1 + shift +fi + +if [[ $# -ne 0 ]]; then + usage + exit 2 +fi + +if [[ ! -d Apps ]]; then + echo "ERROR: Apps/ saknas" + exit 1 +fi + +while IFS= read -r compose_file; do + app_dir="$(dirname "$compose_file")" + app_id="$(basename "$app_dir")" + readme_file="$app_dir/README.md" + + if [[ "$app_id" == "_template" ]]; then + continue + fi + + if [[ ! -f "$readme_file" ]]; then + echo "ERROR: $app_dir saknar README.md" + status=1 + fi + + name_line="$(rg -n '^name:\s*[a-z0-9-]+\s*$' "$compose_file" || true)" + if [[ -z "$name_line" ]]; then + echo "ERROR: $compose_file saknar giltigt top-level 'name' (gemener + '-')" + status=1 + fi + + if rg -n 'image:\s*[^[:space:]]+:latest\s*$' "$compose_file" >/dev/null; then + echo "ERROR: $compose_file använder förbjuden image-tag ':latest'" + status=1 + fi + + if ! rg -n '^x-casaos:\s*$' "$compose_file" >/dev/null; then + echo "ERROR: $compose_file saknar top-level 'x-casaos'" + status=1 + fi + + risk_items=() + + if rg -n '^\s*privileged:\s*true\s*$' "$compose_file" >/dev/null; then + risk_items+=("privileged:true") + fi + if rg -n '^\s*network_mode:\s*host\s*$' "$compose_file" >/dev/null; then + risk_items+=("network_mode:host") + fi + if rg -n '/var/run/docker.sock' "$compose_file" >/dev/null; then + risk_items+=("docker.sock-mount") + fi + + if [[ ${#risk_items[@]} -gt 0 ]]; then + warnings=$((warnings + 1)) + echo "WARN: $compose_file använder högrisk-inställningar: ${risk_items[*]}" + + if [[ $enforce_risk_docs -eq 1 ]]; then + if [[ ! -f "$readme_file" ]] || ! rg -n '^##\s+(Säkerhetsavvikelser|Security Exceptions)\s*$' "$readme_file" >/dev/null; then + echo "ERROR: $app_dir har högrisk-inställningar men README.md saknar sektion '## Säkerhetsavvikelser' (eller '## Security Exceptions')" + status=1 + fi + fi + fi +done < <(find Apps -type f -name 'docker-compose.yaml' | sort) + +if [[ $status -ne 0 ]]; then + echo "Validation FAILED" + exit $status +fi + +if [[ $warnings -gt 0 ]]; then + echo "Validation OK with $warnings warning(s)" + exit 0 +fi + +echo "Validation OK"