mirror of
https://github.com/Av0idD4rk/firegex.git
synced 2026-10-23 16:31:00 +00:00
131 lines
5.9 KiB
Python
131 lines
5.9 KiB
Python
"""The proxy engine as a process: a way in nobody configured, and a death nobody asked for.
|
|
|
|
Both are about what surrounds the datapath rather than what it does to traffic. The
|
|
engine listens on an ephemeral port every local process can dial, and it runs as a
|
|
process that can die while the rules steering traffic at it stay where they are.
|
|
"""
|
|
|
|
import socket
|
|
import time
|
|
|
|
import pytest
|
|
|
|
from integration.conftest import add_regex_filter, start_and_settle
|
|
from helpers.host import as_root, engine_port, listening_pid
|
|
from helpers.traffic import Channel
|
|
|
|
pytestmark = [pytest.mark.instance, pytest.mark.root]
|
|
|
|
|
|
def _listener(port: int, l4: str = "tcp") -> int:
|
|
found = engine_port(port, l4)
|
|
if found is None:
|
|
pytest.skip("cannot read the nftables ruleset from here: not Linux, no root and no "
|
|
"passwordless sudo, or the instance under test is on another host")
|
|
return found
|
|
|
|
|
|
def test_a_connection_dialled_straight_at_the_engine_is_turned_away(api, protected,
|
|
proxy_layer):
|
|
"""Nothing redirected it, so it has nowhere to go but back into the engine.
|
|
|
|
`SO_ORIGINAL_DST` on a connection no rule rewrote is the address it was accepted on —
|
|
the engine's own port. The check against dialling ourselves compared with the address
|
|
the listener was *bound* to, the wildcard, which no connection is ever accepted on:
|
|
so it dialled itself, and that connection dialled itself, for as long as there were
|
|
descriptors to spend. Any local process could set it off with one `connect`.
|
|
|
|
Two things stop it now, and either is enough: the rules drop what reaches one of the
|
|
engine's ports without having been redirected there, and the engine refuses a
|
|
connection whose destination is itself. So it is either never answered or closed at
|
|
once — what it may not be is held.
|
|
"""
|
|
service_id, server, port = protected(proxy_layer, name="selfdial")
|
|
start_and_settle(api, service_id)
|
|
listener = _listener(port)
|
|
|
|
try:
|
|
with socket.create_connection((proxy_layer.ip, listener), timeout=3) as sock:
|
|
sock.settimeout(5)
|
|
try:
|
|
turned_away = sock.recv(1) == b""
|
|
except ConnectionResetError:
|
|
turned_away = True
|
|
except socket.timeout:
|
|
turned_away = False
|
|
except (TimeoutError, ConnectionRefusedError):
|
|
turned_away = True
|
|
assert turned_away, \
|
|
"the engine held a connection that could only have gone back into itself"
|
|
|
|
channel = Channel(server, port, proxy_layer.ipv6)
|
|
assert channel.gets_through(b"still answering"), \
|
|
"the service stopped working after somebody dialled the engine directly"
|
|
|
|
|
|
def test_a_relay_takes_only_what_was_redirected_to_it(api, service, udp_stand_in,
|
|
proxy_layer):
|
|
"""A relay forwards to its service whoever sent the datagram.
|
|
|
|
Every relay is bound to the wildcard, because a redirect delivers to whichever
|
|
address the packet arrived on — so a service protected on a loopback address was
|
|
reachable from the network through its relay's port, filters or no filters. Only
|
|
what arrived through the redirect is carried now.
|
|
"""
|
|
echo = udp_stand_in(proxy_layer.ipv6)
|
|
service_id = service(f"relay-{echo.port}", proxy_layer.ip, echo.port, "proxy",
|
|
proto="udp")
|
|
start_and_settle(api, service_id, wait=1.2)
|
|
assert echo.exchange(b"through the service") == b"through the service", \
|
|
"the service was not reachable to begin with"
|
|
relay = _listener(echo.port, "udp")
|
|
|
|
family = socket.AF_INET6 if proxy_layer.ipv6 else socket.AF_INET
|
|
with socket.socket(family, socket.SOCK_DGRAM) as sock:
|
|
sock.settimeout(1.5)
|
|
sock.sendto(b"straight at the relay", (proxy_layer.ip, relay))
|
|
try:
|
|
answered = sock.recvfrom(65535)[0]
|
|
except (TimeoutError, OSError):
|
|
answered = None
|
|
assert answered is None, "a datagram sent straight to the relay reached the service"
|
|
assert echo.exchange(b"still carried") == b"still carried"
|
|
|
|
|
|
def test_the_engine_killed_is_brought_back_with_its_filters(api, protected, proxy_layer):
|
|
"""The proxy layer's half of the watchdog the queued layer already had.
|
|
|
|
Nothing watched the engine. Killed or crashed, it left its redirect in place, pointing
|
|
at a port nobody listened on any more: every client of the service was refused, while
|
|
the interface went on saying the service was active. The rules outlive the process on
|
|
both layers, which is why the recovery restarts the service rather than the process.
|
|
"""
|
|
service_id, server, port = protected(proxy_layer, name="enginedies")
|
|
add_regex_filter(api, service_id, "BLOCK_ME_ENGINE")
|
|
start_and_settle(api, service_id)
|
|
channel = Channel(server, port, proxy_layer.ipv6)
|
|
assert channel.gets_through(b"harmless"), "the service was not working to begin with"
|
|
|
|
pid = listening_pid(_listener(port))
|
|
if pid is None:
|
|
pytest.skip("cannot name the process behind the engine's port from here")
|
|
as_root("kill", "-9", str(pid))
|
|
|
|
deadline = time.monotonic() + 10
|
|
said: list[str] = []
|
|
while time.monotonic() < deadline:
|
|
said = [e["text"] for e in api.services_logs(service_id)]
|
|
restarted = [i for i, text in enumerate(said) if "restarting it" in text]
|
|
if restarted and any("started on the" in text for text in said[restarted[-1]:]):
|
|
break
|
|
time.sleep(0.3)
|
|
else:
|
|
pytest.fail(f"the engine was not brought back: {said[-5:]}")
|
|
|
|
assert any("exited on its own" in text for text in said), said[-5:]
|
|
assert api.services_get(service_id)["status"] == "active"
|
|
assert channel.gets_through(b"after the engine was killed"), \
|
|
"the service was reported restarted and still refused its clients"
|
|
assert channel.is_blocked(b"carrying BLOCK_ME_ENGINE"), \
|
|
"the engine came back without the filters it had"
|