Merge pull request 'Add docker-ip-addr-manager initial app' (#1) from docker-ip-addr-manager/initial/fastapi-vue-ip-manager into main
Reviewed-on: phirna/zima-apps#1
This commit is contained in:
@@ -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"
|
||||||
Reference in New Issue
Block a user