Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a6d429255c | |||
| c1e347e5e0 | |||
| 57545e661c | |||
| 69011271fc |
@@ -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.
|
||||
@@ -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"
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
```
|
||||
Reference in New Issue
Block a user