Files
OPNexus/backend/app/services/alias_deploy_service.py
T
Julian Boldt 1fc2d2c87c
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 v1.11.1
Fehler aus dem ersten Live-Test der bisher nur gemockten Funktionen auf drei
echten OPNsense-Firewalls (alle in den Tests mit Mocks unsichtbar geblieben).
Live bestätigt sind jetzt auch das Major-Upgrade über den Update Center
(25.7 → 26.1 → 26.7, je mit Backup und Abschluss-Bestätigung), WireGuard,
OpenVPN, IPsec, Zertifikate, ACME (Staging), hierarchische Policies,
Benachrichtigungen und der HA-Config-Sync:

- **Zertifikate (Trust-Store) und ACME ließen sich nicht pushen:** Die URLs
  endeten auf `…Action` (`/trust/cert/addAction`), OPNsense kennt aber nur
  `/trust/cert/add`, `set`, `del` bzw. `/acmeclient/…/add`, `update`, `del` (404).
  Die Mocks hatten denselben Tippfehler. Ein neuer Test prüft den Client-Quelltext
  gegen dieses Muster.
- **OpenVPN-Deploy scheiterte immer:** Die Firewall verlangt `vpnid` und vergibt
  es nicht selbst. OPNexus wählt jetzt die nächste freie. Außerdem erwarten
  `cert`/`ca` (OpenVPN) und `caref` (Zertifikat signieren) die **refid** der
  Trust-Store-Einträge, der Picker lieferte die Zeilen-UUID. Die Listen
  `/hosts/{id}/certificates` und `/cas` liefern jetzt `refid`; im
  Zertifikatsformular gibt es ein CA-Dropdown statt eines Freitextfelds.
- **WireGuard meldete „Erfolg“ ohne laufenden Tunnel:** Ist der globale Dienst
  (VPN › WireGuard › Allgemein) aus, entsteht trotz geschriebener Konfiguration
  kein Interface. Der Deploy schaltet den Dienst jetzt ein.
- **Dienststeuerung:** Das Backend prüfte weder, ob der Dienst existiert, noch ob
  er gesperrt ist (pf, webgui, routing …). Beides war nur in der UI gesperrt;
  unbekannte Namen meldete die Firewall trotzdem als „ok“. Jetzt 404
  `SERVICE_NOT_FOUND` bzw. 409 `SERVICE_LOCKED`.
- **Fehlertexte der Firewall unleserlich:** Die Redaktion maskierte jede
  Validierungsmeldung, deren Feldname `cert`, `crt` oder `auth` enthält
  („instance.cert: [redacted]“). In `validations` lösen diese drei Namen die
  Maskierung nicht mehr aus; echte Geheimnisfelder bleiben maskiert. Das
  Löschen eines benutzten Zertifikats nennt jetzt den Grund der Firewall
  („Item in use by OpenVPN …“) statt eines allgemeinen Fehlers.
- **Drift-Alarme flackerten beim Neustart eines Cluster-Mitglieds:** Mit nur
  einem erreichbaren Host gab es nichts zu vergleichen, offene Regel-/NAT-Drift-
  und Zertifikatsprobleme wurden „gelöst“ und danach neu geöffnet (je ein
  Meldungspaar pro Problem). Offene Probleme bleiben jetzt unverändert, solange
  sie nicht beurteilbar sind.
- **IPsec lief nach dem Deploy nicht:** Der globale IPsec-Schalter war aus, die
  Verbindungen wurden gespeichert, `reconfigure` antwortete „ok“, OPNexus meldete
  „Erfolg“ (gleiche Klasse wie bei WireGuard). Der Deploy schaltet ihn jetzt ein;
  zwischen zwei Test-FWs steht der Tunnel (Phase 1 verbunden).
- **Löschen scheiterte ohne Grund:** Wenn die Firewall ein Löschen ablehnt (Alias
  noch in einer Regel, Zertifikat in Benutzung), nennt die Meldung jetzt den Grund
  der Firewall (`UPSTREAM_REJECTED`) statt „Anfrage fehlgeschlagen“.
- **Kaputte Eingaben:** Ein NUL-Zeichen in einem Textfeld ergab einen 500er samt
  abgebrochener Verbindung, ebenso ein langes oder NUL-haltiges Pfadsegment (der
  Audit-Eintrag übernahm es ungeprüft in `VARCHAR(36)`). Jetzt 422 bzw. 404/422;
  ein Fuzz-Lauf über alle 188 Routen findet keine 5xx mehr.
