firegex/tests/unit/test_proxysim.py

138 lines
4.5 KiB
Python

"""`fgex pyfilters`: the way an operator tries a filter before it goes near a service.
Run as they would run it — the `fgex` script, as a process of its own, in front of a
real service — because every part of that path has broken on its own at some point: the
file watcher that starts the proxy, the child process it runs in (Python 3.14 starts
those with `forkserver`, which imports the script again), and the filtering itself.
Nothing else in the suite runs it, so a release could ship a simulator that no longer
simulates and nobody would find out until they needed it.
What it has to agree with is production. A filter that refuses here refuses there, clean
traffic comes back, and traffic the HTTP parser cannot read is carried — the library's
default — rather than refused as it used to be.
"""
import os
import signal
import socket
import subprocess
import sys
import threading
import time
import pytest
from helpers.net import free_port
FGEX = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "fgex-lib", "fgex"))
RAW = """from firegex.pyfilters import pyfilter, ACCEPT, REJECT
from firegex.pyfilters.models import RawPacket
@pyfilter
def refuse(packet: RawPacket):
return REJECT if b"BLOCKME" in packet.data else ACCEPT
"""
HTTP = """from firegex.pyfilters import pyfilter, ACCEPT, REJECT
from firegex.pyfilters.models import HttpRequest
@pyfilter
def refuse(req: HttpRequest):
return REJECT if "BLOCKME" in (req.url or "") else ACCEPT
"""
@pytest.fixture
def echo():
"""A service that answers with whatever it was sent. Returns its port."""
server = socket.socket()
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(("127.0.0.1", free_port()))
server.listen(16)
def answer(conn):
try:
while data := conn.recv(4096):
conn.sendall(data)
except OSError:
pass
finally:
conn.close()
def serve():
while True:
try:
conn, _ = server.accept()
except OSError:
return
threading.Thread(target=answer, args=(conn,), daemon=True).start()
threading.Thread(target=serve, daemon=True).start()
yield server.getsockname()[1]
server.close()
@pytest.fixture
def simulator(echo, tmp_path):
"""Start `fgex pyfilters` on a filter file in front of `echo`. Returns an asker."""
started = []
def _start(code: str):
path = tmp_path / "filter.py"
path.write_text(code)
port = free_port()
proc = subprocess.Popen(
[sys.executable, FGEX, "pyfilters", str(path), "127.0.0.1", str(echo),
"--from-port", str(port)],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
started.append(proc)
deadline = time.time() + 20
while time.time() < deadline:
if proc.poll() is not None:
pytest.fail(f"fgex pyfilters exited: {proc.stdout.read()}")
try:
socket.create_connection(("127.0.0.1", port), timeout=0.2).close()
break
except OSError:
time.sleep(0.1)
else:
pytest.fail("fgex pyfilters never started listening")
def ask(payload: bytes) -> bytes:
try:
with socket.create_connection(("127.0.0.1", port), timeout=3) as conn:
conn.sendall(payload)
return conn.recv(4096)
except OSError:
return b""
return ask
yield _start
for proc in started:
# Interrupted rather than killed: that is how an operator stops it, and it is the
# path that takes the proxy's own process down with it.
proc.send_signal(signal.SIGINT)
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait()
def test_a_raw_filter_refuses_what_it_should_and_carries_the_rest(simulator):
ask = simulator(RAW)
assert ask(b"hello") == b"hello"
assert ask(b"carrying BLOCKME") == b""
def test_an_http_filter_reads_requests_and_carries_what_is_not_http(simulator):
ask = simulator(HTTP)
clean = b"GET /fine HTTP/1.1\r\nHost: x\r\n\r\n"
assert ask(clean) == clean
assert ask(b"GET /BLOCKME HTTP/1.1\r\nHost: x\r\n\r\n") == b""
# Not a refusal: `invalid_encoding_action` is ACCEPT unless the file says otherwise.
garbage = b"\x16\x03\x01 not a request\r\n\r\n"
assert ask(garbage) == garbage