- **Aus dem Testlauf 1.31.0 behoben (T2 bis T6, B15):**
- **Benutzerliste:** `GET /api/users` zeigte jedem angemeldeten Konto (auch Nur-Lese, auch per Token) alle Benutzernamen, Rollen, letzte Anmeldung und 2FA-Status.
Jetzt sehen **nur Admins** die ganze Liste, alle anderen nur das eigene Konto.
- **Hosts:** Dieselbe Adresse zweimal einzutragen wird abgelehnt (`HOST_URL_EXISTS`, auch beim Ändern der Adresse). Die Hosts-Seite und der Einrichtungsassistent
prüfen die Verbindung beim Anlegen (Schlüssel abgelehnt / zu wenig Rechte / Zertifikat / keine Verbindung), speichern bei einem Fehler nichts und bieten
„Trotzdem anlegen“. Über die API ist das `verify_connection: true` (Standard aus). **Löschen verlangt jetzt auch am Server den Hostnamen:**
`DELETE /api/hosts/{id}?confirm_name=<Name>` (sonst 409); Skripte, die Hosts löschen, müssen das anpassen.
- **Zertifikate:** `cert_type: v3_ca` wird nicht mehr angenommen (es entstand ein Zertifikat mit CA-Erweiterung, keine nutzbare CA); die Oberfläche bot es nie an.
- **Traceroute:** Zwischenstationen ohne Antwort stehen als Zeile „keine Antwort“ in der Liste (vorher sprang die Zählung 1 → 3); ein Weg ganz ohne Antwort wird erklärt.
- **Netzwerk:** Die Vorschau prüft Zahlenbereiche vorab (VLAN-Tag 1–4094, PCP 0–7, VHID 1–255, Basis 1–254, Skew 0–254, gegen das Modell der OPNsense gelesen): `422` mit dem
Feldnamen statt `502` von der Firewall.
- **Gruppen:** Eine Gruppe, die ein VPN-Objekt als Ziel hat, lässt sich nicht mehr löschen (die Verknüpfung fiel still weg).
- Das Skript `scripts/verify_i18n.mjs` läuft jetzt auch gegen die HTTPS-Test-Instanz.
- **Regel-Bereinigung: OPNexus selbst ist automatisch geschützt (B10).** Beim Planen fragt die Bereinigung jede Firewall, von welcher Adresse sie OPNexus kommen sieht
(die eigene API-Verbindung in der Zustandstabelle), und schützt diese Adresse zusätzlich zu den Verwaltungsquellen. Vorher konnte eine Regel abgeschaltet werden, über
die nur OPNexus die Firewall erreicht, wenn in den Quellen nur Arbeitsplätze standen; dann scheiterte auch das automatische Zurücksetzen. Die Vorschau zeigt die Adresse
je Firewall (nur Admins) oder sagt, dass sie sich nicht ermitteln ließ (API-Recht „Diagnose: Zustände“, Management-Tunnel).
- **Keine verlorenen Regeltreffer mehr (B11):** Jedes Anwenden setzt die Regelzähler der Firewall zurück. OPNexus liest sie jetzt unmittelbar vor jedem Anwenden, das es
selbst auslöst (Regeln, NAT, Aliase, IPsec/OpenVPN/WireGuard, Netzwerk); vorher konnte eine Regel deshalb fälschlich als „ungenutzt“ gelten.
- **Einstellungen › Regel-Bereinigung:** schlägt die eigene Browser-Adresse als Verwaltungsquelle vor (B12). Geprüft: OPNsense 26.7 hat kein „savepoint“ zum Zurücknehmen
durch die Firewall selbst (Quellcode und live 404), das automatische Zurücksetzen bleibt bei OPNexus.
- **Installation unter Windows:** Neue Anleitung auf der Website (`/docs/windows/`, auch auf Englisch) und im Handbuch: Docker Desktop mit WSL 2, Zertifizierungsstelle importieren, Zugriff aus dem Netz (Windows-Firewall), Updates mit `opnexus` in WSL und Fehlerbehebung. Entwickelt und getestet wird weiterhin unter Linux; die Anleitung sagt das offen.
- **Sicherheit und Stabilität (aus einer externen Prüfung):**
- **Rückweg bleibt erhalten:** Wer nach „Konfiguration geschrieben, Anwenden fehlgeschlagen“ erneut ausrollte, überschrieb den ursprünglichen Rückweg; die automatische
Rücknahme stellte dann den unbestätigten Stand wieder her. Jetzt bleibt der Rückweg der Stand vor dem ersten Versuch. Wartung, Regel-Bereinigung und
„Konfiguration als Code“ behandeln eine solche Zeile wie eine offene Bestätigung. Gilt für Regeln, Aliase, Port-Forwards, 1:1-NAT, Outbound-NAT und Hierarchie-Regeln.
- **Anmeldung und 2FA:** Eine Passwort-Anmeldung, die genau während der Aktivierung von 2FA lief, bekam eine Sitzung ohne zweiten Faktor. Jetzt wird sie mit `TOTP_REQUIRED` abgewiesen.
- **Letzter Administrator:** Zwei Administratoren, die sich gleichzeitig herabstuften oder gegenseitig löschten, konnten alle Administratoren entfernen. Beides ist jetzt serialisiert.
- **Operatoren:** Mit ausgeschaltetem „Operatoren dürfen ausrollen“ konnten sie noch Snapshots (Boot-Umgebungen) anlegen. Ein Test hält künftig jede schreibende Route
fest, die keiner Rechte-Regel zugeordnet ist.
- **Zertifikate und ACME:** Der Ziel-Host ist nach dem ersten Push fest (`409 ZIEL_HOST_NACH_AUSROLLEN_FEST`); vorher meldete ein Host-Wechsel Erfolg für einen Host, der nie
etwas erhielt. Ein ACME-Konto mit Anfragen lässt sich nicht auf einen anderen Host verschieben (`ACME_KONTO_WIRD_VON_ANFRAGEN_VERWENDET`).
- **VPN-Ausrollen:** IPsec, OpenVPN und WireGuard sichern jede von der Firewall vergebene Kennung sofort; bei einem Abbruch entstehen beim nächsten Versuch keine Duplikate.
Pro VPN läuft nur ein Ausrollen oder Entfernen zugleich (`409 VPN_DEPLOY_LAEUFT_BEREITS`).
- **Konfiguration als Code:** Gültiges YAML mit falsch geformtem Abschnitt (z. B. `aliases: 42`) ergibt `422 CONFIG_INVALID` statt eines Serverfehlers.
- **`.env`:** Die Anleitungen legen sie mit `install -m 600` an; `opnexus update` und `opnexus status` setzen eine zu offene Datei auf 0600. `opnexus rollback` liest seinen
Update-Zustand nur noch als Text und führt ihn nie aus.
- **Abhängigkeiten:** Next.js 15.5.28, PyJWT 2.15.1.
248 lines
13 KiB
Python
248 lines
13 KiB
Python
"""A small stateful OPNsense for the rule-cleanup tests (respx side effects).
|
|
|
|
It keeps its rules in memory and answers searchRule, getRule, toggleRule, delRule, addRule, moveRuleBefore, apply,
|
|
ruleStats, pfStatistics (generated from the enabled rules, like pf after an apply), interfacesInfo, groups, aliases,
|
|
port forwards, getVipStatus and core/hasync/get in the shapes seen live (OPNsense 26.7, see opnsense_client.py).
|
|
``fail`` injects errors per call: {("toggle", uuid): httpx.ReadTimeout | httpx.Response | callable}.
|
|
Addresses: documentation/private ranges only.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
import re
|
|
import uuid as uuid_lib
|
|
|
|
import httpx
|
|
|
|
R_BASE = "aaaaaaaa-0000-4000-8000-000000000100"
|
|
R_DUP = "aaaaaaaa-0000-4000-8000-000000000200"
|
|
R_UNUSED = "aaaaaaaa-0000-4000-8000-000000000300"
|
|
R_BLOCK = "aaaaaaaa-0000-4000-8000-000000000400"
|
|
R_MGMT = "aaaaaaaa-0000-4000-8000-000000000500"
|
|
R_CARP = "aaaaaaaa-0000-4000-8000-000000000600"
|
|
R_ALIAS = "aaaaaaaa-0000-4000-8000-000000000700"
|
|
R_TAIL = "aaaaaaaa-0000-4000-8000-000000000800"
|
|
R_LEGACY = "0123456789abcdef0123456789abcdef"
|
|
AUTO = "02f4bab031b57d1e30553ce08e0ec131"
|
|
MGMT_SOURCE = "203.0.113.10"
|
|
DEVICES = {"wan": "vtnet0", "lan": "vtnet1"}
|
|
|
|
|
|
def rule(uuid, seq, *, action="pass", interface="lan", proto="TCP", src="any", dst="any", dport="", enabled="1",
|
|
description=None, **extra) -> dict:
|
|
row = {
|
|
"uuid": uuid, "sort_order": f"400000.{seq:07d}", "prio_group": "400000", "sequence": str(seq),
|
|
"enabled": enabled, "action": action, "quick": "1", "interface": interface, "interfacenot": "0",
|
|
"direction": "in", "ipprotocol": "inet", "protocol": proto, "source_net": src, "source_not": "0",
|
|
"source_port": "", "destination_net": dst, "destination_not": "0", "destination_port": dport,
|
|
"gateway": "", "sched": "", "statetype": "keep", "tagged": "", "log": "0", "categories": "",
|
|
"description": description or f"cleanup-{seq}",
|
|
}
|
|
row.update(extra)
|
|
return row
|
|
|
|
|
|
def default_rules() -> list[dict]:
|
|
return [
|
|
{"uuid": AUTO, "sort_order": "000001.1000001", "legacy": True, "is_automatic": True, "enabled": "1",
|
|
"action": "block", "quick": False, "direction": "in", "ipprotocol": "inet46", "protocol": "",
|
|
"source_net": "", "source_port": "", "destination_net": "", "destination_port": "",
|
|
"description": "Default deny / state violation rule"},
|
|
rule(R_BASE, 100, src="10.20.30.0/24", dport="9000", description="base 9000"),
|
|
rule(R_DUP, 200, src="10.20.30.0/24", dport="9000", description="dup 9000"),
|
|
rule(R_UNUSED, 300, src="10.20.30.0/24", dport="8081", description="unused 8081"),
|
|
rule(R_BLOCK, 400, action="block", proto="UDP", dport="9999", description="block 9999"),
|
|
rule(R_MGMT, 500, interface="wan", src="203.0.113.0/24", dport="443", description="mgmt 443"),
|
|
rule(R_CARP, 600, proto="CARP", description="carp"),
|
|
rule(R_ALIAS, 700, interface="wan", src="tbl", dport="443", description="alias 443"),
|
|
rule(R_TAIL, 800, src="10.20.30.7", dport="7000", description="tail 7000"),
|
|
]
|
|
|
|
|
|
class FakeFirewall:
|
|
def __init__(self, base: str, *, role: str | None = None, rules: list[dict] | None = None,
|
|
pfsync: str = "", peer: str = "", lan_addr: str = "10.20.30.2", wan_addr: str = "203.0.113.57",
|
|
log: list | None = None):
|
|
self.base = base
|
|
self.log = log # shared across firewalls: the order of calls over a cluster
|
|
self.role = role
|
|
self.rules: list[dict] = copy.deepcopy(rules if rules is not None else default_rules())
|
|
self.pfsync, self.peer = pfsync, peer
|
|
self.lan_addr, self.wan_addr = lan_addr, wan_addr
|
|
self.fail: dict = {}
|
|
self.calls: list[tuple[str, str]] = []
|
|
self.applied = 0
|
|
self.down = False
|
|
self.on_call = None # callable(kind, uuid) run before a write is answered (e.g. look at the database)
|
|
|
|
# ── state helpers ──
|
|
def get(self, uuid):
|
|
return next((r for r in self.rules if r["uuid"].lower() == uuid.lower()), None)
|
|
|
|
def _inject(self, key):
|
|
effect = self.fail.get(key)
|
|
if effect is None:
|
|
return None
|
|
if isinstance(effect, list):
|
|
effect = effect.pop(0) if effect else None
|
|
if effect is None:
|
|
return None
|
|
if isinstance(effect, type) and issubclass(effect, Exception):
|
|
raise effect("injected")
|
|
if isinstance(effect, Exception):
|
|
raise effect
|
|
if callable(effect) and not isinstance(effect, httpx.Response):
|
|
return effect()
|
|
return effect
|
|
|
|
def pf_lines(self) -> dict:
|
|
lines = {"@0 scrub in all fragment reassemble": {}}
|
|
n = 1
|
|
for r in sorted(self.rules, key=lambda r: r["sort_order"]):
|
|
if str(r.get("enabled")) != "1":
|
|
continue
|
|
if r.get("is_automatic"):
|
|
lines[f'@{n} block drop in log inet all label "{r["uuid"]}"'] = {"evaluations": 0}
|
|
else:
|
|
words = ["block" if r["action"] == "block" else "pass", "in", "quick", f"on {DEVICES.get(r['interface'], r['interface'])}", "inet"]
|
|
if r["protocol"] not in ("", "any"):
|
|
words.append(f"proto {r['protocol'].lower()}")
|
|
words.append(f'all label "{r["uuid"]}"')
|
|
lines[f"@{n} " + " ".join(words)] = {"evaluations": 0, "packets": 0}
|
|
n += 1
|
|
return {"rules": {"filter rules": lines}}
|
|
|
|
def model(self, row: dict) -> dict:
|
|
"""getRule shape: select fields as option dicts, no uuid key."""
|
|
out = {k: v for k, v in row.items() if k not in ("uuid", "is_automatic", "legacy")}
|
|
out["action"] = {a: {"value": a, "selected": 1 if row["action"] == a else 0} for a in ("pass", "block", "reject")}
|
|
out["interface"] = {i: {"value": i.upper(), "selected": 1 if i in str(row["interface"]).split(",") else 0} for i in ("lan", "wan")}
|
|
out["categories"] = []
|
|
out["audit"] = {"created": "x", "updated": "y"}
|
|
return out
|
|
|
|
# ── respx wiring ──
|
|
def mount(self, respx_mock):
|
|
respx_mock.route(url__startswith=self.base).mock(side_effect=self.handle)
|
|
return self
|
|
|
|
def handle(self, request: httpx.Request) -> httpx.Response:
|
|
path = request.url.path.removeprefix("/api")
|
|
method = request.method
|
|
self.calls.append((method, path))
|
|
if self.log is not None:
|
|
self.log.append((self.base, method, path))
|
|
if self.down:
|
|
raise httpx.ConnectError("down")
|
|
if method == "GET" and path == "/diagnostics/interface/getVipStatus":
|
|
rows = [] if self.role is None else [{"interface": "LAN", "vhid": "10", "status": self.role}]
|
|
return httpx.Response(200, json={"rows": rows, "carp": {"allow": "1", "maintenancemode": False}})
|
|
if method == "GET" and path == "/firewall/filter/searchRule":
|
|
got = self._inject(("search", ""))
|
|
if got is not None:
|
|
return got
|
|
return httpx.Response(200, json={"rows": copy.deepcopy(sorted(self.rules, key=lambda r: r["sort_order"]))})
|
|
if method == "GET" and path == "/diagnostics/firewall/pfStatistics/rules":
|
|
return httpx.Response(200, json=self.pf_lines())
|
|
if method == "GET" and path == "/firewall/filter_util/ruleStats":
|
|
return httpx.Response(200, json={"status": "ok", "stats": {}})
|
|
if method == "GET" and path == "/interfaces/overview/interfacesInfo":
|
|
return httpx.Response(200, json={"rows": [
|
|
{"identifier": "wan", "device": "vtnet0", "description": "WAN", "ipv4": [{"ipaddr": f"{self.wan_addr}/24"}],
|
|
"ipv6": [], "gateways": ["203.0.113.1"], "routes": ["default"]},
|
|
{"identifier": "lan", "device": "vtnet1", "description": "LAN",
|
|
"ipv4": [{"ipaddr": f"{self.lan_addr}/24"}, {"ipaddr": "10.20.30.1/24", "vhid": "10"}],
|
|
"ipv6": [], "gateways": [], "routes": ["10.20.30.0/24"]},
|
|
{"identifier": "lo0", "device": "lo0", "description": "Loopback", "ipv4": [{"ipaddr": "127.0.0.1/8"}], "ipv6": []},
|
|
]})
|
|
if method == "GET" and path == "/firewall/group/searchItem":
|
|
return httpx.Response(200, json={"rows": []})
|
|
if method == "GET" and path == "/firewall/alias/searchItem":
|
|
return httpx.Response(200, json={"rows": [{"name": "tbl", "type": "urltable", "content": "https://203.0.113.9/list"}]})
|
|
if method == "GET" and path == "/firewall/d_nat/searchRule":
|
|
return httpx.Response(200, json={"rows": []})
|
|
# B10: OPNexus asks for its own source address. respx has no real socket, so the state cannot be matched to
|
|
# this connection: the address stays unknown unless a test sets ``own_source`` (then the fake answers it).
|
|
if method == "GET" and path == "/diagnostics/firewall/pf_states":
|
|
return httpx.Response(200, json={"current": 5, "limit": "100000"})
|
|
if method == "POST" and path == "/diagnostics/firewall/query_states":
|
|
return httpx.Response(200, json={"rows": [], "rowCount": 0, "total": 0, "current": 1})
|
|
if method == "GET" and path == "/core/hasync/get":
|
|
sel = {"": {"value": "Disabled", "selected": 1 if not self.pfsync else 0},
|
|
"lan": {"value": "LAN", "selected": 1 if self.pfsync == "lan" else 0}}
|
|
return httpx.Response(200, json={"hasync": {"pfsyncinterface": sel, "pfsyncpeerip": self.peer, "password": "x"}})
|
|
m = re.fullmatch(r"/firewall/filter/getRule/([^/]+)", path)
|
|
if method == "GET" and m:
|
|
got = self._inject(("get", m.group(1)))
|
|
if got is not None:
|
|
return got
|
|
row = self.get(m.group(1))
|
|
if row is None or row.get("is_automatic") or row.get("legacy"):
|
|
return httpx.Response(200, json=[])
|
|
return httpx.Response(200, json={"rule": self.model(row)})
|
|
m = re.fullmatch(r"/firewall/filter/toggleRule/([^/]+)/([01])", path)
|
|
if method == "POST" and m:
|
|
if self.on_call:
|
|
self.on_call("toggle", m.group(1))
|
|
row = self.get(m.group(1))
|
|
if row is not None and not row.get("legacy"):
|
|
pending = ("toggle", m.group(1))
|
|
if pending in self.fail and self.fail.get(("toggle_applies", m.group(1))):
|
|
row["enabled"] = m.group(2) # applied on the firewall, but the answer is lost
|
|
got = self._inject(pending)
|
|
if got is not None:
|
|
return got
|
|
changed = row["enabled"] != m.group(2)
|
|
row["enabled"] = m.group(2)
|
|
return httpx.Response(200, json={"result": "Enabled" if m.group(2) == "1" else "Disabled", "changed": changed})
|
|
return httpx.Response(200, json={"result": "failed"})
|
|
m = re.fullmatch(r"/firewall/filter/delRule/([^/]+)", path)
|
|
if method == "POST" and m:
|
|
if self.on_call:
|
|
self.on_call("delete", m.group(1))
|
|
row = self.get(m.group(1))
|
|
applies = self.fail.get(("delete_applies", m.group(1)))
|
|
if applies and row is not None:
|
|
self.rules.remove(row)
|
|
got = self._inject(("delete", m.group(1)))
|
|
if got is not None:
|
|
return got
|
|
if row is None or row.get("legacy"):
|
|
return httpx.Response(200, json={"result": "not found"})
|
|
self.rules.remove(row)
|
|
return httpx.Response(200, json={"result": "deleted"})
|
|
if method == "POST" and path == "/firewall/filter/addRule":
|
|
got = self._inject(("add", ""))
|
|
if got is not None:
|
|
return got
|
|
import json as _json
|
|
body = _json.loads(request.content)["rule"]
|
|
new = str(uuid_lib.uuid4())
|
|
top = max(int(float(r.get("sequence") or 0)) for r in self.rules if not r.get("is_automatic"))
|
|
row = {**body, "uuid": new, "sequence": str(top + 100), "sort_order": f"400000.{top + 100:07d}", "prio_group": "400000"}
|
|
self.rules.append(row)
|
|
return httpx.Response(200, json={"result": "saved", "uuid": new})
|
|
m = re.fullmatch(r"/firewall/filter/moveRuleBefore/([^/]+)/([^/]+)", path)
|
|
if method == "POST" and m:
|
|
got = self._inject(("move", m.group(1))) or self._inject(("move", "*"))
|
|
if got is not None:
|
|
return got
|
|
moving, target = self.get(m.group(1)), self.get(m.group(2))
|
|
if moving is None or target is None or moving is target:
|
|
return httpx.Response(500, json={"errorMessage": "Unexpected error, check log for details"})
|
|
seq = int(target["sequence"]) - 1
|
|
moving["sequence"], moving["sort_order"] = str(seq), f"400000.{seq:07d}"
|
|
return httpx.Response(200, json={"status": "ok"})
|
|
if method == "POST" and path == "/firewall/filter/apply":
|
|
if self.on_call:
|
|
self.on_call("apply", "")
|
|
got = self._inject(("apply", ""))
|
|
if got is not None:
|
|
return got
|
|
self.applied += 1
|
|
return httpx.Response(200, json={"status": "OK\n\n"})
|
|
return httpx.Response(404, json={"message": f"unexpected {method} {path}"})
|
|
|
|
def writes(self) -> list[tuple[str, str]]:
|
|
return [(m, p) for m, p in self.calls if m == "POST"]
|