- **Konfiguration als Code:** Unter **System › Konfiguration als Code** (und per `opnexus export` / `opnexus apply`) lassen sich Gerätegruppen, Hierarchiegruppen, Aliase, Filterregeln, Port-Forwards, 1:1-NAT, Outbound-NAT, Hierarchie-Regeln und Standort-Variablen als **eine YAML-Datei exportieren** (sortiert, ohne Zeitstempel, ohne Zugänge oder Schlüssel, für Git und Reviews) und wieder **importieren**: erst ein **Plan** (neu, geändert, entfernt, unverändert, blockiert, mit Hinweisen), dann das **Übernehmen** durch einen Administrator in einer Transaktion mit dem Token genau dieses Plans. **Der Import ändert nur die Entwürfe in OPNexus, nie eine Firewall**; ausgerollt wird wie immer über Vorschau → Ausrollen → Bestätigen. Ausgerollte oder noch verwendete Objekte entfernt er nie (Prune blockiert den Plan). Die Datei wird nur als Daten gelesen (keine Anker, Tags, Merge-Schlüssel, Mehrfach-Dokumente; 1 MiB, strenges Schema mit Zeile und Pfad bei Fehlern). Plan und Export gehen auch mit „Lesen“-Token und Nur-Lese-Konten, `opnexus apply --dry-run` liefert Exit-Codes für CI (0 / 2 / 3 / 4). Regeln und NAT-Objekte tragen dafür einen stabilen `key` (Migration, wird für Bestand automatisch vergeben). Gruppenänderungen stehen jetzt im Audit. - **Rückweg zur vorherigen Firmware:** Vor jedem Firmware-Update und Major-Upgrade (manuell, geplant, Rollout, CARP-Übergabe) legt OPNexus auf der Firewall ein **ZFS-Boot-Environment** `opnexus-pre-<Version>-<Zeit>` als Rückweg-Punkt an (ohne den Punkt startet das Update nicht; ohne ZFS mit Hinweis). Neue Aktion **Zur vorherigen Firmware** (Host-Wartung): Vorschau, Hostname, Downtime-Bestätigung, vorher lokale Sicherung der Konfiguration, dann Aktivieren, Prüfung und Neustart (scheitert ein Schritt, wird das vorherige Boot-Environment wieder eingeschaltet). **Der Rückweg setzt auch die Konfiguration zurück** (live belegt): Ausrollvorgänge seit dem Punkt werden als unklar markiert, damit das nächste Ausrollen wirklich schreibt. Ein Rollout macht nie von selbst einen Rückweg. - **Rollout-Gate: Erreichbarkeitsproben.** Bis zu zehn Ping-/TCP-Proben je Plan, von der Firewall selbst ausgeführt, vor der Welle und nach der Beobachtungszeit: vorher ok und jetzt nicht mehr hält den Rollout an, schon vorher gestört ist nur ein Hinweis. (Erreichbarkeit von der Firewall aus, nicht der Verkehr durch sie hindurch.) - Migrationen 0030 (`config_key`) und 0031 (`gate_probes`).
130 lines
4.9 KiB
Python
130 lines
4.9 KiB
Python
"""Export (1.32 P3): deterministic, complete, schema-conform, and free of anything secret."""
|
|
import re
|
|
|
|
import pytest
|
|
import yaml
|
|
|
|
from app.config_code import schema
|
|
from app.config_code.export import canonical, dump_yaml, export_yaml
|
|
from app.database import SessionLocal
|
|
from app.models.rule import FirewallRule
|
|
from tests.config_code_seed import seed_everything
|
|
|
|
GOLDEN = '''\
|
|
# OPNexus-Konfiguration (Schema v1), exportiert von OPNexus {version}.
|
|
# Enthält keine Zugänge zu Firewalls, Benutzer, Tokens oder Schlüssel. Prüfen: opnexus apply DATEI --dry-run
|
|
opnexus_config: 1
|
|
device_groups:
|
|
- name: Filialen
|
|
description: alle Filialen
|
|
hosts:
|
|
- fw-filiale-1
|
|
- fw-filiale-2
|
|
policy_hierarchy_groups:
|
|
- name: Organisation
|
|
parent: null
|
|
description: ''
|
|
hosts: []
|
|
- name: Nord
|
|
parent: Organisation
|
|
description: Region Nord
|
|
hosts:
|
|
- fw-filiale-1
|
|
aliases:
|
|
- name: WEB_SERVERS
|
|
type: host
|
|
description: DMZ
|
|
enabled: true
|
|
entries:
|
|
- 10.20.30.10
|
|
- 203.0.113.20
|
|
targets:
|
|
hosts: []
|
|
groups:
|
|
- Filialen
|
|
hierarchy_group: null
|
|
'''
|
|
|
|
|
|
async def _export(**kw) -> str:
|
|
async with SessionLocal() as db:
|
|
return await export_yaml(db, **kw)
|
|
|
|
|
|
async def test_export_starts_with_the_fixed_header_and_the_expected_sections():
|
|
from app.version import APP_VERSION
|
|
|
|
await seed_everything()
|
|
text = await _export()
|
|
assert text.startswith(GOLDEN.format(version=APP_VERSION))
|
|
assert [k for k in yaml.safe_load(text) if k != "opnexus_config"] == list(schema.KINDS)
|
|
assert text.endswith("\n") and "\r" not in text
|
|
|
|
|
|
async def test_export_is_deterministic_and_validates_against_the_schema():
|
|
await seed_everything()
|
|
first, second = await _export(), await _export()
|
|
assert first == second
|
|
doc = schema.ConfigDoc.model_validate(yaml.safe_load(first)) # strict: unknown fields, wrong types, control chars
|
|
assert [r.key for r in doc.rules] == ["allow-dns-lan", "block-all"]
|
|
assert doc.variables == {"fw-filiale-1": {"LAN_NET": "10.20.30.0/24", "SITE": "Filiale Nord"}}
|
|
|
|
|
|
async def test_hierarchy_rules_are_ordered_by_group_rulebase_and_sequence_and_keep_placeholders_unresolved():
|
|
await seed_everything()
|
|
data = yaml.safe_load(await _export())
|
|
assert [r["key"] for r in data["policy_hierarchy_rules"]] == ["ph-block-bogons", "ph-allow-mgmt", "ph-post-deny"]
|
|
rule = next(r for r in data["rules"] if r["key"] == "allow-dns-lan")
|
|
assert rule["source_net"] == "{{LAN_NET}}" and rule["destination_port"] == "53"
|
|
|
|
|
|
async def test_ports_are_written_as_strings_so_they_survive_the_round_trip():
|
|
await seed_everything()
|
|
text = await _export()
|
|
assert re.search(r"destination_port: '53'", text)
|
|
assert schema.ConfigDoc.model_validate(yaml.safe_load(text)).rules[0].destination_port == "53"
|
|
|
|
|
|
async def test_export_never_contains_secrets_or_connection_data():
|
|
await seed_everything()
|
|
text = (await _export()).lower()
|
|
for forbidden in ("akia-secret-key", "secret-host.example", "https://", "api_key", "api_secret", "private key", "opx_", "password"):
|
|
assert forbidden not in text
|
|
|
|
|
|
async def test_kinds_limits_the_sections():
|
|
await seed_everything()
|
|
data = yaml.safe_load(await _export(kinds={"aliases", "rules"}))
|
|
assert list(data) == ["opnexus_config", "aliases", "rules"]
|
|
|
|
|
|
async def test_new_objects_get_a_key_and_an_empty_database_exports_empty_sections():
|
|
async with SessionLocal() as db:
|
|
rule = FirewallRule(description="x", interface="lan", direction="in")
|
|
db.add(rule)
|
|
await db.commit()
|
|
assert re.fullmatch(r"r-[0-9a-f]{12}", rule.config_key) and rule.config_key == f"r-{rule.id.hex[:12]}"
|
|
data = yaml.safe_load(await _export(kinds={"aliases", "variables"}))
|
|
assert data["aliases"] == [] and data["variables"] == {}
|
|
|
|
|
|
async def test_exporter_field_lists_match_the_schema():
|
|
"""A field added to the schema but not to the exporter (or vice versa) must fail loudly, not export silently wrong."""
|
|
await seed_everything()
|
|
async with SessionLocal() as db:
|
|
content = await canonical(db)
|
|
for kind, doc in (("rules", schema.RuleDoc), ("port_forwards", schema.PortForwardDoc), ("one_to_one", schema.OneToOneDoc),
|
|
("outbound_nat", schema.OutboundNatDoc), ("policy_hierarchy_rules", schema.HierarchyRuleDoc),
|
|
("aliases", schema.AliasDoc), ("device_groups", schema.DeviceGroupDoc),
|
|
("policy_hierarchy_groups", schema.HierarchyGroupDoc)):
|
|
assert all(list(item) == list(doc.model_fields) for item in content[kind]), kind
|
|
assert dump_yaml(content) == await _export()
|
|
|
|
|
|
async def test_objects_are_sorted_by_key_not_by_insertion_order():
|
|
async with SessionLocal() as db:
|
|
for key in ("zz-last", "aa-first", "mm-middle"):
|
|
db.add(FirewallRule(config_key=key, description=key, interface="lan", direction="in"))
|
|
await db.commit()
|
|
assert [r["key"] for r in yaml.safe_load(await _export())["rules"]] == ["aa-first", "mm-middle", "zz-last"]
|