diff --git a/Apps/docker-ip-addr-manager/HOW_TO_VERIFY.md b/Apps/docker-ip-addr-manager/HOW_TO_VERIFY.md new file mode 100644 index 0000000..b5fefe0 --- /dev/null +++ b/Apps/docker-ip-addr-manager/HOW_TO_VERIFY.md @@ -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="" +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="" +HOSTNAME_TO_TEST="" +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. diff --git a/Apps/docker-ip-addr-manager/README.md b/Apps/docker-ip-addr-manager/README.md new file mode 100644 index 0000000..61e8ff1 --- /dev/null +++ b/Apps/docker-ip-addr-manager/README.md @@ -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 / dev ` + - disable => `ip addr del / dev ` +- 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. diff --git a/Apps/docker-ip-addr-manager/backend/Dockerfile b/Apps/docker-ip-addr-manager/backend/Dockerfile new file mode 100644 index 0000000..d8cb092 --- /dev/null +++ b/Apps/docker-ip-addr-manager/backend/Dockerfile @@ -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}"] diff --git a/Apps/docker-ip-addr-manager/backend/app/__init__.py b/Apps/docker-ip-addr-manager/backend/app/__init__.py new file mode 100644 index 0000000..877664f --- /dev/null +++ b/Apps/docker-ip-addr-manager/backend/app/__init__.py @@ -0,0 +1 @@ +"""docker-ip-addr-manager backend package.""" diff --git a/Apps/docker-ip-addr-manager/backend/app/config.py b/Apps/docker-ip-addr-manager/backend/app/config.py new file mode 100644 index 0000000..5e668f4 --- /dev/null +++ b/Apps/docker-ip-addr-manager/backend/app/config.py @@ -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")), + ) diff --git a/Apps/docker-ip-addr-manager/backend/app/docker_api.py b/Apps/docker-ip-addr-manager/backend/app/docker_api.py new file mode 100644 index 0000000..914aed2 --- /dev/null +++ b/Apps/docker-ip-addr-manager/backend/app/docker_api.py @@ -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" diff --git a/Apps/docker-ip-addr-manager/backend/app/interfaces.py b/Apps/docker-ip-addr-manager/backend/app/interfaces.py new file mode 100644 index 0000000..61b553b --- /dev/null +++ b/Apps/docker-ip-addr-manager/backend/app/interfaces.py @@ -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 diff --git a/Apps/docker-ip-addr-manager/backend/app/ip_commands.py b/Apps/docker-ip-addr-manager/backend/app/ip_commands.py new file mode 100644 index 0000000..708a5ce --- /dev/null +++ b/Apps/docker-ip-addr-manager/backend/app/ip_commands.py @@ -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()}") diff --git a/Apps/docker-ip-addr-manager/backend/app/main.py b/Apps/docker-ip-addr-manager/backend/app/main.py new file mode 100644 index 0000000..aff4693 --- /dev/null +++ b/Apps/docker-ip-addr-manager/backend/app/main.py @@ -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)}) diff --git a/Apps/docker-ip-addr-manager/backend/app/models.py b/Apps/docker-ip-addr-manager/backend/app/models.py new file mode 100644 index 0000000..018c135 --- /dev/null +++ b/Apps/docker-ip-addr-manager/backend/app/models.py @@ -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, + } diff --git a/Apps/docker-ip-addr-manager/backend/app/service.py b/Apps/docker-ip-addr-manager/backend/app/service.py new file mode 100644 index 0000000..75a3aac --- /dev/null +++ b/Apps/docker-ip-addr-manager/backend/app/service.py @@ -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, + } diff --git a/Apps/docker-ip-addr-manager/backend/app/static/app.js b/Apps/docker-ip-addr-manager/backend/app/static/app.js new file mode 100644 index 0000000..5e3c14d --- /dev/null +++ b/Apps/docker-ip-addr-manager/backend/app/static/app.js @@ -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"); diff --git a/Apps/docker-ip-addr-manager/backend/app/static/index.html b/Apps/docker-ip-addr-manager/backend/app/static/index.html new file mode 100644 index 0000000..01cbcf8 --- /dev/null +++ b/Apps/docker-ip-addr-manager/backend/app/static/index.html @@ -0,0 +1,126 @@ + + + + + + Docker IP Addr Manager + + + +
+
+
+

Docker IP Addr Manager

+

Manage host IP addresses used for ZimaOS port bindings.

+
+ +
+ +
+

Add IP Entry

+
+ + + + + + + + + +
+
+ +
+
{{ errorMessage }}
+
+ Docker usage status is unknown. Disable/Delete operations are fail-closed until refresh succeeds. + Error: {{ usageError }} +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Name {{ sortIndicator('name') }}IP Address {{ sortIndicator('ip') }}CIDR {{ sortIndicator('cidr') }}Used {{ sortIndicator('used') }}Container(s) {{ sortIndicator('containers') }}Device {{ sortIndicator('device') }}Enabled {{ sortIndicator('enabled') }}Actions
+ Unknown + Yes + No + {{ containerLabel(entry) }} + + + + + + +
No entries configured.
+
+
+
+ + + + + diff --git a/Apps/docker-ip-addr-manager/backend/app/static/styles.css b/Apps/docker-ip-addr-manager/backend/app/static/styles.css new file mode 100644 index 0000000..0e3e2a2 --- /dev/null +++ b/Apps/docker-ip-addr-manager/backend/app/static/styles.css @@ -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; + } +} diff --git a/Apps/docker-ip-addr-manager/backend/app/storage.py b/Apps/docker-ip-addr-manager/backend/app/storage.py new file mode 100644 index 0000000..647f608 --- /dev/null +++ b/Apps/docker-ip-addr-manager/backend/app/storage.py @@ -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] diff --git a/Apps/docker-ip-addr-manager/backend/requirements.txt b/Apps/docker-ip-addr-manager/backend/requirements.txt new file mode 100644 index 0000000..135a119 --- /dev/null +++ b/Apps/docker-ip-addr-manager/backend/requirements.txt @@ -0,0 +1,2 @@ +fastapi==0.116.1 +uvicorn==0.35.0 diff --git a/Apps/docker-ip-addr-manager/docker-compose.yaml b/Apps/docker-ip-addr-manager/docker-compose.yaml new file mode 100644 index 0000000..885e5c8 --- /dev/null +++ b/Apps/docker-ip-addr-manager/docker-compose.yaml @@ -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 diff --git a/Apps/docker-ip-addr-manager/tests/integration_tests.py b/Apps/docker-ip-addr-manager/tests/integration_tests.py new file mode 100644 index 0000000..3ce12bb --- /dev/null +++ b/Apps/docker-ip-addr-manager/tests/integration_tests.py @@ -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() diff --git a/Apps/docker-ip-addr-manager/tests/run_integration_tests.sh b/Apps/docker-ip-addr-manager/tests/run_integration_tests.sh new file mode 100755 index 0000000..ffeb51f --- /dev/null +++ b/Apps/docker-ip-addr-manager/tests/run_integration_tests.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +PYTHONDONTWRITEBYTECODE=1 python3 "$ROOT_DIR/tests/integration_tests.py"