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
- **Fix (Ursache des Vorfalls vom 20.10. in 1.23.4):** OPNsense merkt ein CARP-VIP zum Löschen vor, wenn eine Änderung kein `interface` mitschickt, und der anschließende „Anwenden“-Schritt entfernt es dann live, ohne es wieder anzulegen. OPNexus schickt bei jeder VIP-Änderung jetzt die bisherige Schnittstelle mit (wie schon die Adresse). Auf dem Test-Cluster mit echten Firewalls nachgestellt und bestätigt. - **Einstellungen › OPNexus aktualisieren:** Die Anleitung zum Aktivieren nennt jetzt alles Nötige in einem Block (auch `UPDATE_CHECK_URL`) und weist darauf hin, dass Updates per Klick Release-Images brauchen; wer aus dem Quellcode baut, aktualisiert mit `git pull` und `docker compose up -d --build`.
603 lines
36 KiB
Python
603 lines
36 KiB
Python
"""Writes to VLANs / virtual IPs / gateways / routes, and the safety around them."""
|
|
import json
|
|
|
|
import httpx
|
|
import pytest
|
|
from sqlalchemy import select
|
|
|
|
from app.database import SessionLocal
|
|
from app.models.audit import AuditEvent
|
|
from tests.test_network import GATEWAYS, INTERFACES_INFO, _host, _mock
|
|
|
|
MGMT = "https://10.20.30.57"
|
|
|
|
|
|
def _url(path, base=MGMT):
|
|
return f"{base}/api{path}"
|
|
|
|
|
|
def _backup(respx_mock, base=MGMT, status=200):
|
|
return respx_mock.get(_url("/core/backup/download/this", base)).mock(
|
|
return_value=httpx.Response(status, content=b"<opnsense/>"))
|
|
|
|
|
|
def _item(respx_mock, path, key, node, base=MGMT):
|
|
respx_mock.get(_url(path, base)).mock(return_value=httpx.Response(200, json={key: node}))
|
|
|
|
|
|
async def _setup(client, respx_mock, name, base=MGMT, **overrides):
|
|
host_id = await _host(client, respx_mock, base=base, name=name)
|
|
_mock(respx_mock, base=base, **overrides)
|
|
return host_id
|
|
|
|
|
|
def _body(call):
|
|
return json.loads(call.request.content.decode())
|
|
|
|
|
|
async def _preview(client, host_id, **payload):
|
|
return await client.post(f"/api/hosts/{host_id}/network/changes/preview", json=payload)
|
|
|
|
|
|
async def _execute(client, host_id, token, ack=False, **payload):
|
|
return await client.post(f"/api/hosts/{host_id}/network/changes",
|
|
json={**payload, "preview_token": token, "acknowledge": ack})
|
|
|
|
|
|
async def _audit(host_id):
|
|
async with SessionLocal() as db:
|
|
rows = await db.scalars(select(AuditEvent).where(AuditEvent.host_id == host_id, AuditEvent.kind == "network")
|
|
.order_by(AuditEvent.created_at))
|
|
return list(rows.all())
|
|
|
|
|
|
VLAN_ADD = {"kind": "vlan", "action": "add", "fields": {"if": "vtnet1", "tag": "20", "descr": "guests"}}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_preview_add_is_read_only_and_shows_diff(client, respx_mock):
|
|
host_id = await _setup(client, respx_mock, "fw-c1")
|
|
resp = await _preview(client, host_id, **VLAN_ADD)
|
|
assert resp.status_code == 200, resp.text
|
|
plan = resp.json()
|
|
assert plan["can_execute"] is True and plan["requires_acknowledgement"] is False
|
|
assert {c["field"] for c in plan["changes"]} == {"if", "tag", "descr"}
|
|
assert len(plan["token"]) == 64
|
|
assert not [c for c in respx_mock.calls if c.request.method == "POST" and "/api/hosts" not in str(c.request.url)
|
|
and "vlan_settings" in str(c.request.url)]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_add_backs_up_writes_applies_and_verifies(client, respx_mock):
|
|
host_id = await _setup(client, respx_mock, "fw-c2")
|
|
backup = _backup(respx_mock)
|
|
add = respx_mock.post(_url("/interfaces/vlan_settings/addItem")).mock(
|
|
return_value=httpx.Response(200, json={"result": "saved", "uuid": "new-vlan"}))
|
|
reconf = respx_mock.post(_url("/interfaces/vlan_settings/reconfigure")).mock(
|
|
return_value=httpx.Response(200, json={"status": "ok"}))
|
|
_item(respx_mock, "/interfaces/vlan_settings/getItem/new-vlan", "vlan",
|
|
{"if": {"vtnet1": {"selected": 1}}, "tag": "20", "descr": "guests"})
|
|
token = (await _preview(client, host_id, **VLAN_ADD)).json()["token"]
|
|
|
|
resp = await _execute(client, host_id, token, **VLAN_ADD)
|
|
assert resp.status_code == 200, resp.text
|
|
result = resp.json()
|
|
assert result["uuid"] == "new-vlan" and result["verified"] is True and result["backup_id"].endswith(".xml")
|
|
assert backup.called and reconf.called
|
|
assert _body(add.calls.last) == {"vlan": {"if": "vtnet1", "tag": "20", "descr": "guests"}}
|
|
assert [e.status for e in await _audit(host_id)] == ["started", "applied"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stale_token_is_refused_and_nothing_is_written(client, respx_mock):
|
|
host_id = await _setup(client, respx_mock, "fw-c3")
|
|
add = respx_mock.post(_url("/interfaces/vlan_settings/addItem")).mock(
|
|
return_value=httpx.Response(200, json={"result": "saved", "uuid": "x"}))
|
|
resp = await _execute(client, host_id, "0" * 64, **VLAN_ADD)
|
|
assert resp.status_code == 409
|
|
assert not add.called
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_token_changes_when_the_fields_change(client, respx_mock):
|
|
host_id = await _setup(client, respx_mock, "fw-c4")
|
|
one = (await _preview(client, host_id, **VLAN_ADD)).json()["token"]
|
|
other = dict(VLAN_ADD, fields={**VLAN_ADD["fields"], "tag": "21"})
|
|
two = (await _preview(client, host_id, **other)).json()["token"]
|
|
assert one != two
|
|
# a token for the first request cannot be used for the second
|
|
assert (await _execute(client, host_id, one, **other)).status_code == 409
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_backup_failure_aborts_before_any_write(client, respx_mock):
|
|
host_id = await _setup(client, respx_mock, "fw-c5")
|
|
_backup(respx_mock, status=500)
|
|
add = respx_mock.post(_url("/interfaces/vlan_settings/addItem")).mock(
|
|
return_value=httpx.Response(200, json={"result": "saved", "uuid": "x"}))
|
|
token = (await _preview(client, host_id, **VLAN_ADD)).json()["token"]
|
|
resp = await _execute(client, host_id, token, **VLAN_ADD)
|
|
assert resp.status_code == 502
|
|
assert resp.json()["detail"]["code"] == "NETWORK_BACKUP_FAILED"
|
|
assert not add.called
|
|
assert [e.status for e in await _audit(host_id)] == ["aborted"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_firewall_rejection_is_reported_and_not_applied(client, respx_mock):
|
|
host_id = await _setup(client, respx_mock, "fw-c6")
|
|
_backup(respx_mock)
|
|
respx_mock.post(_url("/interfaces/vlan_settings/addItem")).mock(
|
|
return_value=httpx.Response(200, json={"result": "failed", "validations": {"vlan.tag": "bad"}}))
|
|
reconf = respx_mock.post(_url("/interfaces/vlan_settings/reconfigure")).mock(
|
|
return_value=httpx.Response(200, json={"status": "ok"}))
|
|
token = (await _preview(client, host_id, **VLAN_ADD)).json()["token"]
|
|
resp = await _execute(client, host_id, token, **VLAN_ADD)
|
|
assert resp.status_code == 502 and resp.json()["detail"]["code"] == "UPSTREAM_REJECTED"
|
|
assert not reconf.called
|
|
assert (await _audit(host_id))[-1].status == "rejected"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_apply_failure_is_never_reported_as_success(client, respx_mock):
|
|
host_id = await _setup(client, respx_mock, "fw-c7")
|
|
_backup(respx_mock)
|
|
respx_mock.post(_url("/interfaces/vlan_settings/addItem")).mock(
|
|
return_value=httpx.Response(200, json={"result": "saved", "uuid": "x"}))
|
|
respx_mock.post(_url("/interfaces/vlan_settings/reconfigure")).mock(side_effect=httpx.ReadTimeout("slow"))
|
|
token = (await _preview(client, host_id, **VLAN_ADD)).json()["token"]
|
|
resp = await _execute(client, host_id, token, **VLAN_ADD)
|
|
assert resp.status_code == 502 and resp.json()["detail"]["code"] == "NETWORK_APPLY_UNCERTAIN"
|
|
assert resp.json()["detail"]["params"]["backup_id"].endswith(".xml")
|
|
assert (await _audit(host_id))[-1].status == "apply_uncertain"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unknown_field_is_rejected_not_forwarded(client, respx_mock):
|
|
host_id = await _setup(client, respx_mock, "fw-c8")
|
|
resp = await _preview(client, host_id, kind="vlan", action="add", fields={"if": "vtnet1", "tag": "9", "evil": "x"})
|
|
assert resp.status_code == 422 and resp.json()["detail"]["code"] == "NETWORK_FIELD_INVALID"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_changing_the_management_vlan_needs_acknowledgement(client, respx_mock):
|
|
# fw reached on 192.168.1.2 = the LAN VLAN device
|
|
base = "https://192.168.1.2"
|
|
vlans = {"rows": [{"uuid": "v-mgmt", "if": "vtnet1", "tag": "10", "pcp": "0", "descr": "", "vlanif": "vtnet1_vlan10 [LAN]"}]}
|
|
host_id = await _setup(client, respx_mock, "fw-c9", base=base, **{
|
|
"/interfaces/vlan_settings/searchItem": httpx.Response(200, json=vlans)})
|
|
_item(respx_mock, "/interfaces/vlan_settings/getItem/v-mgmt", "vlan",
|
|
{"if": {"vtnet1": {"selected": 1}}, "tag": "10", "descr": "", "vlanif": "vtnet1_vlan10"}, base=base)
|
|
_backup(respx_mock, base)
|
|
respx_mock.post(_url("/interfaces/vlan_settings/setItem/v-mgmt", base)).mock(
|
|
return_value=httpx.Response(200, json={"result": "saved"}))
|
|
respx_mock.post(_url("/interfaces/vlan_settings/reconfigure", base)).mock(return_value=httpx.Response(200, json={"status": "ok"}))
|
|
change = {"kind": "vlan", "action": "set", "uuid": "v-mgmt", "fields": {"descr": "renamed"}}
|
|
plan = (await _preview(client, host_id, **change)).json()
|
|
assert plan["requires_acknowledgement"] is True
|
|
assert plan["risks"][0]["code"] == "management_interface"
|
|
refused = await _execute(client, host_id, plan["token"], **change)
|
|
assert refused.status_code == 409 and refused.json()["detail"]["code"] == "NETWORK_ACK_REQUIRED"
|
|
allowed = await _execute(client, host_id, plan["token"], ack=True, **change)
|
|
assert allowed.status_code == 200, allowed.text
|
|
assert (await _audit(host_id))[0].details["acknowledged"] is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_vip_password_never_leaves_the_server(client, respx_mock):
|
|
host_id = await _setup(client, respx_mock, "fw-c10")
|
|
_item(respx_mock, "/interfaces/vip_settings/getItem/vip1", "vip", {
|
|
"interface": {"lan": {"selected": 1}}, "mode": {"carp": {"selected": 1}}, "network": "192.168.1.1/24",
|
|
"vhid": "10", "password": "STORED-SECRET", "descr": "CARP LAN", "advskew": "0"})
|
|
_backup(respx_mock)
|
|
set_route = respx_mock.post(_url("/interfaces/vip_settings/setItem/vip1")).mock(
|
|
return_value=httpx.Response(200, json={"result": "saved"}))
|
|
respx_mock.get(_url("/diagnostics/interface/getVipStatus")).mock(return_value=httpx.Response(200, json={"rows": [{"vhid": "10", "status": "BACKUP"}]}))
|
|
respx_mock.post(_url("/interfaces/vip_settings/reconfigure")).mock(return_value=httpx.Response(200, json={"status": "ok"}))
|
|
change = {"kind": "vip", "action": "set", "uuid": "vip1", "fields": {"password": "NEW-SECRET", "advskew": "100"}}
|
|
resp = await _preview(client, host_id, **change)
|
|
assert "STORED-SECRET" not in resp.text and "NEW-SECRET" not in resp.text
|
|
plan = resp.json()
|
|
assert {"field": "password", "before": None, "after": "(gesetzt)"} in plan["changes"]
|
|
assert [w["code"] for w in plan["warnings"]] == ["carp_change"]
|
|
done = await _execute(client, host_id, plan["token"], **change)
|
|
assert done.status_code == 200, done.text
|
|
assert "NEW-SECRET" not in done.text
|
|
assert _body(set_route.calls.last)["vip"]["password"] == "NEW-SECRET" # but it does reach the firewall
|
|
for event in await _audit(host_id):
|
|
assert "NEW-SECRET" not in json.dumps(event.details) and "STORED-SECRET" not in json.dumps(event.details)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_default_gateway_and_default_route_need_acknowledgement(client, respx_mock):
|
|
host_id = await _setup(client, respx_mock, "fw-c11")
|
|
_item(respx_mock, "/routing/settings/getGateway/g1", "gateway_item",
|
|
{"name": "WAN_GW", "interface": {"wan": {"selected": 1}}, "gateway": "10.20.30.1", "priority": "255"})
|
|
plan = (await _preview(client, host_id, kind="gateway", action="del", uuid="g1", fields={})).json()
|
|
assert {r["code"] for r in plan["risks"]} >= {"default_gateway", "management_gateway"}
|
|
|
|
route = (await _preview(client, host_id, kind="route", action="add",
|
|
fields={"network": "0.0.0.0/0", "gateway": "WAN_GW"})).json()
|
|
assert [r["code"] for r in route["risks"]] == ["default_route"]
|
|
covers = (await _preview(client, host_id, kind="route", action="add",
|
|
fields={"network": "10.20.0.0/16", "gateway": "WAN_GW"})).json()
|
|
assert [r["code"] for r in covers["risks"]] == ["route_covers_management"]
|
|
harmless = (await _preview(client, host_id, kind="route", action="add",
|
|
fields={"network": "172.16.0.0/16", "gateway": "WAN_GW"})).json()
|
|
assert harmless["risks"] == [] and harmless["requires_acknowledgement"] is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_is_verified_by_absence(client, respx_mock):
|
|
host_id = await _setup(client, respx_mock, "fw-c12")
|
|
_item(respx_mock, "/routes/routes/getroute/r1", "route", {"network": "10.9.0.0/16", "gateway": {"WAN_GW": {"selected": 1}}, "descr": "lab", "enabled": "1"})
|
|
_backup(respx_mock)
|
|
respx_mock.post(_url("/routes/routes/delroute/r1")).mock(return_value=httpx.Response(200, json={"result": "deleted"}))
|
|
respx_mock.post(_url("/routes/routes/reconfigure")).mock(return_value=httpx.Response(200, json={"status": "ok"}))
|
|
# after the delete the route list no longer contains it
|
|
respx_mock.get(_url("/routes/routes/searchroute")).mock(return_value=httpx.Response(200, json={"rows": []}))
|
|
change = {"kind": "route", "action": "del", "uuid": "r1", "fields": {}}
|
|
plan = (await _preview(client, host_id, **change)).json()
|
|
assert plan["risks"] == [] # 10.9.0.0/16 does not touch the management address
|
|
done = await _execute(client, host_id, plan["token"], **change)
|
|
assert done.status_code == 200 and done.json()["verified"] is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_missing_object_is_404(client, respx_mock):
|
|
host_id = await _setup(client, respx_mock, "fw-c13")
|
|
respx_mock.get(_url("/interfaces/vlan_settings/getItem/ghost")).mock(return_value=httpx.Response(200, json={"vlan": {}}))
|
|
resp = await _preview(client, host_id, kind="vlan", action="del", uuid="ghost", fields={})
|
|
assert resp.status_code == 404
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unknown_management_path_is_treated_as_risky(client, respx_mock):
|
|
# The address OPNexus uses belongs to no interface (NAT, or a CARP address): be careful, not optimistic.
|
|
base = "https://10.20.30.99"
|
|
host_id = await _setup(client, respx_mock, "fw-c14", base=base)
|
|
_item(respx_mock, "/routes/routes/getroute/r2", "route", {"network": "172.16.0.0/16", "gateway": {"WAN_GW": {"selected": 1}}, "enabled": "1"}, base=base)
|
|
plan = (await _preview(client, host_id, kind="route", action="set", uuid="r2", fields={"descr": "x"}, )).json()
|
|
assert [r["code"] for r in plan["risks"]] == ["management_unknown"]
|
|
add = (await _preview(client, host_id, kind="route", action="add", fields={"network": "172.17.0.0/16", "gateway": "WAN_GW"})).json()
|
|
assert add["risks"] == [] # a new, harmless object cannot cut anything
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_template_returns_the_firewalls_own_choices(client, respx_mock):
|
|
host_id = await _setup(client, respx_mock, "fw-c15")
|
|
_item(respx_mock, "/routes/routes/getroute", "route", {
|
|
"network": "", "descr": "", "enabled": "1", "secretless": "x",
|
|
"gateway": {"Null4": {"value": "Null4", "selected": 0}, "WAN_GW": {"value": "WAN_GW - 10.20.30.1", "selected": 1}}})
|
|
resp = await client.get(f"/api/hosts/{host_id}/network/template/route")
|
|
assert resp.status_code == 200, resp.text
|
|
body = resp.json()
|
|
assert body["gateway"]["value"] == "WAN_GW"
|
|
assert [o["value"] for o in body["gateway"]["options"]] == ["Null4", "WAN_GW"]
|
|
assert "secretless" not in body # only fields that may be sent
|
|
assert (await client.get(f"/api/hosts/{host_id}/network/template/bogus")).status_code == 404
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_inventory_reads_route_enabled_flag(client, respx_mock):
|
|
# Real 26.7.x route rows say enabled "1"/"0", not disabled.
|
|
rows = {"rows": [{"uuid": "r1", "network": "10.9.0.0/16", "gateway": "WAN_GW", "descr": "on", "enabled": "1"},
|
|
{"uuid": "r2", "network": "10.8.0.0/16", "gateway": "WAN_GW", "descr": "off", "enabled": "0"}]}
|
|
host_id = await _setup(client, respx_mock, "fw-c16", **{"/routes/routes/searchroute": httpx.Response(200, json=rows)})
|
|
routes = (await client.get(f"/api/hosts/{host_id}/network")).json()["routes"]
|
|
assert [(r["network"], r["disabled"]) for r in routes] == [("10.9.0.0/16", False), ("10.8.0.0/16", True)]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_preview_lists_only_meaningful_fields(client, respx_mock):
|
|
host_id = await _setup(client, respx_mock, "fw-c17")
|
|
_item(respx_mock, "/interfaces/vlan_settings/getItem/v9", "vlan",
|
|
{"if": {"vtnet1": {"selected": 1}}, "tag": "40", "descr": "x", "vlanif": "vlan01", "pcp": ""})
|
|
plan = (await _preview(client, host_id, kind="vlan", action="del", uuid="v9", fields={})).json()
|
|
assert {c["field"] for c in plan["changes"]} == {"if", "tag", "descr"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_item_endpoint_returns_current_values_without_secrets(client, respx_mock):
|
|
host_id = await _setup(client, respx_mock, "fw-c18")
|
|
_item(respx_mock, "/interfaces/vip_settings/getItem/vip1", "vip", {
|
|
"interface": {"lan": {"selected": 1}}, "mode": {"carp": {"selected": 1}}, "network": "192.168.1.1/24",
|
|
"vhid": "10", "password": "STORED-SECRET", "address": "192.168.1.1/24", "descr": "CARP LAN"})
|
|
resp = await client.get(f"/api/hosts/{host_id}/network/item/vip/vip1")
|
|
assert resp.status_code == 200, resp.text
|
|
assert "STORED-SECRET" not in resp.text
|
|
assert resp.json() == {"interface": "lan", "mode": "carp", "network": "192.168.1.1/24", "vhid": "10", "descr": "CARP LAN"}
|
|
assert (await client.get(f"/api/hosts/{host_id}/network/item/vip/..%2Fx")).status_code == 404
|
|
|
|
|
|
# ── interface assignment / IP configuration ──────────────────────────────
|
|
|
|
def _pending(respx_mock, status="ok", base=MGMT):
|
|
respx_mock.get(_url("/interfaces/assignment/pending", base)).mock(return_value=httpx.Response(200, json={"status": status}))
|
|
|
|
|
|
IF_ADD = {"kind": "interface", "action": "add", "fields": {"if": "vtnet1_vlan20", "descr": "GUEST", "enable": "1",
|
|
"type4": "staticv4", "ipaddr": "10.20.0.1/24"}}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_interface_add_stages_applies_and_verifies(client, respx_mock):
|
|
host_id = await _setup(client, respx_mock, "fw-i1")
|
|
_pending(respx_mock)
|
|
_backup(respx_mock)
|
|
add = respx_mock.post(_url("/interfaces/assignment/addItem")).mock(
|
|
return_value=httpx.Response(200, json={"result": "saved", "uuid": "opt1"}))
|
|
reconf = respx_mock.post(_url("/interfaces/assignment/reconfigure")).mock(
|
|
return_value=httpx.Response(200, json={"status": "ok"}))
|
|
_item(respx_mock, "/interfaces/assignment/getItem/opt1", "interface", {
|
|
"if": {"vtnet1_vlan20": {"selected": 1}}, "descr": "GUEST", "enable": "1",
|
|
"type4": {"staticv4": {"selected": 1}}, "ipaddr": "10.20.0.1/24"})
|
|
plan = (await _preview(client, host_id, **IF_ADD)).json()
|
|
assert plan["can_execute"] is True and plan["risks"] == []
|
|
done = await _execute(client, host_id, plan["token"], **IF_ADD)
|
|
assert done.status_code == 200, done.text
|
|
assert done.json()["uuid"] == "opt1" and done.json()["verified"] is True
|
|
assert _body(add.calls.last)["interface"]["ipaddr"] == "10.20.0.1/24"
|
|
assert reconf.called
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_interface_changes_are_blocked_on_firewalls_without_the_api(client, respx_mock):
|
|
thin = {"rows": [{"uuid": "wan", "identifier": "wan", "descr": "", "if": "vtnet0"}]} # 26.7.1: no type4
|
|
host_id = await _setup(client, respx_mock, "fw-i2", **{"/interfaces/assignment/searchItem": httpx.Response(200, json=thin)})
|
|
plan = (await _preview(client, host_id, **IF_ADD)).json()
|
|
assert plan["can_execute"] is False
|
|
assert [b["code"] for b in plan["blocks"]] == ["interface_api_unavailable"]
|
|
resp = await _execute(client, host_id, plan["token"], **IF_ADD)
|
|
assert resp.status_code == 409 and resp.json()["detail"]["code"] == "NETWORK_CHANGE_BLOCKED"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_staged_changes_from_someone_else_block_a_new_change(client, respx_mock):
|
|
host_id = await _setup(client, respx_mock, "fw-i3")
|
|
_pending(respx_mock, status="pending")
|
|
plan = (await _preview(client, host_id, **IF_ADD)).json()
|
|
assert [b["code"] for b in plan["blocks"]] == ["interface_changes_pending"]
|
|
add = respx_mock.post(_url("/interfaces/assignment/addItem")).mock(return_value=httpx.Response(200, json={"result": "saved"}))
|
|
assert (await _execute(client, host_id, plan["token"], **IF_ADD)).status_code == 409
|
|
assert not add.called
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_deleting_an_interface_lists_the_rules_that_will_vanish(client, respx_mock):
|
|
host_id = await _setup(client, respx_mock, "fw-i4")
|
|
_pending(respx_mock)
|
|
_item(respx_mock, "/interfaces/assignment/getItem/opt1", "interface", {
|
|
"if": {"vtnet1_vlan20": {"selected": 1}}, "descr": "GUEST", "enable": "1"})
|
|
respx_mock.get(_url("/firewall/filter/searchRule")).mock(return_value=httpx.Response(200, json={"rows": [
|
|
{"uuid": "a", "description": "guest out", "interface": "opt1", "is_automatic": False},
|
|
{"uuid": "b", "description": "multi", "interface": "lan,opt1", "is_automatic": False},
|
|
{"uuid": "c", "description": "other", "interface": "lan", "is_automatic": False},
|
|
{"uuid": "d", "description": "system", "interface": "opt1", "is_automatic": True}]}))
|
|
plan = (await _preview(client, host_id, kind="interface", action="del", uuid="opt1", fields={})).json()
|
|
risk = next(r for r in plan["risks"] if r["code"] == "interface_rules_removed")
|
|
assert risk["params"]["count"] == 2 and "guest out" in risk["params"]["names"] and "other" not in risk["params"]["names"]
|
|
assert plan["requires_acknowledgement"] is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_changing_the_management_interface_needs_acknowledgement(client, respx_mock):
|
|
host_id = await _setup(client, respx_mock, "fw-i5") # reached on 10.20.30.57 = wan
|
|
_pending(respx_mock)
|
|
_item(respx_mock, "/interfaces/assignment/getItem/wan", "interface", {
|
|
"if": {"vtnet0": {"selected": 1}}, "descr": "WAN", "enable": "1", "type4": {"staticv4": {"selected": 1}},
|
|
"ipaddr": "10.20.30.57/24"})
|
|
plan = (await _preview(client, host_id, kind="interface", action="set", uuid="wan", fields={"ipaddr": "10.20.30.77/24"})).json()
|
|
assert [r["code"] for r in plan["risks"]] == ["management_interface"]
|
|
assert plan["requires_acknowledgement"] is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_locked_interface_cannot_be_deleted_and_says_why(client, respx_mock):
|
|
host_id = await _setup(client, respx_mock, "fw-i6")
|
|
_pending(respx_mock)
|
|
_item(respx_mock, "/interfaces/assignment/getItem/opt1", "interface",
|
|
{"if": {"vtnet1_vlan20": {"selected": 1}}, "descr": "GUEST", "enable": "1", "lock": "1"})
|
|
respx_mock.get(_url("/firewall/filter/searchRule")).mock(return_value=httpx.Response(200, json={"rows": []}))
|
|
plan = (await _preview(client, host_id, kind="interface", action="del", uuid="opt1", fields={})).json()
|
|
assert [b["code"] for b in plan["blocks"]] == ["interface_locked"] and plan["can_execute"] is False
|
|
|
|
|
|
# ── DNS / DHCP objects ───────────────────────────────────────────────────
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_kea_subnet_options_are_sent_nested_and_read_back_flat(client, respx_mock):
|
|
host_id = await _setup(client, respx_mock, "fw-d1")
|
|
_backup(respx_mock)
|
|
add = respx_mock.post(_url("/kea/dhcpv4/addSubnet")).mock(return_value=httpx.Response(200, json={"result": "saved", "uuid": "sn1"}))
|
|
respx_mock.post(_url("/kea/service/reconfigure")).mock(return_value=httpx.Response(200, json={"status": "ok"}))
|
|
_item(respx_mock, "/kea/dhcpv4/getSubnet/sn1", "subnet4", {
|
|
"subnet": "192.168.50.0/24", "pools": "192.168.50.100-192.168.50.200", "description": "lab",
|
|
"option_data": {"routers": {"192.168.50.1": {"value": "192.168.50.1", "selected": 1}, "": {"value": "", "selected": 0}},
|
|
"domain_name": "lab.example"}})
|
|
change = {"kind": "kea_subnet", "action": "add", "fields": {
|
|
"subnet": "192.168.50.0/24", "pools": "192.168.50.100-192.168.50.200", "description": "lab",
|
|
"option_data.routers": "192.168.50.1", "option_data.domain_name": "lab.example"}}
|
|
plan = (await _preview(client, host_id, **change)).json()
|
|
assert plan["risks"] == [] # DHCP data cannot cut the connection, even with an unknown management path
|
|
done = await _execute(client, host_id, plan["token"], **change)
|
|
assert done.status_code == 200 and done.json()["verified"] is True, done.text
|
|
assert _body(add.calls.last) == {"subnet4": {
|
|
"subnet": "192.168.50.0/24", "pools": "192.168.50.100-192.168.50.200", "description": "lab",
|
|
"option_data": {"routers": "192.168.50.1", "domain_name": "lab.example"}}}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dns_override_on_unknown_management_path_is_not_treated_as_risky(client, respx_mock):
|
|
host_id = await _setup(client, respx_mock, "fw-d2", base="https://10.20.30.98")
|
|
_item(respx_mock, "/unbound/settings/getHostOverride/h1", "host",
|
|
{"hostname": "nas", "domain": "lan.example", "server": "192.168.1.5", "rr": {"A": {"value": "A", "selected": 1}}},
|
|
base="https://10.20.30.98")
|
|
plan = (await _preview(client, host_id, kind="dns_host", action="set", uuid="h1", fields={"server": "192.168.1.6"})).json()
|
|
assert plan["risks"] == [] and plan["can_execute"] is True
|
|
assert plan["changes"] == [{"field": "server", "before": "192.168.1.5", "after": "192.168.1.6"}]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_template_turns_tokenizer_fields_into_comma_text(client, respx_mock):
|
|
host_id = await _setup(client, respx_mock, "fw-d3")
|
|
_item(respx_mock, "/dnsmasq/settings/getHost", "host", {
|
|
"host": "", "domain": "", "local": "0",
|
|
"ip": {"": {"value": "", "selected": 1}},
|
|
"hwaddr": {"aa:bb:cc:dd:ee:ff": {"value": "aa:bb:cc:dd:ee:ff", "selected": 1}, "11:22:33:44:55:66": {"value": "11:22:33:44:55:66", "selected": 1}}})
|
|
body = (await client.get(f"/api/hosts/{host_id}/network/template/dhcp_host")).json()
|
|
assert body["ip"]["value"] == "" and body["hwaddr"]["value"] == "aa:bb:cc:dd:ee:ff,11:22:33:44:55:66"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unlisted_secret_fields_never_reach_the_audit_log(client, respx_mock):
|
|
host_id = await _setup(client, respx_mock, "fw-d4")
|
|
_backup(respx_mock)
|
|
_item(respx_mock, "/kea/dhcpv4/getSubnet/sn9", "subnet4", {
|
|
"subnet": "192.168.9.0/24", "description": "old", "ddns_domain_key_secret": "KEA-DDNS-SECRET"})
|
|
respx_mock.post(_url("/kea/dhcpv4/setSubnet/sn9")).mock(return_value=httpx.Response(200, json={"result": "saved"}))
|
|
respx_mock.post(_url("/kea/service/reconfigure")).mock(return_value=httpx.Response(200, json={"status": "ok"}))
|
|
change = {"kind": "kea_subnet", "action": "set", "uuid": "sn9", "fields": {"description": "new"}}
|
|
plan = (await _preview(client, host_id, **change)).json()
|
|
assert (await _execute(client, host_id, plan["token"], **change)).status_code == 200
|
|
assert "KEA-DDNS-SECRET" not in json.dumps([e.details for e in await _audit(host_id)])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_vip_description_edit_does_not_reapply_the_vip(client, respx_mock):
|
|
# Re-applying a CARP VIP for a description edit left it in INIT on both prod nodes (2026-10-20).
|
|
host_id = await _setup(client, respx_mock, "fw-v2")
|
|
_item(respx_mock, "/interfaces/vip_settings/getItem/vip1", "vip", {
|
|
"interface": {"lan": {"selected": 1}}, "mode": {"carp": {"selected": 1}}, "network": "192.168.1.50/24",
|
|
"vhid": "12", "advskew": "0", "descr": "old"})
|
|
_backup(respx_mock)
|
|
respx_mock.post(_url("/interfaces/vip_settings/setItem/vip1")).mock(return_value=httpx.Response(200, json={"result": "saved"}))
|
|
reconf = respx_mock.post(_url("/interfaces/vip_settings/reconfigure")).mock(return_value=httpx.Response(200, json={"status": "ok"}))
|
|
change = {"kind": "vip", "action": "set", "uuid": "vip1", "fields": {"descr": "new"}}
|
|
plan = (await _preview(client, host_id, **change)).json()
|
|
done = await _execute(client, host_id, plan["token"], **change)
|
|
assert done.status_code == 200, done.text
|
|
assert not reconf.called
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_vip_edit_reports_a_vip_that_is_not_live(client, respx_mock, monkeypatch):
|
|
async def no_sleep(_):
|
|
return None
|
|
monkeypatch.setattr("app.services.network_change_service.asyncio.sleep", no_sleep)
|
|
host_id = await _setup(client, respx_mock, "fw-v3")
|
|
_item(respx_mock, "/interfaces/vip_settings/getItem/vip1", "vip", {
|
|
"interface": {"lan": {"selected": 1}}, "mode": {"carp": {"selected": 1}}, "network": "192.168.1.50/24",
|
|
"vhid": "13", "advskew": "0", "descr": "x"})
|
|
_backup(respx_mock)
|
|
respx_mock.post(_url("/interfaces/vip_settings/setItem/vip1")).mock(return_value=httpx.Response(200, json={"result": "saved"}))
|
|
reconf = respx_mock.post(_url("/interfaces/vip_settings/reconfigure")).mock(return_value=httpx.Response(200, json={"status": "ok"}))
|
|
respx_mock.get(_url("/diagnostics/interface/getVipStatus")).mock(
|
|
return_value=httpx.Response(200, json={"rows": [{"vhid": "13", "status": "DISABLED"}]}))
|
|
change = {"kind": "vip", "action": "set", "uuid": "vip1", "fields": {"advskew": "20"}}
|
|
plan = (await _preview(client, host_id, **change)).json()
|
|
done = await _execute(client, host_id, plan["token"], **change)
|
|
assert done.status_code == 200, done.text
|
|
assert done.json()["verified"] is False and "carp_vip_inactive" in done.json()["mismatches"]
|
|
assert reconf.call_count == 2 # one retry
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_vip_edit_always_repeats_the_address(client, respx_mock):
|
|
# OPNsense blanks a VIP's address when an update leaves out "network" (seen live on 26.7.6).
|
|
host_id = await _setup(client, respx_mock, "fw-v1")
|
|
_item(respx_mock, "/interfaces/vip_settings/getItem/vip1", "vip", {
|
|
"interface": {"lan": {"selected": 1}}, "mode": {"carp": {"selected": 1}}, "network": "192.168.1.50/24",
|
|
"vhid": "11", "advskew": "0", "descr": "x"})
|
|
_backup(respx_mock)
|
|
put = respx_mock.post(_url("/interfaces/vip_settings/setItem/vip1")).mock(return_value=httpx.Response(200, json={"result": "saved"}))
|
|
respx_mock.get(_url("/diagnostics/interface/getVipStatus")).mock(return_value=httpx.Response(200, json={"rows": [{"vhid": "11", "status": "BACKUP"}]}))
|
|
respx_mock.post(_url("/interfaces/vip_settings/reconfigure")).mock(return_value=httpx.Response(200, json={"status": "ok"}))
|
|
change = {"kind": "vip", "action": "set", "uuid": "vip1", "fields": {"advskew": "20"}}
|
|
plan = (await _preview(client, host_id, **change)).json()
|
|
done = await _execute(client, host_id, plan["token"], **change)
|
|
assert done.status_code == 200, done.text
|
|
# ...and the interface: OPNsense queues a delete of the live address when the posted interface differs
|
|
# from the stored one, and the reconfigure then drops the VIP (seen live 2026-10-20).
|
|
assert _body(put.calls.last)["vip"] == {"advskew": "20", "network": "192.168.1.50/24", "interface": "lan"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_verification_catches_a_field_that_changed_without_being_asked(client, respx_mock):
|
|
# The firewall dropped an untouched field while saving: that must not be reported as a clean success.
|
|
host_id = await _setup(client, respx_mock, "fw-v2")
|
|
state = iter([
|
|
{"descr": "old", "network": "172.16.0.0/16", "gateway": {"WAN_GW": {"selected": 1}}, "enabled": "1"}, # preview (before)
|
|
{"descr": "old", "network": "172.16.0.0/16", "gateway": {"WAN_GW": {"selected": 1}}, "enabled": "1"}, # execute re-preview
|
|
{"descr": "new", "network": "", "gateway": {"WAN_GW": {"selected": 1}}, "enabled": "1"}, # read-back: network wiped
|
|
])
|
|
respx_mock.get(_url("/routes/routes/getroute/r5")).mock(side_effect=lambda req: httpx.Response(200, json={"route": next(state)}))
|
|
_backup(respx_mock)
|
|
respx_mock.post(_url("/routes/routes/setroute/r5")).mock(return_value=httpx.Response(200, json={"result": "saved"}))
|
|
respx_mock.post(_url("/routes/routes/reconfigure")).mock(return_value=httpx.Response(200, json={"status": "ok"}))
|
|
change = {"kind": "route", "action": "set", "uuid": "r5", "fields": {"descr": "new"}}
|
|
plan = (await _preview(client, host_id, **change)).json()
|
|
done = (await _execute(client, host_id, plan["token"], **change)).json()
|
|
assert done["verified"] is False and done["mismatches"] == ["network"]
|
|
|
|
|
|
# ── generic object kinds ─────────────────────────────────────────────────
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_objects_list_returns_only_the_display_columns(client, respx_mock):
|
|
host_id = await _setup(client, respx_mock, "fw-o1")
|
|
respx_mock.get(_url("/syslog/settings/searchDestinations")).mock(return_value=httpx.Response(200, json={"rows": [
|
|
{"uuid": "d1", "enabled": "1", "transport": "udp4", "hostname": "192.0.2.10", "port": "514", "program": "", "level": "info",
|
|
"facility": "daemon", "certificate": "SECRET-CERT-REF", "description": "central"}]}))
|
|
body = (await client.get(f"/api/hosts/{host_id}/network/objects/syslog_destination")).json()
|
|
assert body["available"] is True and body["rows"][0]["hostname"] == "192.0.2.10"
|
|
assert "certificate" not in body["rows"][0] and "facility" not in body["rows"][0]
|
|
assert (await client.get(f"/api/hosts/{host_id}/network/objects/vlan")).status_code == 404 # typed kinds have their own lists
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_objects_list_reports_a_missing_api_instead_of_failing(client, respx_mock):
|
|
host_id = await _setup(client, respx_mock, "fw-o2")
|
|
respx_mock.get(_url("/interfaces/gre_settings/searchItem")).mock(return_value=httpx.Response(404, json={"errorMessage": "Endpoint not found"}))
|
|
body = (await client.get(f"/api/hosts/{host_id}/network/objects/gre")).json()
|
|
assert body == {"available": False, "columns": body["columns"], "rows": []}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tunables_always_need_acknowledgement_and_defaults_cannot_be_edited(client, respx_mock):
|
|
host_id = await _setup(client, respx_mock, "fw-o3")
|
|
add = (await _preview(client, host_id, kind="tunable", action="add", fields={"tunable": "vfs.read_max", "value": "128"})).json()
|
|
assert [r["code"] for r in add["risks"]] == ["tunable_change"] and add["requires_acknowledgement"] is True
|
|
_item(respx_mock, "/core/tunables/getItem/0add3c58db981d0d24d5d4ab322e3695", "sysctl",
|
|
{"tunable": "net.inet.tcp.drop_synfin", "value": "1", "descr": "built in"})
|
|
default = (await _preview(client, host_id, kind="tunable", action="set", uuid="0add3c58db981d0d24d5d4ab322e3695", fields={"value": "0"})).json()
|
|
assert [b["code"] for b in default["blocks"]] == ["tunable_default_row"] and default["can_execute"] is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_lagg_or_bridge_containing_the_management_device_needs_acknowledgement(client, respx_mock):
|
|
host_id = await _setup(client, respx_mock, "fw-o4") # reached on vtnet0
|
|
mine = (await _preview(client, host_id, kind="lagg", action="add", fields={"members": "vtnet0,vtnet2", "proto": "failover"})).json()
|
|
assert [r["code"] for r in mine["risks"]] == ["management_member"]
|
|
other = (await _preview(client, host_id, kind="lagg", action="add", fields={"members": "vtnet2,vtnet3", "proto": "failover"})).json()
|
|
assert other["risks"] == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_vxlan_and_gif_are_listed_and_changed_like_the_other_devices(client, respx_mock):
|
|
host_id = await _setup(client, respx_mock, "fw-o5")
|
|
respx_mock.get(_url("/interfaces/vxlan_settings/searchItem")).mock(return_value=httpx.Response(200, json={"rows": [
|
|
{"uuid": "x1", "vxlanif": "vxlan1", "vxlanid": "100", "vxlanlocal": "10.20.30.57", "vxlanremote": "10.20.30.58",
|
|
"vxlangroup": "", "vxlandev": "lan", "secret_like": "dropped"}]}))
|
|
body = (await client.get(f"/api/hosts/{host_id}/network/objects/vxlan")).json()
|
|
assert body["rows"][0]["vxlanid"] == "100" and "secret_like" not in body["rows"][0]
|
|
_backup(respx_mock)
|
|
add = respx_mock.post(_url("/interfaces/gif_settings/addItem")).mock(return_value=httpx.Response(200, json={"result": "saved", "uuid": "g1"}))
|
|
respx_mock.post(_url("/interfaces/gif_settings/reconfigure")).mock(return_value=httpx.Response(200, json={"status": "ok"}))
|
|
_item(respx_mock, "/interfaces/gif_settings/getItem/g1", "gif", {"local-addr": "10.20.30.57", "remote-addr": "198.51.100.9", "descr": "t"})
|
|
change = {"kind": "gif", "action": "add", "fields": {"local-addr": "10.20.30.57", "remote-addr": "198.51.100.9", "descr": "t"}}
|
|
plan = (await _preview(client, host_id, **change)).json()
|
|
done = await _execute(client, host_id, plan["token"], **change)
|
|
assert done.status_code == 200 and done.json()["verified"] is True, done.text
|
|
assert _body(add.calls.last) == {"gif": change["fields"]}
|