Add caddy-autogen app, tests, and agent policy updates
This commit is contained in:
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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}
|
||||
@@ -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.
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
admin {$CADDY_ADMIN}
|
||||
}
|
||||
|
||||
:80 {
|
||||
respond "caddy-autogen initializing" 200
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
@@ -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"
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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`.
|
||||
@@ -0,0 +1,8 @@
|
||||
[
|
||||
"Utilities",
|
||||
"Developer",
|
||||
"Media",
|
||||
"Productivity",
|
||||
"Network",
|
||||
"System"
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -0,0 +1 @@
|
||||
[]
|
||||
Executable
+95
@@ -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"
|
||||
Reference in New Issue
Block a user