mirror of
https://github.com/Av0idD4rk/firegex.git
synced 2026-10-23 21:42:05 +00:00
159 lines
5.0 KiB
Python
159 lines
5.0 KiB
Python
"""Reading the interface the engine writes its reconstructions to.
|
|
|
|
`firegex0` carries what the filters saw, framed as the TCP streams the traffic either was
|
|
(TLS over TCP) or would have been (a QUIC stream, an HTTP/3 exchange). The framing is
|
|
invented — that is said everywhere this is offered — so the one thing worth testing about
|
|
it is whether a tool pointed at the interface can take it apart again, which is precisely
|
|
what the invention is for.
|
|
|
|
`tcpdump` writes the pcap and this reads it. No scapy: the suite's dependencies are pure
|
|
python on purpose, the format is four headers deep, and a parser small enough to read is
|
|
a better neighbour than a dependency that has to be installed on a competition laptop.
|
|
"""
|
|
|
|
import os
|
|
import shutil
|
|
import struct
|
|
import subprocess
|
|
import tempfile
|
|
import time
|
|
from contextlib import contextmanager
|
|
from dataclasses import dataclass, field
|
|
|
|
import pytest
|
|
|
|
DEVICE = "firegex0"
|
|
|
|
#: Long enough for the engine to have written what the client already received: the send
|
|
#: is best-effort and non-blocking, so it can trail the traffic slightly.
|
|
DRAIN = 1.0
|
|
|
|
needs_capture = pytest.mark.skipif(
|
|
not (shutil.which("tcpdump") and os.path.isdir(f"/sys/class/net/{DEVICE}")),
|
|
reason=f"no tcpdump, or no {DEVICE} on this host",
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class Segment:
|
|
"""One reconstructed TCP segment, in the terms the assertions are written in."""
|
|
src_port: int
|
|
dst_port: int
|
|
syn: bool
|
|
fin: bool
|
|
payload: bytes
|
|
|
|
|
|
@dataclass
|
|
class Capture:
|
|
segments: list = field(default_factory=list)
|
|
|
|
def carrying(self, needle: bytes) -> list:
|
|
return [s for s in self.segments if needle in s.payload]
|
|
|
|
def conversations(self, needle: bytes) -> set:
|
|
"""Which reconstructed streams carried these bytes.
|
|
|
|
A stream is named by its client port, which on a multiplexed connection is the
|
|
only thing that tells two of them apart — and is synthetic for exactly that
|
|
reason.
|
|
"""
|
|
return {s.src_port for s in self.carrying(needle)}
|
|
|
|
def opened(self, port: int) -> bool:
|
|
return any(s.syn and s.src_port == port for s in self.segments)
|
|
|
|
|
|
@contextmanager
|
|
def watching(device: str = DEVICE):
|
|
"""Run `tcpdump` over the body, and hand back what it recorded."""
|
|
recorded = Capture()
|
|
handle, path = tempfile.mkstemp(suffix=".pcap")
|
|
os.close(handle)
|
|
# `-U` so each packet is written as it arrives rather than at the end of a buffer,
|
|
# which matters because this is stopped by a signal.
|
|
dump = subprocess.Popen(
|
|
["tcpdump", "-i", device, "-s", "0", "-U", "-w", path, "-n"],
|
|
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
|
|
)
|
|
# tcpdump says when it is listening, and starting the traffic before it does is how
|
|
# a test loses the first exchange and then passes or fails by the clock.
|
|
deadline = time.time() + 10
|
|
while time.time() < deadline:
|
|
line = dump.stderr.readline()
|
|
if b"listening on" in line:
|
|
break
|
|
if dump.poll() is not None:
|
|
pytest.skip(f"tcpdump would not start: {line.decode(errors='replace').strip()}")
|
|
try:
|
|
yield recorded
|
|
finally:
|
|
time.sleep(DRAIN)
|
|
dump.terminate()
|
|
dump.wait(timeout=10)
|
|
recorded.segments.extend(read_pcap(path))
|
|
try:
|
|
os.remove(path)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def read_pcap(path: str) -> list:
|
|
"""Every TCP segment in the file, with the headers peeled off."""
|
|
with open(path, "rb") as handle:
|
|
raw = handle.read()
|
|
if len(raw) < 24:
|
|
return []
|
|
magic = raw[:4]
|
|
if magic in (b"\xa1\xb2\xc3\xd4", b"\xa1\xb2\x3c\x4d"):
|
|
endian = ">"
|
|
elif magic in (b"\xd4\xc3\xb2\xa1", b"\x4d\x3c\xb2\xa1"):
|
|
endian = "<"
|
|
else:
|
|
return []
|
|
|
|
segments = []
|
|
offset = 24
|
|
while offset + 16 <= len(raw):
|
|
_, _, captured, _ = struct.unpack_from(f"{endian}IIII", raw, offset)
|
|
offset += 16
|
|
frame = raw[offset:offset + captured]
|
|
offset += captured
|
|
segment = _tcp_of(frame)
|
|
if segment is not None:
|
|
segments.append(segment)
|
|
return segments
|
|
|
|
|
|
def _tcp_of(frame: bytes):
|
|
"""Ethernet → IP → TCP, or `None` for anything else on the interface."""
|
|
if len(frame) < 14:
|
|
return None
|
|
ethertype = struct.unpack_from(">H", frame, 12)[0]
|
|
if ethertype == 0x0800:
|
|
if len(frame) < 34:
|
|
return None
|
|
ihl = (frame[14] & 0x0F) * 4
|
|
if frame[23] != 6: # not TCP
|
|
return None
|
|
start = 14 + ihl
|
|
elif ethertype == 0x86DD:
|
|
if len(frame) < 54 or frame[20] != 6:
|
|
return None
|
|
start = 54
|
|
else:
|
|
return None
|
|
|
|
if len(frame) < start + 20:
|
|
return None
|
|
src_port, dst_port = struct.unpack_from(">HH", frame, start)
|
|
data_offset = (frame[start + 12] >> 4) * 4
|
|
flags = frame[start + 13]
|
|
return Segment(
|
|
src_port=src_port,
|
|
dst_port=dst_port,
|
|
syn=bool(flags & 0x02),
|
|
fin=bool(flags & 0x01),
|
|
payload=frame[start + data_offset:],
|
|
)
|