Add caddy-autogen app, tests, and agent policy updates

This commit is contained in:
Joachim Friberg
2026-03-18 16:19:01 +01:00
commit 9ea8ca421b
19 changed files with 1273 additions and 0 deletions
+93
View File
@@ -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/<app-id>/`.
- 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:
`<appnamn>/<initial|bugfix|update>/<detalj>`
Regler för `<detalj>`:
- `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`.
+30
View File
@@ -0,0 +1,30 @@
# App Template
Kopiera denna mapp till `Apps/<app-id>`.
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.
+67
View File
@@ -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}
+104
View File
@@ -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=<container_tcp_port>`
Valfria:
- `LABEL_CADDY_HOST=<subdomain-or-fqdn>`
- `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.
+5
View File
@@ -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"]
+318
View File
@@ -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)
+7
View File
@@ -0,0 +1,7 @@
{
admin {$CADDY_ADMIN}
}
:80 {
respond "caddy-autogen initializing" 200
}
+7
View File
@@ -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
+12
View File
@@ -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"
+162
View File
@@ -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
@@ -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()
+5
View File
@@ -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"
+72
View File
@@ -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.
+81
View File
@@ -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
+54
View File
@@ -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/<app-id>`.
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`.
+8
View File
@@ -0,0 +1,8 @@
[
"Utilities",
"Developer",
"Media",
"Productivity",
"Network",
"System"
]
+1
View File
@@ -0,0 +1 @@
[]
+1
View File
@@ -0,0 +1 @@
[]
+95
View File
@@ -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"