Release / verify (push) Canceled after 0s
CI / checks (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 0s
Release / test (push) Canceled after 0s
Release / publish (backend) (push) Canceled after 0s
Release / publish (frontend) (push) Canceled after 0s
Release / publish (proxy) (push) Canceled after 0s
Release / publish (updater) (push) Canceled after 0s
- **Management-Tunnel** (System › Management-Tunnel): Firewalls hinter NAT/CGNAT verwalten. Ein optionaler WireGuard-Hub auf dem OPNexus-Server (Compose-Profil `overlay`, Container `wg-hub` aus dem Backend-Image mit `NET_ADMIN`) nimmt die Firewalls entgegen, die sich nach außen einwählen; OPNexus erreicht deren API über einen kleinen CONNECT-Proxy des Hubs, der nur ins Tunnel-Netz verbindet. Je Firewall eine Tunnel-Adresse und ein eigener Pre-Shared Key, Anleitung mit kopierbaren Werten, Live-Status (Handshake, Datenmenge). Der Hub nimmt keine neuen Verbindungen aus dem Tunnel an. Ende-zu-Ende gegen eine echte OPNsense 26.7 geprüft (Handshake, API, Zertifikat festsetzen). Neue Tabellen `overlay_settings`, `overlay_peers` (Migration `0015`); das Backend-Image enthält jetzt `wireguard-tools`, `iproute2`, `iptables`.
143 lines
6.0 KiB
Python
143 lines
6.0 KiB
Python
"""Management overlay API (see services/overlay_service.py). Writes and the pre-shared key are admin-only."""
|
|
import uuid
|
|
|
|
from fastapi import APIRouter, Depends
|
|
from sqlalchemy import select
|
|
from sqlalchemy.exc import IntegrityError
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.database import get_db
|
|
from app.errors import ErrorCode, api_error
|
|
from app.models.host import OpnsenseHost
|
|
from app.models.overlay import OverlayPeer
|
|
from app.models.user import AdminUser
|
|
from app.routers.auth import current_user
|
|
from app.schemas.overlay import OverlaySettingsRead, OverlaySettingsUpdate, PeerCreate, PeerRead, PeerUpdate
|
|
from app.services import overlay_service as svc
|
|
|
|
router = APIRouter(prefix="/overlay", tags=["overlay"])
|
|
|
|
|
|
def _require_admin(user: AdminUser) -> None:
|
|
if user.role != "admin":
|
|
raise api_error(403, ErrorCode.ADMIN_REQUIRED)
|
|
|
|
|
|
async def _settings_read(db: AsyncSession) -> dict:
|
|
cfg = await svc.get_settings(db)
|
|
net = svc.parse_subnet(cfg.subnet)
|
|
return {
|
|
"enabled": cfg.enabled, "endpoint": cfg.endpoint, "subnet": cfg.subnet, "listen_port": cfg.listen_port,
|
|
"hub_public_key": svc.hub_public_key() if cfg.enabled else None, "hub_address": str(svc.hub_address(net)),
|
|
"hub_state": svc.hub_state(svc.read_status()),
|
|
}
|
|
|
|
|
|
async def _peers(db: AsyncSession) -> list[dict]:
|
|
status = svc.read_status()
|
|
rows = (await db.execute(select(OverlayPeer, OpnsenseHost.name).outerjoin(OpnsenseHost, OverlayPeer.host_id == OpnsenseHost.id).order_by(OverlayPeer.ip))).all()
|
|
return [
|
|
{"id": p.id, "name": p.name, "ip": p.ip, "public_key": p.public_key, "host_id": p.host_id, "host_name": host_name,
|
|
**svc.peer_status(status, p.public_key)}
|
|
for p, host_name in rows
|
|
]
|
|
|
|
|
|
@router.get("")
|
|
async def overview(db: AsyncSession = Depends(get_db)):
|
|
return {"settings": await _settings_read(db), "peers": await _peers(db)}
|
|
|
|
|
|
@router.put("/settings", response_model=OverlaySettingsRead)
|
|
async def update_settings(payload: OverlaySettingsUpdate, db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
|
|
_require_admin(user)
|
|
cfg = await svc.get_settings(db)
|
|
if payload.subnet is not None and payload.subnet.strip() != cfg.subnet:
|
|
try:
|
|
svc.parse_subnet(payload.subnet)
|
|
except svc.OverlayError as exc:
|
|
raise api_error(422, ErrorCode.OVERLAY_INVALID, reason=str(exc)) from exc
|
|
if (await db.scalar(select(OverlayPeer.id).limit(1))) is not None:
|
|
raise api_error(409, ErrorCode.OVERLAY_SUBNET_LOCKED) # peers already hold addresses in the old network
|
|
cfg.subnet = payload.subnet.strip()
|
|
for field in ("enabled", "listen_port"):
|
|
value = getattr(payload, field)
|
|
if value is not None:
|
|
setattr(cfg, field, value)
|
|
if payload.endpoint is not None:
|
|
cfg.endpoint = payload.endpoint.strip() or None
|
|
if cfg.enabled and not cfg.endpoint:
|
|
raise api_error(422, ErrorCode.OVERLAY_INVALID, reason="Ohne Endpunkt wissen die Firewalls nicht, wohin sie sich verbinden sollen")
|
|
await db.commit()
|
|
await svc.sync(db)
|
|
return await _settings_read(db)
|
|
|
|
|
|
@router.post("/peers", status_code=201)
|
|
async def create_peer(payload: PeerCreate, db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
|
|
_require_admin(user)
|
|
cfg = await svc.get_settings(db)
|
|
taken = set((await db.scalars(select(OverlayPeer.ip))).all())
|
|
try:
|
|
ip = svc.next_free_ip(svc.parse_subnet(cfg.subnet), taken)
|
|
except svc.OverlayError as exc:
|
|
raise api_error(409, ErrorCode.OVERLAY_INVALID, reason=str(exc)) from exc
|
|
peer = OverlayPeer(name=payload.name.strip(), ip=ip)
|
|
db.add(peer)
|
|
try:
|
|
await db.commit()
|
|
except IntegrityError:
|
|
await db.rollback()
|
|
raise api_error(409, ErrorCode.OVERLAY_NAME_TAKEN) from None
|
|
await svc.sync(db)
|
|
return {"id": peer.id, "name": peer.name, "ip": peer.ip, "instructions": svc.instructions(cfg, peer)}
|
|
|
|
|
|
@router.get("/peers/{peer_id}/instructions")
|
|
async def peer_instructions(peer_id: uuid.UUID, db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
|
|
_require_admin(user)
|
|
peer = await db.get(OverlayPeer, peer_id)
|
|
if peer is None:
|
|
raise api_error(404, ErrorCode.OVERLAY_PEER_NOT_FOUND)
|
|
return svc.instructions(await svc.get_settings(db), peer)
|
|
|
|
|
|
@router.patch("/peers/{peer_id}")
|
|
async def update_peer(peer_id: uuid.UUID, payload: PeerUpdate, db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
|
|
_require_admin(user)
|
|
peer = await db.get(OverlayPeer, peer_id)
|
|
if peer is None:
|
|
raise api_error(404, ErrorCode.OVERLAY_PEER_NOT_FOUND)
|
|
if payload.public_key is not None:
|
|
key = payload.public_key.strip()
|
|
if key and not svc.valid_public_key(key):
|
|
raise api_error(422, ErrorCode.OVERLAY_INVALID, reason="Das ist kein WireGuard-Schlüssel (44 Zeichen, Base64)")
|
|
if key and key == svc.hub_public_key():
|
|
raise api_error(422, ErrorCode.OVERLAY_INVALID, reason="Das ist der Schlüssel des Hubs, nicht der der Firewall")
|
|
peer.public_key = key or None
|
|
if payload.clear_host:
|
|
peer.host_id = None
|
|
elif payload.host_id is not None:
|
|
if await db.get(OpnsenseHost, payload.host_id) is None:
|
|
raise api_error(404, ErrorCode.HOST_NICHT_GEFUNDEN)
|
|
peer.host_id = payload.host_id
|
|
try:
|
|
await db.commit()
|
|
except IntegrityError:
|
|
await db.rollback()
|
|
raise api_error(409, ErrorCode.OVERLAY_INVALID, reason="Dieser Schlüssel gehört schon zu einem anderen Eintrag") from None
|
|
await svc.sync(db)
|
|
return {"ok": True}
|
|
|
|
|
|
@router.delete("/peers/{peer_id}", status_code=204)
|
|
async def delete_peer(peer_id: uuid.UUID, db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user)):
|
|
_require_admin(user)
|
|
peer = await db.get(OverlayPeer, peer_id)
|
|
if peer is None:
|
|
raise api_error(404, ErrorCode.OVERLAY_PEER_NOT_FOUND)
|
|
await db.delete(peer)
|
|
await db.commit()
|
|
svc.delete_peer_secret(peer_id)
|
|
await svc.sync(db)
|