4 Commits

27 changed files with 2590 additions and 0 deletions
@@ -0,0 +1,194 @@
# HOW_TO_VERIFY
Detta dokument verifierar att `docker-ip-addr-manager` fungerar säkert i ZimaOS.
## 1) Förutsättningar
- ZimaOS-host med minst ett interface (ex `eth0`).
- Docker körs på hosten.
- Appen är installerad och startad.
- Webb-UI/API nås på `${APP_PORT:-31810}`.
Kontrollera att appen är uppe:
```bash
curl -fsS http://127.0.0.1:31810/healthz
```
Förväntat resultat:
- JSON med `{"ok":true}`.
## 2) Positiva testfall
### Test A: Skapa och enable IP-post
Skapa post:
```bash
curl -fsS -X POST http://127.0.0.1:31810/api/entries \
-H 'Content-Type: application/json' \
-d '{"name":"lan-test","ip":"10.0.4.2","cidr":16,"device":"eth0"}'
```
Hämta `id` från svaret och enable:
```bash
ENTRY_ID="<id-fran-svaret>"
curl -fsS -X POST "http://127.0.0.1:31810/api/entries/${ENTRY_ID}/enable"
```
Verifiera på host:
```bash
ip -4 -o addr show dev eth0 | rg '10\.0\.4\.2/16'
```
Förväntat resultat:
- IP-adressen finns på `eth0`.
### Test B: Used-detektion via Docker port binding
Starta testcontainer bunden till IP:n:
```bash
docker run -d --rm --name ip-verify-nginx -p 10.0.4.2:18080:80 nginx:1.27.5
```
Refresh appdata:
```bash
curl -fsS -X POST http://127.0.0.1:31810/api/refresh
```
Förväntat resultat:
- posten med `10.0.4.2` har `used=true`.
- `containers` innehåller `ip-verify-nginx`.
### Test C: Disable/Delete efter frigöring
Stoppa testcontainer:
```bash
docker stop ip-verify-nginx
```
Disable posten:
```bash
curl -fsS -X POST "http://127.0.0.1:31810/api/entries/${ENTRY_ID}/disable"
```
Verifiera borttagen IP:
```bash
ip -4 -o addr show dev eth0 | rg '10\.0\.4\.2/16' || true
```
Förväntat resultat:
- ingen träff för `10.0.4.2/16`.
Ta bort posten:
```bash
curl -fsS -X DELETE "http://127.0.0.1:31810/api/entries/${ENTRY_ID}"
```
Förväntat resultat:
- svar med `{"deleted":true}`.
## 3) Negativt / fail-closed testfall
### Test D: Blockera disable när IP används
1. Skapa + enable som i Test A.
2. Starta container som i Test B.
3. Försök disable:
```bash
curl -sS -o /tmp/disable.out -w '%{http_code}\n' \
-X POST "http://127.0.0.1:31810/api/entries/${ENTRY_ID}/disable"
cat /tmp/disable.out
```
Förväntat resultat:
- HTTP `409`.
- feltext som anger att posten används av container.
## 4) DNS / nät / TLS verifiering
### DNS (om hostname används i LAN)
```bash
DNS_SERVER="<dns-server-ip>"
HOSTNAME_TO_TEST="<hostname-i-lan>"
dig +short "${HOSTNAME_TO_TEST}" @"${DNS_SERVER}"
```
Förväntat resultat:
- returnerar avsedd LAN-IP.
### Nätverk (lyssning och routning)
```bash
ss -ltnp | rg ':31810'
ip route get 10.0.4.2
```
Förväntat resultat:
- appen lyssnar på `31810`.
- route lookup fungerar mot rätt interface.
### TLS
Appens UI/API i v1 kör HTTP, inte HTTPS.
Verifiera att HTTP fungerar:
```bash
curl -fsS http://127.0.0.1:31810/healthz
```
Verifiera att HTTPS mot app-porten inte ska användas:
```bash
curl -k https://127.0.0.1:31810/healthz || true
```
Förväntat resultat:
- HTTP fungerar.
- HTTPS-anrop misslyckas eller ger ogiltigt TLS-handslag.
## 5) Data att samla (för snabb felsökning)
- Versions-/buildinfo:
- app-id: `docker-ip-addr-manager`
- branch/commit eller zip + checksum.
- Relevant konfiguration (maska secrets):
```bash
docker inspect docker-ip-addr-manager | jq '.[0].Config.Env'
```
- Loggar från berörda containers:
```bash
docker logs --tail 200 docker-ip-addr-manager
docker logs --tail 200 docker-ip-addr-manager-socket-proxy
```
- Konkreta felobservationer:
- hostname/IP,
- exakt tidpunkt,
- förväntat beteende,
- faktiskt beteende,
- exakta kommandon som kördes.
+105
View File
@@ -0,0 +1,105 @@
# Docker IP Addr Manager
Docker IP Addr Manager ger ett enkelt GUI i ZimaOS för att hantera host-IP-aliaser som används i portbindings (`IP:PORT`).
Exempel: istället för att köra `ip addr add 10.0.4.2/16 dev eth0` via SSH, kan du skapa en post i GUI och aktivera den.
## Funktioner (v1)
- CRUD för IP-poster: `name`, `ip`, `cidr`, `device`.
- Enable/disable per post:
- enable => `ip addr add <ip>/<cidr> dev <device>`
- disable => `ip addr del <ip>/<cidr> dev <device>`
- Sorterbar tabell: namn, IP-adress, used/unused, containernamn, device, enable/disable.
- Used/unused-kontroll via Docker API (`NetworkSettings.Ports`) med exakt `HostIp`-match.
- Include stopped containers i used-kontroll.
- Fail-closed:
- disable blockeras om IP används av minst en container,
- delete blockeras om posten är enabled eller used,
- disable/delete blockeras om Docker-usage inte kan verifieras.
- Startup reconcile: enabled-poster återappliceras vid appstart.
- Manuell refresh-knapp (ingen websocket i v1).
## Portar
- `${APP_PORT:-31810}`: webbgränssnitt/API.
- `127.0.0.1:2375` (socket-proxy, valfritt): lokal Docker TCP proxy för alternativ endpoint.
## Volymer
- `/DATA/AppData/$AppID/data:/data`
- persistent lagring av IP-poster.
- `/var/run/docker.sock:/var/run/docker.sock:ro`
- Docker metadata för used/unused-kontroll.
## Privilegier
- `network_mode: host`
- `cap_add: [NET_ADMIN]`
- `security_opt: ["no-new-privileges:true"]`
## Säkerhetsavvikelser
Denna app använder högrisk-inställningar och de är avsiktliga:
- `network_mode: host`
- mount av `/var/run/docker.sock`
### Varför det behövs
- `ip addr add/del` måste köras i hostens nätverksnamespace för att påverka hostens interface (ex. `eth0`).
- Used/unused-status behöver läsas från Docker container metadata.
### Alternativ som utvärderats
- **Extern host-agent utanför Docker**: bättre isolering men kräver separat installation utanför appstore.
- **nsenter-helper**: mer komplex driftsmodell och högre implementationsoverhead för v1.
- **Endast Docker TCP endpoint utan socket**: stöds, men default är unix-socket för enklare baseline i ZimaOS.
### Risker
- Host network + `NET_ADMIN` kan påverka hostens nätverk direkt vid felaktig konfiguration.
- Docker socket-access innebär insyn i container-metadata och behöver hanteras med strikt kontroll.
### Riskreducering
- Minsta capability för nätverksoperationer (`NET_ADMIN`), inte `privileged: true`.
- `no-new-privileges:true`.
- Fail-closed blockering vid osäker Docker-usage.
- Valbar socket-proxy med read-only Docker socket och endpoint-begränsning.
## Konfiguration
Viktiga environment-variabler:
- `APP_PORT` (default `31810`)
- `DOCKER_API_URL` (default `unix:///var/run/docker.sock`)
- alternativt `http://127.0.0.1:2375` för socket-proxy.
- `DOCKER_TIMEOUT_SECONDS` (default `3`)
- `STATE_FILE` (default `/data/entries.json`)
## Integrationstester
Kör:
```bash
./Apps/docker-ip-addr-manager/tests/run_integration_tests.sh
```
Testerna mockar Docker API och `ip`-kommandoflöde och verifierar:
- exakt `HostIp`-matchning,
- fail-closed disable/delete,
- blockering vid enabled/used,
- startup reconcile av enabled-poster.
## Auth-notis
Ingen auth ingår i v1.
Auth/autorisering ska implementeras i en senare version och är en uttalad roadmap-punkt.
## Roadmap (ej v1)
- WebSocket-baserad live-uppdatering av used-status.
- DNS-integration (Cloudflare/lokal DNS) kopplat till IP-poster och hostnamn.
@@ -0,0 +1,19 @@
FROM python:3.12.9-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends iproute2 \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt /tmp/requirements.txt
RUN pip install --no-cache-dir -r /tmp/requirements.txt
COPY app /app/app
EXPOSE 31810
CMD ["sh", "-c", "uvicorn app.main:app --host 0.0.0.0 --port ${APP_PORT:-31810}"]
@@ -0,0 +1 @@
"""docker-ip-addr-manager backend package."""
@@ -0,0 +1,21 @@
from __future__ import annotations
from dataclasses import dataclass
import os
@dataclass(frozen=True)
class Settings:
state_file: str
docker_api_url: str
docker_timeout_seconds: float
app_port: int
def get_settings() -> Settings:
return Settings(
state_file=os.getenv("STATE_FILE", "/data/entries.json"),
docker_api_url=os.getenv("DOCKER_API_URL", "unix:///var/run/docker.sock"),
docker_timeout_seconds=float(os.getenv("DOCKER_TIMEOUT_SECONDS", "3")),
app_port=int(os.getenv("APP_PORT", "31810")),
)
@@ -0,0 +1,134 @@
from __future__ import annotations
import http.client
import json
import socket
from urllib.parse import urlencode, urlparse
class DockerApiError(RuntimeError):
pass
class _UnixHTTPConnection(http.client.HTTPConnection):
def __init__(self, socket_path: str, timeout: float):
super().__init__(host="localhost", timeout=timeout)
self._socket_path = socket_path
def connect(self) -> None:
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.settimeout(self.timeout)
sock.connect(self._socket_path)
self.sock = sock
class DockerApiClient:
def __init__(self, base_url: str, timeout_seconds: float = 3.0):
parsed = urlparse(base_url)
self._timeout = timeout_seconds
if parsed.scheme == "unix":
self._mode = "unix"
self._socket_path = parsed.path or "/var/run/docker.sock"
self._base_path = ""
elif parsed.scheme in {"http", "https"}:
if not parsed.netloc:
raise ValueError(f"Invalid Docker URL (missing host): {base_url}")
self._mode = parsed.scheme
self._host = parsed.hostname or "localhost"
self._port = parsed.port
self._base_path = parsed.path.rstrip("/")
else:
raise ValueError(f"Unsupported Docker URL scheme: {parsed.scheme}")
def list_containers(self, include_stopped: bool = True) -> list[dict]:
query = urlencode({"all": "1" if include_stopped else "0"})
return self._get_json(f"/containers/json?{query}")
def inspect_container(self, container_id: str) -> dict:
return self._get_json(f"/containers/{container_id}/json")
def _get_json(self, path: str):
body = self._get(path)
try:
return json.loads(body)
except json.JSONDecodeError as exc:
raise DockerApiError(f"Docker API returned invalid JSON for {path}: {exc}") from exc
def _get(self, path: str) -> str:
if not path.startswith("/"):
path = "/" + path
if self._mode == "unix":
conn = _UnixHTTPConnection(self._socket_path, timeout=self._timeout)
request_path = f"{self._base_path}{path}"
else:
if self._mode == "https":
conn = http.client.HTTPSConnection(self._host, self._port, timeout=self._timeout)
else:
conn = http.client.HTTPConnection(self._host, self._port, timeout=self._timeout)
request_path = f"{self._base_path}{path}"
try:
conn.request("GET", request_path)
response = conn.getresponse()
payload = response.read().decode("utf-8", errors="replace")
except OSError as exc:
raise DockerApiError(f"Docker API request failed for {path}: {exc}") from exc
finally:
conn.close()
if response.status < 200 or response.status >= 300:
raise DockerApiError(
f"Docker API error for {path}: HTTP {response.status} {response.reason}; body={payload[:400]}"
)
return payload
class DockerUsageResolver:
def __init__(self, docker_client: DockerApiClient):
self._docker = docker_client
def resolve_ip_usage(self, ips: set[str]) -> dict[str, set[str]]:
usage: dict[str, set[str]] = {ip: set() for ip in ips}
if not ips:
return usage
containers = self._docker.list_containers(include_stopped=True)
for container in containers:
container_id = container.get("Id")
if not container_id:
continue
inspect_payload = self._docker.inspect_container(container_id)
container_name = _display_name(container, inspect_payload)
ports = ((inspect_payload.get("NetworkSettings") or {}).get("Ports") or {})
if not isinstance(ports, dict):
continue
for bindings in ports.values():
if not bindings:
continue
for binding in bindings:
if not isinstance(binding, dict):
continue
host_ip = binding.get("HostIp")
if host_ip in usage:
usage[host_ip].add(container_name)
return usage
def _display_name(container_summary: dict, container_inspect: dict) -> str:
inspect_name = container_inspect.get("Name")
if isinstance(inspect_name, str) and inspect_name.strip("/"):
return inspect_name.strip("/")
names = container_summary.get("Names")
if isinstance(names, list) and names:
first_name = str(names[0]).strip("/")
if first_name:
return first_name
container_id = container_summary.get("Id", "")
return str(container_id)[:12] or "unknown"
@@ -0,0 +1,28 @@
from __future__ import annotations
import subprocess
def list_host_interfaces() -> list[str]:
result = subprocess.run(
["ip", "-o", "link", "show"],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
raise RuntimeError(f"Failed to list interfaces: {result.stderr.strip()}")
interfaces: list[str] = []
seen = set()
for line in result.stdout.splitlines():
parts = line.split(":", maxsplit=2)
if len(parts) < 2:
continue
name = parts[1].strip().split("@", maxsplit=1)[0]
if not name or name in seen:
continue
seen.add(name)
interfaces.append(name)
return interfaces
@@ -0,0 +1,48 @@
from __future__ import annotations
import subprocess
from typing import Callable
class CommandError(RuntimeError):
pass
Runner = Callable[[list[str]], subprocess.CompletedProcess[str]]
def _default_runner(args: list[str]) -> subprocess.CompletedProcess[str]:
return subprocess.run(args, capture_output=True, text=True, check=False)
class IpAddressManager:
def __init__(self, runner: Runner | None = None):
self._runner = runner or _default_runner
def is_present(self, ip: str, cidr: int, device: str) -> bool:
target = f"{ip}/{cidr}"
result = self._runner(["ip", "-4", "-o", "addr", "show", "dev", device])
if result.returncode != 0:
raise CommandError(f"Failed to query addresses on {device}: {result.stderr.strip()}")
for line in result.stdout.splitlines():
tokens = line.split()
if target in tokens:
return True
return False
def ensure_present(self, ip: str, cidr: int, device: str) -> None:
if self.is_present(ip, cidr, device):
return
result = self._runner(["ip", "addr", "add", f"{ip}/{cidr}", "dev", device])
if result.returncode != 0 and "File exists" not in result.stderr:
raise CommandError(f"Failed to add address {ip}/{cidr} on {device}: {result.stderr.strip()}")
def ensure_absent(self, ip: str, cidr: int, device: str) -> None:
if not self.is_present(ip, cidr, device):
return
result = self._runner(["ip", "addr", "del", f"{ip}/{cidr}", "dev", device])
if result.returncode != 0 and "Cannot assign requested address" not in result.stderr:
raise CommandError(f"Failed to remove address {ip}/{cidr} on {device}: {result.stderr.strip()}")
@@ -0,0 +1,141 @@
from __future__ import annotations
from pathlib import Path
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from app.config import get_settings
from app.docker_api import DockerApiClient, DockerApiError, DockerUsageResolver
from app.ip_commands import CommandError, IpAddressManager
from app.service import (
ConflictError,
DependencyError,
EntryService,
NotFoundError,
ValidationError,
)
from app.storage import EntryStorage
def build_service() -> EntryService:
settings = get_settings()
storage = EntryStorage(settings.state_file)
docker_client = DockerApiClient(settings.docker_api_url, timeout_seconds=settings.docker_timeout_seconds)
usage_resolver = DockerUsageResolver(docker_client)
ip_manager = IpAddressManager()
return EntryService(storage=storage, usage_resolver=usage_resolver, ip_manager=ip_manager)
service = build_service()
app = FastAPI(title="Docker IP Addr Manager", version="0.1.0")
static_dir = Path(__file__).parent / "static"
app.mount("/static", StaticFiles(directory=static_dir), name="static")
@app.on_event("startup")
def startup_reconcile() -> None:
errors = service.reconcile_enabled_entries()
if errors:
for error in errors:
print(f"[startup-reconcile] {error}")
@app.get("/")
def index() -> FileResponse:
return FileResponse(static_dir / "index.html")
@app.get("/healthz")
def healthz() -> dict:
return {"ok": True}
@app.get("/api/interfaces")
def list_interfaces() -> dict:
try:
return {"items": service.list_interfaces()}
except DependencyError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
@app.get("/api/entries")
def list_entries() -> dict:
response = service.list_entries()
return response.to_dict()
@app.post("/api/refresh")
def refresh_entries() -> dict:
response = service.list_entries()
return response.to_dict()
@app.post("/api/entries")
def create_entry(payload: dict) -> dict:
try:
entry = service.create_entry(payload)
return entry.to_dict()
except ValidationError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
except ConflictError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
@app.put("/api/entries/{entry_id}")
def update_entry(entry_id: str, payload: dict) -> dict:
try:
entry = service.update_entry(entry_id, payload)
return entry.to_dict()
except ValidationError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
except ConflictError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except NotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@app.post("/api/entries/{entry_id}/enable")
def enable_entry(entry_id: str) -> dict:
try:
entry = service.set_enabled(entry_id, enabled=True)
return entry.to_dict()
except NotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except (ConflictError, CommandError) as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except DependencyError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
@app.post("/api/entries/{entry_id}/disable")
def disable_entry(entry_id: str) -> dict:
try:
entry = service.set_enabled(entry_id, enabled=False)
return entry.to_dict()
except NotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except (ConflictError, CommandError) as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except DependencyError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
@app.delete("/api/entries/{entry_id}")
def delete_entry(entry_id: str) -> dict:
try:
service.delete_entry(entry_id)
return {"deleted": True}
except NotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except ConflictError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except DependencyError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
@app.exception_handler(DockerApiError)
async def docker_error_handler(_, exc: DockerApiError):
return JSONResponse(status_code=503, content={"detail": str(exc)})
@@ -0,0 +1,62 @@
from __future__ import annotations
from dataclasses import dataclass
@dataclass
class IpEntry:
id: str
name: str
ip: str
cidr: int
device: str
enabled: bool
@classmethod
def from_dict(cls, data: dict) -> "IpEntry":
return cls(
id=str(data["id"]),
name=str(data["name"]),
ip=str(data["ip"]),
cidr=int(data["cidr"]),
device=str(data["device"]),
enabled=bool(data.get("enabled", False)),
)
def to_dict(self) -> dict:
return {
"id": self.id,
"name": self.name,
"ip": self.ip,
"cidr": self.cidr,
"device": self.device,
"enabled": self.enabled,
}
@dataclass
class EntryView:
id: str
name: str
ip: str
cidr: int
device: str
enabled: bool
effective_enabled: bool
used: bool
containers: list[str]
usage_known: bool
def to_dict(self) -> dict:
return {
"id": self.id,
"name": self.name,
"ip": self.ip,
"cidr": self.cidr,
"device": self.device,
"enabled": self.enabled,
"effective_enabled": self.effective_enabled,
"used": self.used,
"containers": self.containers,
"usage_known": self.usage_known,
}
@@ -0,0 +1,276 @@
from __future__ import annotations
from dataclasses import dataclass
from ipaddress import IPv4Address
import threading
from typing import Callable
from uuid import uuid4
from app.docker_api import DockerApiError, DockerUsageResolver
from app.interfaces import list_host_interfaces
from app.ip_commands import CommandError, IpAddressManager
from app.models import EntryView, IpEntry
from app.storage import EntryStorage
class ValidationError(RuntimeError):
pass
class NotFoundError(RuntimeError):
pass
class ConflictError(RuntimeError):
pass
class DependencyError(RuntimeError):
pass
@dataclass
class EntryListResponse:
items: list[EntryView]
usage_known: bool
usage_error: str | None
def to_dict(self) -> dict:
return {
"items": [item.to_dict() for item in self.items],
"usage_known": self.usage_known,
"usage_error": self.usage_error,
}
class EntryService:
def __init__(
self,
storage: EntryStorage,
usage_resolver: DockerUsageResolver,
ip_manager: IpAddressManager,
interface_provider: Callable[[], list[str]] = list_host_interfaces,
):
self._storage = storage
self._usage_resolver = usage_resolver
self._ip_manager = ip_manager
self._interface_provider = interface_provider
self._lock = threading.Lock()
def list_interfaces(self) -> list[str]:
interfaces = self._interface_provider()
if not interfaces:
raise DependencyError("No network interfaces discovered on host")
return interfaces
def list_entries(self) -> EntryListResponse:
entries = self._storage.list_entries()
usage_map, usage_known, usage_error = self._resolve_usage(entries)
views: list[EntryView] = []
for entry in entries:
containers = sorted(usage_map.get(entry.ip, set()))
used = bool(containers) if usage_known else False
effective_enabled = False
try:
effective_enabled = self._ip_manager.is_present(entry.ip, entry.cidr, entry.device)
except CommandError:
effective_enabled = False
views.append(
EntryView(
id=entry.id,
name=entry.name,
ip=entry.ip,
cidr=entry.cidr,
device=entry.device,
enabled=entry.enabled,
effective_enabled=effective_enabled,
used=used,
containers=containers,
usage_known=usage_known,
)
)
return EntryListResponse(items=views, usage_known=usage_known, usage_error=usage_error)
def create_entry(self, payload: dict) -> IpEntry:
parsed = _parse_payload(payload)
with self._lock:
entries = self._storage.list_entries()
self._assert_device_exists(parsed["device"])
self._assert_unique_binding(entries, ip=parsed["ip"], cidr=parsed["cidr"], device=parsed["device"])
created = IpEntry(
id=uuid4().hex,
name=parsed["name"],
ip=parsed["ip"],
cidr=parsed["cidr"],
device=parsed["device"],
enabled=False,
)
entries.append(created)
self._storage.save_entries(entries)
return created
def update_entry(self, entry_id: str, payload: dict) -> IpEntry:
parsed = _parse_payload(payload)
with self._lock:
entries = self._storage.list_entries()
index, current = _find_entry(entries, entry_id)
if current.enabled:
raise ConflictError("Disable entry before updating IP/cidr/device")
self._assert_device_exists(parsed["device"])
self._assert_unique_binding(
entries,
ip=parsed["ip"],
cidr=parsed["cidr"],
device=parsed["device"],
ignore_entry_id=entry_id,
)
updated = IpEntry(
id=current.id,
name=parsed["name"],
ip=parsed["ip"],
cidr=parsed["cidr"],
device=parsed["device"],
enabled=False,
)
entries[index] = updated
self._storage.save_entries(entries)
return updated
def set_enabled(self, entry_id: str, enabled: bool) -> IpEntry:
with self._lock:
entries = self._storage.list_entries()
index, entry = _find_entry(entries, entry_id)
if enabled:
self._ip_manager.ensure_present(entry.ip, entry.cidr, entry.device)
entry.enabled = True
else:
self._assert_not_used(entry)
self._ip_manager.ensure_absent(entry.ip, entry.cidr, entry.device)
entry.enabled = False
entries[index] = entry
self._storage.save_entries(entries)
return entry
def delete_entry(self, entry_id: str) -> None:
with self._lock:
entries = self._storage.list_entries()
_, entry = _find_entry(entries, entry_id)
if entry.enabled:
raise ConflictError("Disable entry before deleting")
self._assert_not_used(entry)
remaining = [candidate for candidate in entries if candidate.id != entry_id]
self._storage.save_entries(remaining)
def reconcile_enabled_entries(self) -> list[str]:
errors: list[str] = []
with self._lock:
entries = self._storage.list_entries()
changed = False
for entry in entries:
if not entry.enabled:
continue
try:
self._ip_manager.ensure_present(entry.ip, entry.cidr, entry.device)
changed = True
except CommandError as exc:
errors.append(f"{entry.name} ({entry.ip}/{entry.cidr} {entry.device}): {exc}")
if changed:
self._storage.save_entries(entries)
return errors
def _assert_not_used(self, entry: IpEntry) -> None:
try:
usage = self._usage_resolver.resolve_ip_usage({entry.ip})
except DockerApiError as exc:
raise DependencyError(f"Docker usage check failed: {exc}") from exc
containers = sorted(usage.get(entry.ip, set()))
if containers:
raise ConflictError(
"Entry is currently used by container port bindings: " + ", ".join(containers)
)
def _resolve_usage(self, entries: list[IpEntry]) -> tuple[dict[str, set[str]], bool, str | None]:
ips = {entry.ip for entry in entries}
if not ips:
return {}, True, None
try:
usage = self._usage_resolver.resolve_ip_usage(ips)
return usage, True, None
except DockerApiError as exc:
return {ip: set() for ip in ips}, False, str(exc)
def _assert_unique_binding(
self,
entries: list[IpEntry],
ip: str,
cidr: int,
device: str,
ignore_entry_id: str | None = None,
) -> None:
for entry in entries:
if ignore_entry_id and entry.id == ignore_entry_id:
continue
if entry.ip == ip and entry.cidr == cidr and entry.device == device:
raise ConflictError("Entry with same ip/cidr/device already exists")
def _assert_device_exists(self, device: str) -> None:
interfaces = self.list_interfaces()
if device not in interfaces:
raise ValidationError(f"Unknown network device: {device}")
def _find_entry(entries: list[IpEntry], entry_id: str) -> tuple[int, IpEntry]:
for index, entry in enumerate(entries):
if entry.id == entry_id:
return index, entry
raise NotFoundError(f"Entry not found: {entry_id}")
def _parse_payload(payload: dict) -> dict:
name = str(payload.get("name", "")).strip()
ip = str(payload.get("ip", "")).strip()
device = str(payload.get("device", "")).strip()
if not name:
raise ValidationError("Field 'name' is required")
if len(name) > 96:
raise ValidationError("Field 'name' is too long (max 96)")
if not ip:
raise ValidationError("Field 'ip' is required")
try:
ip_obj = IPv4Address(ip)
except ValueError as exc:
raise ValidationError(f"Invalid IPv4 address: {ip}") from exc
if not device:
raise ValidationError("Field 'device' is required")
if any(ch.isspace() for ch in device):
raise ValidationError("Field 'device' cannot contain whitespace")
raw_cidr = payload.get("cidr")
if raw_cidr is None:
raise ValidationError("Field 'cidr' is required")
try:
cidr = int(raw_cidr)
except (TypeError, ValueError) as exc:
raise ValidationError("Field 'cidr' must be an integer") from exc
if cidr < 0 or cidr > 32:
raise ValidationError("Field 'cidr' must be in range 0..32")
return {
"name": name,
"ip": str(ip_obj),
"cidr": cidr,
"device": device,
}
@@ -0,0 +1,216 @@
const { createApp } = Vue;
function mapEntry(entry) {
return {
...entry,
draft: {
name: entry.name,
ip: entry.ip,
cidr: entry.cidr,
device: entry.device,
},
};
}
createApp({
data() {
return {
interfaces: [],
entries: [],
usageKnown: true,
usageError: "",
loading: false,
saving: false,
busyById: {},
errorMessage: "",
form: {
name: "",
ip: "",
cidr: 16,
device: "",
},
sort: {
key: "name",
direction: "asc",
},
};
},
computed: {
sortedEntries() {
const list = [...this.entries];
const key = this.sort.key;
const multiplier = this.sort.direction === "asc" ? 1 : -1;
const extractors = {
name: (entry) => entry.name.toLowerCase(),
ip: (entry) => entry.ip,
cidr: (entry) => entry.cidr,
used: (entry) => (entry.usage_known ? (entry.used ? 1 : 0) : -1),
containers: (entry) => entry.containers.join(",").toLowerCase(),
device: (entry) => entry.device.toLowerCase(),
enabled: (entry) => (entry.enabled ? 1 : 0),
};
const extract = extractors[key] || extractors.name;
list.sort((a, b) => {
const left = extract(a);
const right = extract(b);
if (left < right) {
return -1 * multiplier;
}
if (left > right) {
return 1 * multiplier;
}
return a.name.localeCompare(b.name) * multiplier;
});
return list;
},
},
methods: {
async api(path, options = {}) {
const response = await fetch(path, {
headers: {
"Content-Type": "application/json",
},
...options,
});
let payload = {};
try {
payload = await response.json();
} catch (err) {
payload = {};
}
if (!response.ok) {
const message = payload.detail || `Request failed: ${response.status}`;
throw new Error(message);
}
return payload;
},
async loadInterfaces() {
const data = await this.api("/api/interfaces");
this.interfaces = data.items || [];
if (!this.form.device && this.interfaces.length > 0) {
this.form.device = this.interfaces[0];
}
},
async refreshEntries() {
this.loading = true;
this.errorMessage = "";
try {
const data = await this.api("/api/refresh", { method: "POST" });
this.entries = (data.items || []).map(mapEntry);
this.usageKnown = Boolean(data.usage_known);
this.usageError = data.usage_error || "";
} catch (err) {
this.errorMessage = err.message;
} finally {
this.loading = false;
}
},
async createEntry() {
this.saving = true;
this.errorMessage = "";
try {
await this.api("/api/entries", {
method: "POST",
body: JSON.stringify({
name: this.form.name,
ip: this.form.ip,
cidr: this.form.cidr,
device: this.form.device,
}),
});
this.form.name = "";
this.form.ip = "";
await this.refreshEntries();
} catch (err) {
this.errorMessage = err.message;
} finally {
this.saving = false;
}
},
async saveEntry(entry) {
this.errorMessage = "";
this.busyById = { ...this.busyById, [entry.id]: true };
try {
await this.api(`/api/entries/${entry.id}`, {
method: "PUT",
body: JSON.stringify({
name: entry.draft.name,
ip: entry.draft.ip,
cidr: entry.draft.cidr,
device: entry.draft.device,
}),
});
await this.refreshEntries();
} catch (err) {
this.errorMessage = err.message;
} finally {
this.busyById = { ...this.busyById, [entry.id]: false };
}
},
async toggleEntry(entry) {
this.errorMessage = "";
this.busyById = { ...this.busyById, [entry.id]: true };
try {
const action = entry.enabled ? "disable" : "enable";
await this.api(`/api/entries/${entry.id}/${action}`, { method: "POST" });
await this.refreshEntries();
} catch (err) {
this.errorMessage = err.message;
} finally {
this.busyById = { ...this.busyById, [entry.id]: false };
}
},
async deleteEntry(entry) {
this.errorMessage = "";
this.busyById = { ...this.busyById, [entry.id]: true };
try {
await this.api(`/api/entries/${entry.id}`, { method: "DELETE" });
await this.refreshEntries();
} catch (err) {
this.errorMessage = err.message;
} finally {
this.busyById = { ...this.busyById, [entry.id]: false };
}
},
containerLabel(entry) {
if (!entry.containers || entry.containers.length === 0) {
return "-";
}
if (entry.containers.length === 1) {
return entry.containers[0];
}
return `${entry.containers.join(", ")} (${entry.containers.length})`;
},
setSort(key) {
if (this.sort.key === key) {
this.sort.direction = this.sort.direction === "asc" ? "desc" : "asc";
return;
}
this.sort.key = key;
this.sort.direction = "asc";
},
sortIndicator(key) {
if (this.sort.key !== key) {
return "";
}
return this.sort.direction === "asc" ? "▲" : "▼";
},
isBusy(entryId) {
return Boolean(this.busyById[entryId]);
},
},
async mounted() {
try {
await this.loadInterfaces();
await this.refreshEntries();
} catch (err) {
this.errorMessage = err.message;
}
},
}).mount("#app");
@@ -0,0 +1,126 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Docker IP Addr Manager</title>
<link rel="stylesheet" href="/static/styles.css" />
</head>
<body>
<main id="app" class="app-shell" v-cloak>
<header class="topbar">
<div>
<h1>Docker IP Addr Manager</h1>
<p>Manage host IP addresses used for ZimaOS port bindings.</p>
</div>
<button type="button" class="btn btn-secondary" @click="refreshEntries" :disabled="loading">
{{ loading ? 'Refreshing...' : 'Refresh' }}
</button>
</header>
<section class="panel">
<h2>Add IP Entry</h2>
<form class="entry-form" @submit.prevent="createEntry">
<label>
Name
<input v-model.trim="form.name" type="text" required maxlength="96" />
</label>
<label>
IP Address
<input v-model.trim="form.ip" type="text" placeholder="10.0.4.2" required />
</label>
<label>
CIDR
<input v-model.number="form.cidr" type="number" min="0" max="32" required />
</label>
<label>
Device
<select v-model="form.device" required>
<option disabled value="">Choose device</option>
<option v-for="iface in interfaces" :key="iface" :value="iface">{{ iface }}</option>
</select>
</label>
<button class="btn btn-primary" type="submit" :disabled="saving">Add</button>
</form>
</section>
<section class="panel">
<div class="status" v-if="errorMessage">{{ errorMessage }}</div>
<div class="status warning" v-if="!usageKnown">
Docker usage status is unknown. Disable/Delete operations are fail-closed until refresh succeeds.
<span v-if="usageError">Error: {{ usageError }}</span>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th @click="setSort('name')">Name <span>{{ sortIndicator('name') }}</span></th>
<th @click="setSort('ip')">IP Address <span>{{ sortIndicator('ip') }}</span></th>
<th @click="setSort('cidr')">CIDR <span>{{ sortIndicator('cidr') }}</span></th>
<th @click="setSort('used')">Used <span>{{ sortIndicator('used') }}</span></th>
<th @click="setSort('containers')">Container(s) <span>{{ sortIndicator('containers') }}</span></th>
<th @click="setSort('device')">Device <span>{{ sortIndicator('device') }}</span></th>
<th @click="setSort('enabled')">Enabled <span>{{ sortIndicator('enabled') }}</span></th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="entry in sortedEntries" :key="entry.id">
<td><input v-model.trim="entry.draft.name" type="text" maxlength="96" :disabled="entry.enabled" /></td>
<td><input v-model.trim="entry.draft.ip" type="text" :disabled="entry.enabled" /></td>
<td><input v-model.number="entry.draft.cidr" type="number" min="0" max="32" :disabled="entry.enabled" /></td>
<td class="status-cell">
<span v-if="!entry.usage_known" class="chip unknown">Unknown</span>
<span v-else-if="entry.used" class="chip used">Yes</span>
<span v-else class="chip free">No</span>
</td>
<td class="containers">{{ containerLabel(entry) }}</td>
<td>
<select v-model="entry.draft.device" :disabled="entry.enabled">
<option v-for="iface in interfaces" :key="`${entry.id}-${iface}`" :value="iface">{{ iface }}</option>
</select>
</td>
<td class="toggle-cell">
<button
type="button"
class="btn"
:class="entry.enabled ? 'btn-danger' : 'btn-primary'"
@click="toggleEntry(entry)"
:disabled="isBusy(entry.id)"
>
{{ entry.enabled ? 'Disable' : 'Enable' }}
</button>
</td>
<td class="actions">
<button
type="button"
class="btn btn-secondary"
@click="saveEntry(entry)"
:disabled="entry.enabled || isBusy(entry.id)"
>Save</button>
<button
type="button"
class="btn btn-danger"
@click="deleteEntry(entry)"
:disabled="entry.enabled || isBusy(entry.id)"
>Delete</button>
</td>
</tr>
<tr v-if="sortedEntries.length === 0">
<td colspan="8" class="empty">No entries configured.</td>
</tr>
</tbody>
</table>
</div>
</section>
</main>
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<script src="/static/app.js"></script>
</body>
</html>
@@ -0,0 +1,227 @@
:root {
--bg: #f2f5f8;
--panel: #ffffff;
--border: #d7dee7;
--text: #1c2733;
--muted: #5f6b77;
--primary: #2274e5;
--danger: #c63434;
--warning: #8a6b00;
--chip-used: #ffe8e8;
--chip-free: #e7f7ec;
--chip-unknown: #fff6d8;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: "Noto Sans", "Segoe UI", sans-serif;
background: linear-gradient(180deg, #f4f7fb 0%, #ecf1f7 100%);
color: var(--text);
}
[v-cloak] {
display: none;
}
.app-shell {
max-width: 1200px;
margin: 0 auto;
padding: 1.5rem;
display: grid;
gap: 1rem;
}
.topbar {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: center;
}
.topbar h1 {
margin: 0;
font-size: 1.45rem;
}
.topbar p {
margin: 0.2rem 0 0;
color: var(--muted);
}
.panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1rem;
}
.panel h2 {
margin: 0 0 0.8rem;
font-size: 1.05rem;
}
.entry-form {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 0.75rem;
align-items: end;
}
.entry-form label {
display: grid;
gap: 0.35rem;
font-size: 0.9rem;
color: var(--muted);
}
input,
select,
button {
font: inherit;
}
input,
select {
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.5rem 0.55rem;
min-height: 2.2rem;
background: #fff;
color: var(--text);
}
.btn {
border: 0;
border-radius: 8px;
padding: 0.55rem 0.85rem;
cursor: pointer;
min-height: 2.2rem;
}
.btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.btn-primary {
background: var(--primary);
color: #fff;
}
.btn-secondary {
background: #e8edf5;
color: var(--text);
}
.btn-danger {
background: var(--danger);
color: #fff;
}
.status {
margin-bottom: 0.8rem;
padding: 0.65rem 0.8rem;
border-radius: 8px;
background: #e8f0fc;
border: 1px solid #c7daf8;
color: #2a4b7d;
}
.status.warning {
background: #fff8df;
border-color: #f0db97;
color: var(--warning);
}
.table-wrap {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
min-width: 900px;
}
th,
td {
border-bottom: 1px solid var(--border);
padding: 0.55rem;
text-align: left;
vertical-align: middle;
}
th {
color: var(--muted);
font-weight: 600;
font-size: 0.88rem;
user-select: none;
cursor: pointer;
white-space: nowrap;
}
th:last-child {
cursor: default;
}
.status-cell,
.toggle-cell,
.actions {
white-space: nowrap;
}
.actions {
display: flex;
gap: 0.45rem;
}
.containers {
color: var(--muted);
max-width: 320px;
overflow: hidden;
text-overflow: ellipsis;
}
.chip {
display: inline-block;
border-radius: 999px;
padding: 0.2rem 0.55rem;
font-size: 0.78rem;
font-weight: 600;
}
.chip.used {
background: var(--chip-used);
color: #8a1f1f;
}
.chip.free {
background: var(--chip-free);
color: #145a2c;
}
.chip.unknown {
background: var(--chip-unknown);
color: #7d5b04;
}
.empty {
text-align: center;
color: var(--muted);
padding: 1rem;
}
@media (max-width: 760px) {
.app-shell {
padding: 1rem;
}
.topbar {
flex-direction: column;
align-items: stretch;
}
}
@@ -0,0 +1,40 @@
from __future__ import annotations
import json
from pathlib import Path
import threading
from typing import Iterable
from app.models import IpEntry
class EntryStorage:
def __init__(self, file_path: str):
self._path = Path(file_path)
self._lock = threading.Lock()
def list_entries(self) -> list[IpEntry]:
with self._lock:
return self._read_entries_unlocked()
def save_entries(self, entries: Iterable[IpEntry]) -> None:
serializable = [entry.to_dict() for entry in entries]
with self._lock:
self._path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = self._path.with_suffix(self._path.suffix + ".tmp")
tmp_path.write_text(json.dumps(serializable, indent=2, sort_keys=True), encoding="utf-8")
tmp_path.replace(self._path)
def _read_entries_unlocked(self) -> list[IpEntry]:
if not self._path.exists():
return []
raw = self._path.read_text(encoding="utf-8").strip()
if not raw:
return []
parsed = json.loads(raw)
if not isinstance(parsed, list):
raise ValueError(f"State file must be a list: {self._path}")
return [IpEntry.from_dict(item) for item in parsed]
@@ -0,0 +1,2 @@
fastapi==0.116.1
uvicorn==0.35.0
@@ -0,0 +1,95 @@
name: docker-ip-addr-manager
services:
app:
build:
context: ./backend
dockerfile: Dockerfile
container_name: docker-ip-addr-manager
restart: unless-stopped
network_mode: host
cap_add:
- NET_ADMIN
security_opt:
- no-new-privileges:true
environment:
TZ: ${TZ}
APP_PORT: ${APP_PORT:-31810}
STATE_FILE: ${STATE_FILE:-/data/entries.json}
DOCKER_API_URL: ${DOCKER_API_URL:-unix:///var/run/docker.sock}
DOCKER_TIMEOUT_SECONDS: ${DOCKER_TIMEOUT_SECONDS:-3}
volumes:
- type: bind
source: /DATA/AppData/$AppID/data
target: /data
- type: bind
source: /var/run/docker.sock
target: /var/run/docker.sock
read_only: true
x-casaos:
envs:
- container: APP_PORT
description:
en_us: HTTP port for the management UI
- container: DOCKER_API_URL
description:
en_us: Docker endpoint (unix socket default, optional tcp endpoint)
- container: DOCKER_TIMEOUT_SECONDS
description:
en_us: Timeout in seconds for Docker API requests
volumes:
- container: /data
description:
en_us: Persistent IP entry state
socket-proxy:
image: lscr.io/linuxserver/socket-proxy:version-24.02.26
container_name: docker-ip-addr-manager-socket-proxy
restart: unless-stopped
environment:
TZ: ${TZ}
CONTAINERS: 1
INFO: 1
PING: 1
POST: 0
VERSION: 1
read_only: true
tmpfs:
- /run
ports:
- target: 2375
published: "2375"
host_ip: 127.0.0.1
protocol: tcp
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
x-casaos:
architectures:
- amd64
- arm64
- arm
main: app
category: Network
author: Zima Apps Team
developer: Zima Apps Team
icon: https://www.svgrepo.com/show/49710/network.svg
tagline:
en_us: Manage host LAN IP aliases for container port bindings
description:
en_us: >-
Adds/removes host interface IP aliases and shows whether an IP is used by Docker
container port bindings. Includes fail-closed disable/delete checks when usage cannot
be validated.
title:
en_us: Docker IP Addr Manager
index: /
port_map: ${APP_PORT:-31810}
scheme: http
@@ -0,0 +1,180 @@
#!/usr/bin/env python3
from __future__ import annotations
from pathlib import Path
import sys
import tempfile
ROOT_DIR = Path(__file__).resolve().parents[1]
BACKEND_DIR = ROOT_DIR / "backend"
sys.path.insert(0, str(BACKEND_DIR))
from app.docker_api import DockerApiError, DockerUsageResolver
from app.models import IpEntry
from app.service import ConflictError, DependencyError, EntryService
from app.storage import EntryStorage
class FakeDockerClient:
def list_containers(self, include_stopped: bool = True):
assert include_stopped is True
return [
{"Id": "a1", "Names": ["/alpha"]},
{"Id": "b2", "Names": ["/beta"]},
]
def inspect_container(self, container_id: str):
if container_id == "a1":
return {
"Name": "/alpha",
"NetworkSettings": {
"Ports": {
"8080/tcp": [{"HostIp": "10.0.4.2", "HostPort": "8080"}],
"8443/tcp": [{"HostIp": "0.0.0.0", "HostPort": "8443"}],
}
},
}
if container_id == "b2":
return {
"Name": "/beta",
"NetworkSettings": {
"Ports": {
"9000/tcp": [{"HostIp": "10.0.4.3", "HostPort": "9000"}],
}
},
}
raise AssertionError(f"unexpected container_id: {container_id}")
class FakeUsageResolver:
def __init__(self, mapping=None, should_fail=False):
self._mapping = mapping or {}
self._should_fail = should_fail
def resolve_ip_usage(self, ips: set[str]):
if self._should_fail:
raise DockerApiError("docker unreachable")
response = {ip: set() for ip in ips}
for ip in ips:
response[ip] = set(self._mapping.get(ip, set()))
return response
class FakeIpManager:
def __init__(self):
self.present = set()
def is_present(self, ip: str, cidr: int, device: str):
return (ip, cidr, device) in self.present
def ensure_present(self, ip: str, cidr: int, device: str):
self.present.add((ip, cidr, device))
def ensure_absent(self, ip: str, cidr: int, device: str):
self.present.discard((ip, cidr, device))
def assert_true(condition, message):
if not condition:
raise AssertionError(message)
def build_service(tmp_path: Path, entries=None, usage_resolver=None, ip_manager=None):
storage = EntryStorage(str(tmp_path / "entries.json"))
if entries:
storage.save_entries(entries)
resolver = usage_resolver or FakeUsageResolver()
ipm = ip_manager or FakeIpManager()
return EntryService(
storage=storage,
usage_resolver=resolver,
ip_manager=ipm,
interface_provider=lambda: ["eth0", "eth1"],
)
def test_exact_hostip_match_only():
resolver = DockerUsageResolver(FakeDockerClient())
usage = resolver.resolve_ip_usage({"10.0.4.2", "10.0.4.3"})
assert_true(usage["10.0.4.2"] == {"alpha"}, "10.0.4.2 should match alpha")
assert_true(usage["10.0.4.3"] == {"beta"}, "10.0.4.3 should match beta")
def test_disable_blocked_when_used(tmp_path: Path):
entry = IpEntry(id="one", name="lan-a", ip="10.0.4.2", cidr=16, device="eth0", enabled=True)
resolver = FakeUsageResolver(mapping={"10.0.4.2": {"alpha"}})
ip_manager = FakeIpManager()
ip_manager.present.add(("10.0.4.2", 16, "eth0"))
service = build_service(tmp_path, entries=[entry], usage_resolver=resolver, ip_manager=ip_manager)
blocked = False
try:
service.set_enabled("one", enabled=False)
except ConflictError:
blocked = True
assert_true(blocked, "disable must be blocked when entry is used")
assert_true(("10.0.4.2", 16, "eth0") in ip_manager.present, "IP must stay present when disable is blocked")
def test_disable_blocked_when_docker_check_fails(tmp_path: Path):
entry = IpEntry(id="two", name="lan-b", ip="10.0.4.8", cidr=16, device="eth0", enabled=True)
resolver = FakeUsageResolver(should_fail=True)
ip_manager = FakeIpManager()
ip_manager.present.add(("10.0.4.8", 16, "eth0"))
service = build_service(tmp_path, entries=[entry], usage_resolver=resolver, ip_manager=ip_manager)
blocked = False
try:
service.set_enabled("two", enabled=False)
except DependencyError:
blocked = True
assert_true(blocked, "disable must fail-closed when Docker usage check fails")
def test_delete_blocked_when_enabled(tmp_path: Path):
entry = IpEntry(id="three", name="lan-c", ip="10.0.4.9", cidr=16, device="eth0", enabled=True)
service = build_service(tmp_path, entries=[entry])
blocked = False
try:
service.delete_entry("three")
except ConflictError:
blocked = True
assert_true(blocked, "delete must be blocked while entry is enabled")
def test_reconcile_reapplies_enabled(tmp_path: Path):
entry = IpEntry(id="four", name="lan-d", ip="10.0.4.10", cidr=16, device="eth0", enabled=True)
ip_manager = FakeIpManager()
service = build_service(tmp_path, entries=[entry], ip_manager=ip_manager)
errors = service.reconcile_enabled_entries()
assert_true(errors == [], "reconcile should succeed without errors")
assert_true(("10.0.4.10", 16, "eth0") in ip_manager.present, "enabled IP must be re-applied on startup reconcile")
def main():
test_exact_hostip_match_only()
with tempfile.TemporaryDirectory() as tmp:
tmp_path = Path(tmp)
test_disable_blocked_when_used(tmp_path)
test_disable_blocked_when_docker_check_fails(tmp_path)
test_delete_blocked_when_enabled(tmp_path)
test_reconcile_reapplies_enabled(tmp_path)
print("Integration tests passed")
if __name__ == "__main__":
main()
@@ -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"
+78
View File
@@ -0,0 +1,78 @@
# Steam Headless
Steam Headless kör en webbaserad Linux-desktop med Steam i container, baserat på LinuxServer image `lscr.io/linuxserver/steam`.
Imagepinning i denna app:
- Compose använder immutable referens: `lscr.io/linuxserver/steam:version-f4f48542@sha256:d7b9fbf302e05ae79248d1171fe9751b354f8397eafa1e13a3df0aa6a75de0b4`
- `latest` finns i registryt men används inte i repo enligt policy.
- Vid verifieringstillfället pekade `latest` på samma release (`version-f4f48542`).
## 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-f4f48542@sha256:d7b9fbf302e05ae79248d1171fe9751b354f8397eafa1e13a3df0aa6a75de0b4
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,51 @@
# Moonlight Runtime Checklist
Strikt checklista för att aktivera `steam-moonlight`-profilen säkert.
## Preflight (måste vara grönt)
1. Bekräfta att du medvetet accepterar högriskprofil (`network_mode: host`, extra capabilities, device passthrough).
2. Sätt starkt lösenord i `SUNSHINE_PASS` (inte `change-me`, inte återanvänt).
3. Verifiera GPU-device mapping:
- `GPU_CARD_DEVICE` pekar på korrekt `/dev/dri/card*`
- `GPU_RENDER_DEVICE` pekar på korrekt `/dev/dri/renderD*`
4. Verifiera att hosten har:
- `/dev/fuse`
- `/dev/uinput`
- korrekt `/dev/dri/*`
5. Bekräfta att Sunshine-portar inte exponeras publikt mot internet.
6. Verifiera att volymerna är under `/DATA/AppData/$AppID/...`.
7. Verifiera compose-rendering:
- `docker compose -f Apps/steam-moonlight/docker-compose.yaml --profile moonlight config`
## Startsekvens
1. Starta endast Moonlight-profilen:
- `docker compose -f Apps/steam-moonlight/docker-compose.yaml --profile moonlight up -d steam-moonlight`
2. Kontrollera containerstatus:
- `docker compose -f Apps/steam-moonlight/docker-compose.yaml ps`
3. Kontrollera initiala logs:
- `docker logs --tail=200 steam-moonlight-profile`
## Post-start verifiering
1. Verifiera att Sunshine kräver autentisering.
2. Verifiera att streaming fungerar från avsedd klient (LAN/VPN).
3. Verifiera controller-input utan att aktivera fler capabilities än definierat.
4. Verifiera att inga oväntade portar exponeras.
5. Verifiera att defaultprofil fortfarande kan köras separat vid rollback.
## Driftregler
1. Kör inte Moonlight-profilen permanent om den inte används.
2. Roterar `SUNSHINE_PASS` regelbundet och alltid efter incident.
3. Uppdatera image-pin via digest i kontrollerade change windows.
4. Undvik ad-hoc ändringar av capabilities eller device mounts.
## Snabb rollback
1. Stoppa Moonlight-profil:
- `docker compose -f Apps/steam-moonlight/docker-compose.yaml --profile moonlight stop steam-moonlight`
2. Kör defaultprofil:
- `docker compose -f Apps/steam-moonlight/docker-compose.yaml up -d steam`
3. Bekräfta återställd låg-risk drift via logs och funktionstest.
+102
View File
@@ -0,0 +1,102 @@
# Steam Moonlight (Scaffold)
Detta är en scaffold för hybridspåret (browser + Moonlight) baserat på `josh5/steam-headless`.
Kompletterande säkerhetsdokument:
- `SECURITY.md`
- `MOONLIGHT-RUNTIME-CHECKLIST.md`
## Syfte
- Ge en låg-risk default för browserbaserad Steam-desktop.
- Ge en separat opt-in-profil för Moonlight/Sunshine.
- Isolera högriskinställningar till en explicit profil (`moonlight`).
## Imagepinning
- Compose använder immutable referens:
- `josh5/steam-headless:debian-0.2.0@sha256:540366bee31297c5679a5006a84dbca039ca62aaab695852b51b5f62dffd2c14`
- Repon kräver att `latest` inte används i compose.
## Profiler
- `steam` (default): browser-first, lägre risk, ingen host network.
- `steam-moonlight` (`profiles: ["moonlight"]`): aktiverar Sunshine + controller/GPU passthrough med högre privilegier.
## Körning
- Default (rekommenderad start):
- `docker compose -f Apps/steam-moonlight/docker-compose.yaml up -d steam`
- Moonlight (opt-in):
- `docker compose -f Apps/steam-moonlight/docker-compose.yaml --profile moonlight up -d steam-moonlight`
- Innan Moonlight aktiveras:
- byt `SUNSHINE_PASS` till ett starkt lösenord,
- verifiera `GPU_CARD_DEVICE` och `GPU_RENDER_DEVICE` för rätt GPU.
## Portar
- Defaultprofil (`steam`):
- `${STEAM_WEB_PORT:-8083}/tcp` för webdesktop.
Moonlightprofilen använder `network_mode: host` och tar därför nätverk direkt från host.
## Volymer
- Defaultprofil:
- `/DATA/AppData/$AppID/home -> /home/default`
- `/DATA/AppData/$AppID/games -> /mnt/games`
- Moonlightprofil:
- `/DATA/AppData/$AppID/moonlight-home -> /home/default`
- `/DATA/AppData/$AppID/moonlight-games -> /mnt/games`
## Privilegier och säkerhet
Gemensamt:
- `no-new-privileges:true`
Defaultprofil (`steam`, lägre risk):
- `cap_drop: ["ALL"]`
- Ingen `network_mode: host`
- Inga device mounts
- Ingen Sunshine-aktivering som default
Moonlightprofil (högrisk):
- `ipc: host`
- `security_opt: ["seccomp:unconfined", "apparmor:unconfined", "no-new-privileges:true"]`
- `cap_drop: ["ALL"]` + `cap_add: [NET_ADMIN, SYS_ADMIN, SYS_NICE]`
- `network_mode: host`
- Device mounts: `/dev/fuse`, `/dev/uinput`, `/dev/dri/*`
- `device_cgroup_rules: ['c 13:* rmw']`
## Säkerhetsavvikelser
Denna app innehåller avsiktliga avvikelser, primärt i `moonlight`-profilen.
Varför det behövs:
- Moonlight/Sunshine och fysisk controller-input kräver i praktiken host-nära åtkomst i denna containerfamilj.
Alternativ som utvärderats:
- Browser-only (`steam` default): lägre risk men sämre latens/gamepad.
- Full `privileged: true`: avvisat i scaffolden för att begränsa attackytan.
Risker:
- Host network minskar nätverksisolering.
- Extra capabilities och device-passthrough ökar konsekvensen vid containerkompromettering.
Riskreducering:
- Högrisk är opt-in via profil, inte default.
- Ingen docker socket-mount används.
- Persistent data är begränsad till `/DATA/AppData/$AppID/...`.
- Defaultprofilen droppar alla Linux capabilities.
## Status
Scaffolden är avsedd för kontrollerad vidareutveckling och verifiering, inte som slutlig hardened release.
+50
View File
@@ -0,0 +1,50 @@
# Roadmap
## Current State (2026-03-18)
- Browser-first service (`steam`) är testbar med låg-risk baseline.
- Moonlight service (`steam-moonlight`) finns som opt-in scaffold med dokumenterade risker.
- Operativa säkerhetskrav finns i `SECURITY.md` och `MOONLIGHT-RUNTIME-CHECKLIST.md`.
## Next Milestone: Moonlight Test-Ready (Fail-Closed)
Mål: Moonlight-profilen ska vägra starta vid osäker konfiguration.
1. Enforce credentials
- Blockera start om `SUNSHINE_PASS` är tomt eller default (`change-me`).
- Blockera start om `SUNSHINE_USER` är tomt.
2. Enforce GPU/device prerequisites
- Blockera start om `GPU_CARD_DEVICE` eller `GPU_RENDER_DEVICE` saknas på host.
- Blockera start om `/dev/fuse` eller `/dev/uinput` saknas.
3. Enforce network safety baseline
- Tydlig varning + explicit opt-in env för internet-exponering.
- Defaultantagande: LAN/VPN-only drift.
4. Preflight command and runbook
- Lägg till en verifierbar preflight-sekvens som måste passera före `up`.
- Uppdatera checklistan med pass/fail-kriterier.
## Hardening Milestone
1. Secrets hygiene
- Dokumentera rotationsintervall för Sunshine-credentials.
- Säkerställ att exempel aldrig innehåller riktiga credentials.
2. Capability minimization
- Verifiera om någon `cap_add` kan tas bort utan funktionsförlust.
- Dokumentera minsta uppsättning capabilities per use-case.
3. Upgrade discipline
- Definiera rutin för image-pin uppdatering (`tag + digest`) och rollback.
## Release Criteria (Moonlight)
För att kalla Moonlight-profilen releaseklar ska följande vara uppfyllt:
1. Fail-closed enforcement implementerat och verifierat.
2. Positiv test av Moonlight streaming med korrekt auth.
3. Negativ test: start blockeras vid osäkra defaults/missing devices.
4. `./scripts/validate-appstore.sh --enforce-risk-docs` passerar.
5. README + security docs uppdaterade med slutlig driftmodell.
+70
View File
@@ -0,0 +1,70 @@
# Security Model
Detta dokument beskriver säkerhetsmodellen för `steam-moonlight` och hur risker begränsas mellan default- och Moonlight-profil.
## Mål
- Hålla default-profilen (`steam`) så nära least-privilege som möjligt.
- Göra Moonlight-profil (`steam-moonlight`) explicit opt-in med tydlig riskaccept.
- Hålla state/data begränsad till `/DATA/AppData/$AppID/...`.
## Tillitsgränser
- Host OS och Docker daemon är högsta trust-zon.
- Containern är lägre trust-zon.
- Moonlight-klienter och LAN-trafik är extern yta.
## Threat Model
Primära hot:
- Kontoövertagande via svagt `SUNSHINE_PASS`.
- Lateral movement via `network_mode: host` (Moonlight-profil).
- Ökad impact vid containerkompromiss p.g.a. extra capabilities och device passthrough.
- Dataförlust vid felaktig mount-path.
## Säkerhetskontroller
Gemensamt:
- Immutable image pin (`tag + digest`).
- Ingen docker socket mount.
- Ingen `privileged: true`.
- Data paths begränsade till `/DATA/AppData/$AppID/...`.
Defaultprofil (`steam`):
- `cap_drop: ["ALL"]`
- `no-new-privileges:true`
- Ingen `host` network
- Inga device mounts
- Sunshine avstängd som default (`ENABLE_SUNSHINE=false`)
Moonlightprofil (`steam-moonlight`):
- Högriskkontroller isolerade till `profiles: ["moonlight"]`
- `cap_drop: ["ALL"]` + minsta kända `cap_add`
- Explicit device lista (`/dev/fuse`, `/dev/uinput`, `/dev/dri/*`)
- `seccomp:unconfined` och `apparmor:unconfined` endast i Moonlight-profilen
## Fail-Closed Regler
- Moonlight ska inte startas om `SUNSHINE_PASS` är default eller tomt.
- Moonlight ska inte startas om GPU-device mapping saknas eller är fel.
- Vid osäkerhet, kör endast defaultprofilen (`steam`).
## Operativa krav
- Exponera inte Sunshine admin/UI mot internet.
- Begränsa åtkomst till LAN/VPN och pålitliga klienter.
- Rota image-pins kontrollerat och verifiera digest före uppdatering.
- Följ checklistan i `MOONLIGHT-RUNTIME-CHECKLIST.md` före varje aktivering.
## Incidentrespons (minimum)
Vid misstänkt kompromiss:
1. Stoppa Moonlight-profilen direkt.
2. Roterar `SUNSHINE_PASS` och övriga credentials.
3. Granska hostens nätverksexponering och Docker logs.
4. Byt image till känd god pin och starta om endast defaultprofil.
+131
View File
@@ -0,0 +1,131 @@
name: steam-moonlight
x-steam-common: &steam-common
image: josh5/steam-headless:debian-0.2.0@sha256:540366bee31297c5679a5006a84dbca039ca62aaab695852b51b5f62dffd2c14
restart: unless-stopped
shm_size: ${SHM_SIZE:-2G}
environment:
TZ: ${TZ}
PUID: ${PUID}
PGID: ${PGID}
UMASK: ${UMASK:-000}
USER_PASSWORD: ${USER_PASSWORD:-change-me}
MODE: ${MODE:-primary}
WEB_UI_MODE: ${WEB_UI_MODE:-vnc}
PORT_NOVNC_WEB: ${STEAM_WEB_PORT:-8083}
ENABLE_STEAM: ${ENABLE_STEAM:-true}
STEAM_ARGS: ${STEAM_ARGS:--silent}
ENABLE_SUNSHINE: ${ENABLE_SUNSHINE:-false}
SUNSHINE_USER: ${SUNSHINE_USER:-admin}
SUNSHINE_PASS: ${SUNSHINE_PASS:-change-me}
services:
steam:
<<: *steam-common
container_name: steam-moonlight
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
ports:
- target: 8083
published: ${STEAM_WEB_PORT:-8083}
protocol: tcp
volumes:
- type: bind
source: /DATA/AppData/$AppID/home
target: /home/default
- type: bind
source: /DATA/AppData/$AppID/games
target: /mnt/games
x-casaos:
envs:
- container: TZ
description:
en_us: Timezone, for example Europe/Stockholm
- container: PUID
description:
en_us: User ID for filesystem permissions
- container: PGID
description:
en_us: Group ID for filesystem permissions
- container: STEAM_WEB_PORT
description:
en_us: Browser desktop port
ports:
- container: "8083"
description:
en_us: Steam desktop over web browser
volumes:
- container: /home/default
description:
en_us: Persistent user home and runtime state
- container: /mnt/games
description:
en_us: Persistent Steam game library
steam-moonlight:
<<: *steam-common
container_name: steam-moonlight-profile
profiles: ["moonlight"]
network_mode: host
ipc: host
security_opt:
- seccomp:unconfined
- apparmor:unconfined
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
- NET_ADMIN
- SYS_ADMIN
- SYS_NICE
devices:
- /dev/fuse
- /dev/uinput
- ${GPU_CARD_DEVICE:-/dev/dri/card0}
- ${GPU_RENDER_DEVICE:-/dev/dri/renderD128}
device_cgroup_rules:
- 'c 13:* rmw'
environment:
TZ: ${TZ}
PUID: ${PUID}
PGID: ${PGID}
UMASK: ${UMASK:-000}
USER_PASSWORD: ${USER_PASSWORD:-change-me}
MODE: ${MODE:-primary}
WEB_UI_MODE: ${WEB_UI_MODE:-vnc}
PORT_NOVNC_WEB: ${STEAM_WEB_PORT:-8083}
ENABLE_STEAM: ${ENABLE_STEAM:-true}
STEAM_ARGS: ${STEAM_ARGS:--silent}
ENABLE_SUNSHINE: "true"
SUNSHINE_USER: ${SUNSHINE_USER:-admin}
SUNSHINE_PASS: ${SUNSHINE_PASS:-change-me}
volumes:
- type: bind
source: /DATA/AppData/$AppID/moonlight-home
target: /home/default
- type: bind
source: /DATA/AppData/$AppID/moonlight-games
target: /mnt/games
x-casaos:
architectures:
- amd64
main: steam
category: Games
author: Zima Apps Team
developer: Steam-Headless community
icon: https://cdn.simpleicons.org/steam
tagline:
en_us: Steam web desktop with optional Moonlight profile
description:
en_us: >-
Browser-first Steam container with an explicit moonlight profile for higher
compatibility and controller support. The moonlight profile is opt-in and
carries additional security risk.
title:
en_us: Steam Moonlight (Scaffold)
index: /
port_map: ${STEAM_WEB_PORT:-8083}
scheme: http
+107
View File
@@ -0,0 +1,107 @@
# How To Verify
Detta dokument verifierar båda Steam-apparna i repo:
- `Apps/steam-headless`
- `Apps/steam-moonlight`
## 1) Repo-validering
Kör från repo-roten:
```bash
./scripts/validate-appstore.sh --enforce-risk-docs
```
Förväntat: `Validation OK` eller `Validation OK with ... warning(s)`.
## 2) Verifiera steam-headless (browser-first)
Rendera compose:
```bash
docker compose -f Apps/steam-headless/docker-compose.yaml config
```
Starta:
```bash
docker compose -f Apps/steam-headless/docker-compose.yaml up -d steam
```
Kontroller:
1. `docker compose -f Apps/steam-headless/docker-compose.yaml ps` visar `steam` som running.
2. Web UI nås på `${STEAM_HTTP_PORT:-3000}` eller `${STEAM_HTTPS_PORT:-3001}`.
3. Inga extra högriskflaggor används (`privileged`, `host network`, `docker.sock`).
Stoppa:
```bash
docker compose -f Apps/steam-headless/docker-compose.yaml down
```
## 3) Verifiera steam-moonlight defaultprofil
Rendera compose (default):
```bash
docker compose -f Apps/steam-moonlight/docker-compose.yaml config
```
Starta default service:
```bash
docker compose -f Apps/steam-moonlight/docker-compose.yaml up -d steam
```
Kontroller:
1. `steam` är running.
2. Webdesktop nås på `${STEAM_WEB_PORT:-8083}`.
3. Defaultprofilen kör med låg-risk baseline (`cap_drop: ALL`, ingen `host network`).
Stoppa:
```bash
docker compose -f Apps/steam-moonlight/docker-compose.yaml down
```
## 4) Verifiera steam-moonlight moonlight-profil (opt-in)
Preflight:
1. Sätt starkt `SUNSHINE_PASS`.
2. Verifiera GPU devices (`GPU_CARD_DEVICE`, `GPU_RENDER_DEVICE`).
3. Verifiera `/dev/fuse` och `/dev/uinput` på host.
Rendera moonlight-profil:
```bash
docker compose -f Apps/steam-moonlight/docker-compose.yaml --profile moonlight config
```
Starta:
```bash
docker compose -f Apps/steam-moonlight/docker-compose.yaml --profile moonlight up -d steam-moonlight
```
Kontroller:
1. `steam-moonlight` är running.
2. Sunshine kräver autentisering.
3. Moonlight-klient kan ansluta från LAN/VPN.
4. Ingen oavsiktlig internetexponering av Sunshine-portar.
Stoppa/rollback:
```bash
docker compose -f Apps/steam-moonlight/docker-compose.yaml --profile moonlight down
```
Vid problem, återgå till defaultprofil:
```bash
docker compose -f Apps/steam-moonlight/docker-compose.yaml up -d steam
```