Add docker-ip-addr-manager initial app

This commit is contained in:
Joachim Friberg
2026-03-18 21:43:59 +01:00
parent 2fddde0129
commit 69011271fc
19 changed files with 1920 additions and 0 deletions
@@ -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