- **`opnexus rollback` landete auf der falschen Version:** Zurückgesetzt wurde auf
  die vom Backend gemeldete Programmversion statt auf den vorher eingestellten
  Image-Tag. Bei Release-Candidates (rc1-Images melden schon `1.11.0`) führte das
  auf das neuere Release. Jetzt wird der vorherige exakte Tag wiederhergestellt.
- **Handy-Ansicht (375px) zoomte aus:** Die ausgeblendete Überschrift der
  Aktionsspalte (`sr-only`, absolut positioniert) lag außerhalb der scrollbaren
  Tabellenkarte und machte die Seite bis zu doppelt so breit (Hosts, Benutzer,
  Host-Details). Scroll-Container sind jetzt der Bezugsrahmen.
- **Überschriften-Reihenfolge:** Kartentitel in den Listen (OpenVPN, IPsec,
  WireGuard, NAT) sind `h2` statt `h3`, sichtbar erst mit Daten; axe meldet auf
  allen Seiten, hell und dunkel, nichts mehr.

- **CARP-Wartungsfenster mit Auto-Revert:** Nach dem automatischen Zurückschalten
  blieb die Aktion „CARP-Übergabe“ (`carp_enter`) offen. Sie ließ sich nicht mehr
  bestätigen (der Host ist ja nicht mehr im Wartungsmodus) und blockierte alle
  Deploys und Updates im Cluster (`OPEN_HOST_MAINTENANCE_BLOCKS_DEPLOY`), bis
  jemand sie von Hand abbrach. Der Auto-Revert schließt die Aktion jetzt selbst
  (Status „abgebrochen“, Audit-Eintrag). Schlägt das Zurückschalten fehl, bleibt
  sie bewusst offen. Gefunden beim Live-Test gegen zwei OPNsense-26.7-Firewalls.
- **Update Center, Protokoll-Fenster:** Nach einem Update mit Neustart ist das
  Protokoll der Firewall leer; angezeigt wurde dann nur das Wort „error“, obwohl
  das Update erfolgreich war. Jetzt steht dort ein erklärender Hinweis.
2026-10-09 18:35:53 +02:00

172 lines
7.0 KiB
Python

