mirror of
https://github.com/Av0idD4rk/firegex.git
synced 2026-10-23 21:42:05 +00:00
272 lines
12 KiB
Python
272 lines
12 KiB
Python
"""What the backend actually hands each datapath, and how a block comes back attributed.
|
|
|
|
Two wire formats, two engines, one operator-visible promise: whichever layer a service is
|
|
on, a block names the rule that made it. Getting the encoding wrong is not a crash — it is
|
|
a pattern that never matches, or a block credited to nothing, and both of those look from
|
|
the outside like a filter that is simply switched off.
|
|
|
|
None of this had a unit test: it is exercised only end to end, so a mistake here shows up
|
|
on a machine with a live instance, as traffic, rather than here.
|
|
"""
|
|
|
|
import pytest
|
|
|
|
from modules.services.models import KIND, PROTO, Filter, Regex, Service, TRANSPORT
|
|
from modules.services.transports import ChainLink, _QueueStage, announcement
|
|
|
|
|
|
def regex(regex_id: str, pattern: bytes, mode: str = "B", case_sensitive: bool = True,
|
|
active: bool = True) -> Regex:
|
|
return Regex(regex_id=regex_id, filter_id="f1", regex=pattern, mode=mode,
|
|
case_sensitive=case_sensitive, active=active)
|
|
|
|
|
|
def a_filter(kind: str = KIND.REGEX, active: bool = True, proto: str = PROTO.TCP) -> Filter:
|
|
return Filter(filter_id="f1", service_id="s", position=0, kind=kind, name="p",
|
|
active=active, proto=proto)
|
|
|
|
|
|
@pytest.fixture
|
|
def stage():
|
|
"""A stage that has never been started: only its payload building is being asked."""
|
|
srv = Service(service_id="s", name="n", status="stop", proto="tcp",
|
|
transport=TRANSPORT.NFQUEUE)
|
|
return _QueueStage(srv, ChainLink(a_filter()), owner=None)
|
|
|
|
|
|
# --- what cppregex is told ----------------------------------------------------
|
|
# `<case><direction><hex>`, space separated. A pattern is bytes, and hex is the only
|
|
# encoding both ends agree on without quoting rules.
|
|
|
|
|
|
def test_a_pattern_is_sent_as_case_direction_and_hex(stage):
|
|
assert stage._regex_payload([regex("r1", b"FLAG", mode="C")]) == b"1C464c4147\n"
|
|
|
|
|
|
def test_case_insensitivity_is_the_leading_digit(stage):
|
|
assert stage._regex_payload(
|
|
[regex("r1", b"x", mode="C", case_sensitive=False)]) == b"0C78\n"
|
|
|
|
|
|
def test_a_pattern_matched_both_ways_is_sent_once_per_direction(stage):
|
|
"""And **both codes attribute back to the one rule**.
|
|
|
|
The engine reports whichever code matched; if the map held only one of them, a block
|
|
in the other direction would arrive with nothing to credit it to — which the operator
|
|
sees as a counter that never moves on a rule that is plainly working.
|
|
"""
|
|
payload = stage._regex_payload([regex("r1", b"FLAG", mode="B")])
|
|
assert payload == b"1C464c4147 1S464c4147\n"
|
|
assert stage._codes == {"1C464c4147": "r1", "1S464c4147": "r1"}
|
|
|
|
|
|
def test_a_pattern_switched_off_is_not_sent(stage):
|
|
payload = stage._regex_payload([regex("r1", b"on", mode="C"),
|
|
regex("r2", b"off", mode="C", active=False)])
|
|
assert b"6f6666" not in payload, "the disabled pattern was sent anyway"
|
|
assert "r2" not in stage._codes.values()
|
|
|
|
|
|
def test_an_empty_filter_is_still_a_valid_payload(stage):
|
|
"""A service whose patterns are all off keeps its datapath: the operator turned the
|
|
rules off, not the service, and turning one back on must not need a restart."""
|
|
assert stage._regex_payload([]) == b"\n"
|
|
|
|
|
|
def test_a_pattern_can_be_bytes_that_are_not_text(stage):
|
|
"""Matching a raw byte sequence is a normal thing to want, which is why patterns are
|
|
stored base64 and sent as hex."""
|
|
assert stage._regex_payload(
|
|
[regex("r1", bytes([0x00, 0xFF]), mode="C")]) == b"1C00ff\n"
|
|
|
|
|
|
# --- which of a file's functions run ------------------------------------------
|
|
# Three states, distinct all the way down to the worker's argv: absent means every
|
|
# function the file defines, a list means exactly those, and an empty list means the
|
|
# operator switched them all off. Collapsing two of them either runs filters that were
|
|
# turned off or stops running filters nobody touched.
|
|
|
|
|
|
def test_a_filter_whose_functions_were_never_derived_runs_all_of_them():
|
|
"""No rows at all is a filter predating the per-function switches, or one whose code
|
|
did not parse when it was saved. Defaulting to "none" there would silently stop every
|
|
filter in the file."""
|
|
assert ChainLink(a_filter(), functions=[]).enabled_functions is None
|
|
assert ChainLink(a_filter()).enabled_functions is None
|
|
|
|
|
|
def test_only_the_functions_left_switched_on_are_named():
|
|
link = ChainLink(a_filter(), functions=[{"name": "a", "active": 1},
|
|
{"name": "b", "active": 0},
|
|
{"name": "c", "active": 1}])
|
|
assert link.enabled_functions == ["a", "c"]
|
|
|
|
|
|
def test_every_function_switched_off_is_an_empty_list_not_an_absence():
|
|
"""The distinction the whole three-state design exists for."""
|
|
link = ChainLink(a_filter(), functions=[{"name": "a", "active": 0},
|
|
{"name": "b", "active": 0}])
|
|
assert link.enabled_functions == []
|
|
assert link.enabled_functions is not None
|
|
|
|
|
|
# --- what cpproxy is told ------------------------------------------------------
|
|
|
|
|
|
def _python_stage(tmp_path, code: str, functions=None):
|
|
path = tmp_path / "filter.py"
|
|
path.write_text(code, encoding="utf-8")
|
|
srv = Service(service_id="s", name="n", status="stop", proto="tcp",
|
|
transport=TRANSPORT.NFQUEUE)
|
|
link = ChainLink(a_filter(KIND.PYFILTER), code_path=str(path), functions=functions)
|
|
return _QueueStage(srv, link, owner=None), link
|
|
|
|
|
|
def _body(payload: bytes) -> bytes:
|
|
size = int.from_bytes(payload[:4], "big")
|
|
assert size == len(payload) - 4, "the length prefix disagrees with what follows"
|
|
return payload[4:]
|
|
|
|
|
|
def test_the_length_prefix_counts_bytes_not_characters(tmp_path):
|
|
"""Counted in characters, one accented letter in a comment announced fewer bytes than
|
|
followed; the binary read the rest as the next length prefix and exited."""
|
|
stage, link = _python_stage(tmp_path, "# perché\nX = 'é'\n")
|
|
_body(stage._python_payload(link))
|
|
|
|
|
|
def test_the_binary_is_told_names_and_never_runs_the_file_here(tmp_path):
|
|
"""Which functions exist is the library's to work out, in the process that compiles
|
|
the module: nothing of the operator's code runs in the backend to ask."""
|
|
stage, link = _python_stage(tmp_path, "raise SystemExit('ran in the backend')\n")
|
|
assert b"__firegex_pyfilter_enabled = None" in _body(stage._python_payload(link))
|
|
|
|
stage, link = _python_stage(tmp_path, "X = 1\n", functions=[{"name": "a", "active": 1},
|
|
{"name": "b", "active": 0}])
|
|
assert b"__firegex_pyfilter_enabled = ['a']" in _body(stage._python_payload(link))
|
|
|
|
|
|
# --- what the proxy engine is told about an address ---------------------------
|
|
|
|
|
|
def _service(proto: str = "tcp") -> Service:
|
|
return Service(service_id="s", name="n", status="stop", proto=proto,
|
|
transport=TRANSPORT.PROXY)
|
|
|
|
|
|
def _address(**kw):
|
|
from modules.services.models import Address
|
|
return Address(**{"address_id": "a", "service_id": "s", "ip_int": "10.0.0.1/32",
|
|
"port": 443, "proto": "tcp", "edge": "tcp", **kw})
|
|
|
|
|
|
def test_an_ordinary_address_is_not_announced_at_all():
|
|
"""The engine's answer for an address it has never heard of is the transparent case,
|
|
which is exactly what it would be told. An entry restating the default is one more
|
|
thing that can be wrong."""
|
|
assert announcement(_service(), _address()) is None
|
|
|
|
|
|
def test_an_address_that_moved_its_service_is_announced():
|
|
said = announcement(_service(), _address(target_port=80))
|
|
assert (said.word, said.onward, said.target_port) == ("any", "same", 80)
|
|
|
|
|
|
def test_a_target_that_is_the_port_itself_is_not_a_move():
|
|
assert announcement(_service(), _address(target_port=443)) is None
|
|
|
|
|
|
def test_only_an_http_service_declares_an_encrypted_address():
|
|
"""`tls` is the one edge with a name of its own, because it is the only one that is a
|
|
promise: a client opening that port in the clear is refused."""
|
|
assert announcement(_service("http"), _address(edge="tls")).word == "tls"
|
|
assert announcement(_service("http"), _address(edge="tcp")) is None
|
|
assert announcement(_service("tls"), _address(edge="tls")) is None, \
|
|
"a TLS service speaks TLS at every address; there is nothing to declare"
|
|
|
|
|
|
def test_what_the_service_behind_speaks_is_announced_on_its_own():
|
|
"""The one that was forgotten when this was two copies: it has no other way in."""
|
|
said = announcement(_service("tls"), _address(edge="tls", upstream="tcp"))
|
|
assert (said.word, said.onward, said.target_port) == ("any", "plain", None)
|
|
|
|
|
|
def test_a_relayed_address_is_never_announced_this_way():
|
|
"""A UDP or QUIC address has a relay of its own, bound to the service it fronts, so
|
|
both questions were already answered when it was opened."""
|
|
assert announcement(_service("http"),
|
|
_address(edge="quic", proto="udp", target_port=80)) is None
|
|
|
|
|
|
# --- what a service says about itself -----------------------------------------
|
|
# These drive two decisions with visible consequences: whether a UDP relay is opened at
|
|
# all, and whether adding an address costs every existing connection a restart. Both are
|
|
# wrong in a way nobody sees — a needless rebuild drops connections that were fine, and a
|
|
# missed one leaves a listener that cannot accept what the rules now send it.
|
|
|
|
|
|
def test_a_service_with_no_addresses_yet_answers_from_its_protocol():
|
|
"""A check made at creation, before there is an address to look at, still means
|
|
something."""
|
|
assert _service("tcp").carries("tcp") and not _service("tcp").carries("udp")
|
|
assert _service("quic").carries("udp") and not _service("quic").carries("tcp")
|
|
assert _service("http").carries("tcp") and _service("http").carries("udp"), \
|
|
"http is the one protocol reached on both"
|
|
|
|
|
|
def test_which_addresses_need_a_relay_of_their_own():
|
|
"""Datagrams and QUIC; `SO_ORIGINAL_DST` is TCP-only, so there is nothing to recover
|
|
per datagram and a socket is bound per address instead."""
|
|
srv = _service("http")
|
|
srv.addresses = [_address(port=443, proto="tcp", edge="tls"),
|
|
_address(port=443, proto="udp", edge="quic")]
|
|
assert [a.edge for a in srv.udp_addresses] == ["quic"]
|
|
|
|
|
|
def test_an_ipv6_quic_address_does_not_make_the_tcp_listener_ipv6():
|
|
"""The narrower question, and the one worth asking before rebuilding a service.
|
|
|
|
The only listener that can have its family wrong is the TCP one: everything relayed
|
|
per address binds a socket in the family of the address when it arrives. On an `http`
|
|
service the two sit side by side, so an IPv6 HTTP/3 address must not cost every TCP
|
|
connection on it a restart it did not need.
|
|
"""
|
|
from modules.services.models import Address
|
|
|
|
srv = _service("http")
|
|
srv.addresses = [
|
|
Address(address_id="v4", service_id="s", ip_int="10.0.0.1/32", port=443,
|
|
proto="tcp", edge="tls"),
|
|
Address(address_id="v6", service_id="s", ip_int="fd00::1/128", port=443,
|
|
proto="udp", edge="quic"),
|
|
]
|
|
assert srv.has_ipv6 is True, "it does answer on an IPv6 address"
|
|
assert srv.has_ipv6_tcp is False, "but not one the TCP listener has to accept"
|
|
|
|
|
|
def test_an_ipv6_tcp_address_does():
|
|
from modules.services.models import Address
|
|
|
|
srv = _service("tcp")
|
|
srv.addresses = [Address(address_id="v6", service_id="s", ip_int="fd00::1/128",
|
|
port=80, proto="tcp", edge="tcp")]
|
|
assert srv.has_ipv6_tcp is True
|
|
|
|
|
|
def test_an_interface_is_not_an_address_of_either_family():
|
|
"""It stands for whatever it carries, resolved when the rules are installed — so the
|
|
service cannot answer the family question from the name alone."""
|
|
from modules.services.models import Address
|
|
|
|
srv = _service("tcp")
|
|
srv.addresses = [Address(address_id="i", service_id="s", ip_int="eth0", port=80,
|
|
proto="tcp", edge="tcp")]
|
|
assert srv.addresses[0].is_interface is True
|
|
assert srv.has_ipv6 is False
|
|
|
|
|
|
def test_which_protocols_are_decrypted_by_the_engine():
|
|
"""Named for what it does rather than for TLS, because two protocols do it now."""
|
|
assert [p for p in ("tcp", "udp", "tls", "quic", "http") if _service(p).decrypts] \
|
|
== ["tls", "quic", "http"]
|