mirror of
https://github.com/Av0idD4rk/firegex.git
synced 2026-10-23 15:30:59 +00:00
A backup taken within a second of a block missed it. The backup test also snapshotted while NFQUEUE blocks were still being reported, which failed the 5.0.0 release run; it now waits until two reads agree.
237 lines
11 KiB
Python
237 lines
11 KiB
Python
"""Export and import: what a backup brings back, and the two things it must never carry.
|
|
|
|
A backup is configuration, and restoring it has to bring that configuration back as it
|
|
was — services, addresses, chains, patterns, the Python files beside the database, the
|
|
firewall's rules — not merely be accepted. The password is not configuration — importing one must not
|
|
lock the current session out — and neither is *whether a password is asked for*, which
|
|
belongs to where firegex is deployed. A backup able to carry that second one is a backup
|
|
able to turn a firewall's authentication off.
|
|
"""
|
|
|
|
import time
|
|
|
|
import pytest
|
|
|
|
from integration.conftest import SETTLE, add_python_filter, add_regex_filter, start_and_settle
|
|
from helpers.firegexapi import FiregexAPI
|
|
from helpers.traffic import Channel
|
|
|
|
pytestmark = pytest.mark.instance
|
|
|
|
|
|
def test_a_backup_round_trips(api):
|
|
backup = api.export_backup()
|
|
assert backup, "nothing came back from the export"
|
|
assert api.import_backup(backup)
|
|
|
|
|
|
def test_the_password_survives_an_import(api, fg_address, fg_password, auth_enabled):
|
|
if not auth_enabled:
|
|
pytest.skip("there is no password to survive anything on this instance")
|
|
assert api.import_backup(api.export_backup())
|
|
assert api.status()["loggined"] is True, "the importing session was logged out"
|
|
assert FiregexAPI(fg_address).login(fg_password), "the password was lost or changed"
|
|
|
|
|
|
def test_whether_authentication_is_asked_for_survives_an_import(api, auth_enabled):
|
|
"""Turned off *around* the import on purpose.
|
|
|
|
Asserting it is still on afterwards would pass either way on a container whose
|
|
environment agrees with the running state. The bug only shows where the two disagree,
|
|
which is exactly what a runtime change is: the key lives in `keys_values`, so it rode
|
|
along in the dump, and the reload at the end of the import re-seeded it from the
|
|
container's environment — reverting a `run.py config` made hours earlier.
|
|
"""
|
|
if not auth_enabled:
|
|
pytest.skip("this instance is already running with authentication off, so there "
|
|
"is no disagreement between the running state and the environment")
|
|
|
|
assert api.set_auth_mode(True), "could not turn authentication off"
|
|
try:
|
|
assert api.import_backup(api.export_backup()), \
|
|
"could not import a backup with authentication off"
|
|
assert api.status()["auth_disabled"] is True, \
|
|
"the backup import turned authentication back on"
|
|
finally:
|
|
# Back on with the session signed before it went off, which is what the rest of
|
|
# the suite expects.
|
|
assert api.set_auth_mode(False)
|
|
assert api.status()["auth_disabled"] is False
|
|
|
|
|
|
# --- what a backup brings back ------------------------------------------------------
|
|
#
|
|
# Everything above checks what an import must *not* touch. These check what it is for:
|
|
# that a configuration exported, then lost, comes back as it was — not only accepted.
|
|
|
|
|
|
#: Two functions in one file, so that a function switched off can be told apart from one
|
|
#: that was never there: the switch lives in the database, the code on disk, and a backup
|
|
#: has to bring both back for the filter to mean what it meant.
|
|
TWO_FUNCTIONS = """from firegex.pyfilters import pyfilter, ACCEPT, REJECT
|
|
from firegex.pyfilters.models import RawPacket
|
|
|
|
|
|
@pyfilter
|
|
def refuse_marker(packet: RawPacket):
|
|
return REJECT if b"PYBLOCK" in packet.data else ACCEPT
|
|
|
|
|
|
@pyfilter
|
|
def switched_off(packet: RawPacket):
|
|
return REJECT if b"OFFBLOCK" in packet.data else ACCEPT
|
|
"""
|
|
|
|
|
|
def _snapshot(api, service_id: str) -> dict:
|
|
"""Everything the API says about a service and its chain, code included."""
|
|
service = dict(api.services_get(service_id))
|
|
# The live log is the process's, not the configuration's: a deleted service's goes
|
|
# with it, and a restored one starts a new one.
|
|
service.pop("problem", None)
|
|
filters = api.services_filters(service_id)
|
|
chain = []
|
|
for flt in filters:
|
|
entry = {"filter": flt}
|
|
if flt["kind"] == "regex":
|
|
entry["regexes"] = sorted(api.services_regexes(service_id, flt["filter_id"]),
|
|
key=lambda r: r["regex_id"])
|
|
else:
|
|
entry["functions"] = api.services_functions(service_id, flt["filter_id"])
|
|
entry["code"] = api.services_get_code(service_id, flt["filter_id"])
|
|
chain.append(entry)
|
|
return {"service": service, "addresses": api.services_addresses(service_id),
|
|
"chain": chain}
|
|
|
|
|
|
def _filters_as_configured(channel) -> None:
|
|
assert channel.gets_through(b"harmless"), "the service is not answering"
|
|
assert channel.is_blocked(b"carrying RESTORE_RX"), "the pattern is not blocking"
|
|
assert channel.is_blocked(b"carrying PYBLOCK"), "the Python filter is not blocking"
|
|
assert channel.gets_through(b"carrying OFFBLOCK"), \
|
|
"a function switched off is running"
|
|
|
|
|
|
def test_a_deleted_service_comes_back_whole_and_filtering(api, protected, filtering_layer):
|
|
"""Export, lose the service, import: it has to be the same service again.
|
|
|
|
Same id, same addresses, same chain in the same order, the same patterns and code,
|
|
the same functions switched off, the certificate a TLS service needs to start — and
|
|
running and filtering, because it was when the backup was taken. The code is the half
|
|
most easily lost: it is a file beside the database, deleted with the service, and
|
|
nothing but the backup's own entry for it brings it back.
|
|
"""
|
|
service_id, server, port = protected(filtering_layer, name="restore")
|
|
add_regex_filter(api, service_id, "RESTORE_RX", name="patterns")
|
|
filter_id = add_python_filter(api, service_id, TWO_FUNCTIONS, name="python")
|
|
assert api.services_edit_function(service_id, filter_id, "switched_off", False)
|
|
start_and_settle(api, service_id)
|
|
channel = Channel(server, port, filtering_layer.ipv6, filtering_layer.tls)
|
|
_filters_as_configured(channel)
|
|
|
|
# Read through the services API first, which writes the counters still waiting: what
|
|
# is compared afterwards includes them. Until two reads agree, because on NFQUEUE a
|
|
# block reaches the backend after the client has already seen the refusal — one
|
|
# counted between this snapshot and the export failed the 5.0.0 release run.
|
|
before = _snapshot(api, service_id)
|
|
for _ in range(10):
|
|
time.sleep(SETTLE)
|
|
before, settled = _snapshot(api, service_id), before
|
|
if before == settled:
|
|
break
|
|
backup = api.export_backup()
|
|
|
|
assert api.services_delete(service_id)
|
|
assert service_id not in [s["service_id"] for s in api.services_list()]
|
|
|
|
assert api.import_backup(backup), "the backup was not imported"
|
|
time.sleep(SETTLE)
|
|
|
|
after = _snapshot(api, service_id)
|
|
assert after["service"]["status"] == "active", \
|
|
"a service running when the backup was taken did not come back running"
|
|
assert after == before, "the restored service is not the one that was exported"
|
|
_filters_as_configured(channel)
|
|
|
|
|
|
def test_firewall_rules_and_policy_come_back(api):
|
|
"""The firewall's rules and policy live in a database of their own, beside the
|
|
services', and travel in the same backup."""
|
|
was = api.firewall_rules()
|
|
if was.get("enabled"):
|
|
pytest.skip("the firewall is enabled on this instance, and rewriting its rules "
|
|
"here would change what the host accepts")
|
|
rules = [
|
|
{"active": True, "name": "ssh from the lab", "proto": "tcp", "table": "filter",
|
|
"src": "10.0.0.0/8", "dst": "", "port_src_from": 1, "port_src_to": 65535,
|
|
"port_dst_from": 22, "port_dst_to": 22, "action": "accept", "mode": "in"},
|
|
{"active": False, "name": "dns out", "proto": "udp", "table": "filter",
|
|
"src": "", "dst": "eth0", "port_src_from": 1, "port_src_to": 65535,
|
|
"port_dst_from": 53, "port_dst_to": 53, "action": "drop", "mode": "out"},
|
|
]
|
|
try:
|
|
assert api.firewall_set_rules(rules, "accept")
|
|
configured = api.firewall_rules()
|
|
backup = api.export_backup()
|
|
|
|
assert api.firewall_set_rules([], "reject")
|
|
assert api.firewall_rules()["rules"] == []
|
|
|
|
assert api.import_backup(backup), "the backup was not imported"
|
|
restored = api.firewall_rules()
|
|
assert restored == configured, "the firewall came back different from the backup"
|
|
assert restored["enabled"] is False
|
|
finally:
|
|
api.firewall_set_rules(was["rules"], was["policy"])
|
|
|
|
|
|
def test_a_backup_that_cannot_be_loaded_changes_nothing(api, fg_address, fg_password,
|
|
auth_enabled):
|
|
"""Every database is tried before any is replaced.
|
|
|
|
They used to be loaded one after another, so a refusal from one left the ones before
|
|
it replaced and the rest of the import never ran. With `firegex.db` first — the order
|
|
an export lists them in — its `keys_values` were replaced and this instance's password
|
|
and secret never put back: the instance came back asking the next visitor to choose a
|
|
password. Found by the firewall's own rules, which no import could load at all.
|
|
"""
|
|
before = api.export_backup()
|
|
broken = {
|
|
"firegex.db": {"keys_values": before["firegex.db"]["keys_values"]},
|
|
"services.db": {"services": [{"service_id": "x", "name": "x", "status": "bogus",
|
|
"proto": "tcp", "transport": "proxy"}]},
|
|
}
|
|
why = api.s.post(f"{api.address}api/import", json=broken)
|
|
assert why.status_code == 400, why.text
|
|
assert "nothing was imported" in why.json().get("detail", ""), why.text
|
|
|
|
assert api.status()["status"] == "run", "the instance lost its password to the import"
|
|
if auth_enabled:
|
|
assert api.status()["loggined"] is True, "the importing session was logged out"
|
|
assert FiregexAPI(fg_address).login(fg_password), "the password was lost or changed"
|
|
after = api.export_backup()
|
|
assert after["services.db"] == before["services.db"], "the services were changed"
|
|
|
|
|
|
def test_an_import_leaves_no_code_behind_from_after_the_backup(api, service):
|
|
"""The database is replaced whole and the backup's filter files written, and the files
|
|
the backup did not have used to stay: the code of every Python filter created since,
|
|
on disk for filters that no longer existed after the import."""
|
|
from helpers.net import free_port
|
|
|
|
backup = api.export_backup()
|
|
port = free_port()
|
|
service_id = service(f"orphan-{port}", "127.0.0.1", port, "proxy")
|
|
filter_id = add_python_filter(api, service_id, TWO_FUNCTIONS, name="python")
|
|
assert f"{filter_id}.py" in api.export_backup().get("service_filters", {})
|
|
|
|
assert api.import_backup(backup), "the backup was not imported"
|
|
restored = api.export_backup()
|
|
files = set(restored.get("service_filters", {}))
|
|
assert f"{filter_id}.py" not in files, "the code of a filter the backup never had stayed"
|
|
used = {f"{row['filter_id']}.py" for row in restored["services.db"].get("filters", [])
|
|
if row["kind"] == "pyfilter"}
|
|
assert files <= used, f"filter files nothing refers to: {sorted(files - used)}"
|
|
kept = set(backup.get("service_filters", {})) & used
|
|
assert kept <= files, "the code of a filter in the backup was lost"
|