"""Pushes a centrally-authored alias to its target hosts via the OPNsense
API — the alias equivalent of rule_deploy_service.py. Same deploy lockout
protection: a successful push goes to "pending" with a revert_deadline,
not straight to "success", since editing an alias's content changes
enforcement for every rule that references it on that host. See
rule_deploy_service.py for the full rationale; this mirrors it exactly,
kept as a separate module rather than a shared generic implementation so a
bug in alias handling can't affect the already-relied-upon rule deploy
path.
"""
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.models.host import OpnsenseHost
from app.models.alias import FirewallAlias
from app.models.alias_deployment import AliasDeployment
from app.services.audit_service import tracked_rollback
from app.services import opnsense_client
from app.services.deploy_flow import push_to_host
from app.services.opnsense_client import OpnsenseAmbiguousError, OpnsenseAPIError
def _now() -> datetime:
return datetime.now(timezone.utc)
def _build_payload(alias: FirewallAlias) -> dict:
return {
"enabled": "1" if alias.enabled else "0",
"name": alias.name,
"type": alias.type,
"content": alias.content,
"description": alias.description,
}
async def _deploy_to_host(db: AsyncSession, alias: FirewallAlias, host: OpnsenseHost) -> AliasDeployment:
payload = _build_payload(alias)
existing = await db.scalar(
select(AliasDeployment).where(AliasDeployment.alias_id == alias.id, AliasDeployment.host_id == host.id)
)
if existing and existing.status in ("success", "reverted") and existing.last_payload == payload:
existing.status = "success"
existing.message = None
existing.revert_deadline = None
existing.deployed_at = _now()
return existing
if existing and existing.status in ("pending", "revert_failed"):
return existing
prior_uuid = existing.opnsense_alias_uuid if existing else None
prior_live_payload = existing.last_payload if existing else None
async def write(uuid_):
if uuid_:
await opnsense_client.set_alias_item(host, uuid_, payload)
return uuid_
result = await opnsense_client.add_alias_item(host, payload)
new_uuid = result.get("uuid") if isinstance(result, dict) else None
if not new_uuid:
raise OpnsenseAmbiguousError("Firewall hat keine Alias-UUID zurückgegeben; Stand manuell prüfen")
return new_uuid
deployment = await push_to_host(
db, existing=existing, uuid_attr="opnsense_alias_uuid",
make=lambda **fields: AliasDeployment(alias_id=alias.id, host_id=host.id, **fields),
prior_uuid=prior_uuid, prior_live_payload=prior_live_payload, payload=payload,
write=write, apply=lambda: opnsense_client.reconfigure_aliases(host),
)
return deployment
async def deploy_alias(db: AsyncSession, alias: FirewallAlias, hosts: list[OpnsenseHost]) -> list[AliasDeployment]:
from app.services.host_maintenance_service import ensure_no_maintenance
from app.services.policy_hierarchy_deploy_service import reassert_after_managed_change
await ensure_no_maintenance(db, hosts)
deployments = [await _deploy_to_host(db, alias, host) for host in hosts]
await db.flush()
for host, deployment in zip(hosts, deployments):
if deployment.status != "pending":
continue
try:
await reassert_after_managed_change(db, host)
except OpnsenseAPIError as exc:
deployment.status = "error"
deployment.message = f"Alias wurde übernommen, Hierarchie-Reihenfolge aber nicht bestätigt: {exc}"
# The change is live: keep the revert deadline so an unconfirmed result is still undone.
await db.commit()
return deployments
async def confirm_alias(db: AsyncSession, alias: FirewallAlias) -> list[AliasDeployment]:
result = await db.execute(
select(AliasDeployment).where(AliasDeployment.alias_id == alias.id, AliasDeployment.status == "pending", AliasDeployment.revert_deadline > _now())
)
deployments = list(result.scalars().all())
for deployment in deployments:
deployment.status = "success"
deployment.revert_deadline = None
await db.commit()
return deployments
async def sweep_expired_alias_deployments(db: AsyncSession) -> list[AliasDeployment]:
from app.services.policy_service import policy_lock
await policy_lock(db)
result = await db.execute(
select(AliasDeployment).where(AliasDeployment.status.in_(("pending", "error")), AliasDeployment.revert_deadline <= _now())
)
expired = list(result.scalars().all())
touched: list[AliasDeployment] = []
for deployment in expired:
await revert_alias_deployment(db, deployment)
touched.append(deployment)
await db.commit()
return touched
async def undeploy_alias(db: AsyncSession, alias: FirewallAlias) -> None:
failures = []
result = await db.execute(select(AliasDeployment).where(AliasDeployment.alias_id == alias.id))
for deployment in result.scalars().all():
host = await db.get(OpnsenseHost, deployment.host_id)
if host and deployment.opnsense_alias_uuid:
try:
await opnsense_client.del_alias_item(host, deployment.opnsense_alias_uuid)
await opnsense_client.reconfigure_aliases(host)
except OpnsenseAPIError as exc:
deployment.status = "error"
deployment.message = str(exc)
failures.append(f"{host.name} ({str(exc)[:200]})")
continue
await db.delete(deployment)
await db.flush()
if failures:
await db.commit()
raise OpnsenseAPIError("Alias konnte nicht von allen Hosts entfernt werden: " + ", ".join(failures))
@tracked_rollback("alias")
async def revert_alias_deployment(db: AsyncSession, deployment: AliasDeployment) -> bool:
host = await db.get(OpnsenseHost, deployment.host_id)
if host is None:
await db.delete(deployment)
return True
try:
if deployment.previous_payload is None:
if deployment.opnsense_alias_uuid:
await opnsense_client.del_alias_item(host, deployment.opnsense_alias_uuid)
await opnsense_client.reconfigure_aliases(host)
await db.delete(deployment)
else:
await opnsense_client.set_alias_item(host, deployment.opnsense_alias_uuid, deployment.previous_payload)
await opnsense_client.reconfigure_aliases(host)
deployment.last_payload = deployment.previous_payload
deployment.status = "reverted"
deployment.message = None
deployment.revert_deadline = None
return True
except OpnsenseAPIError as exc:
deployment.status = "revert_failed"
deployment.message = str(exc)
deployment.revert_deadline = None
return False