mirror of
https://github.com/ruvnet/RuView.git
synced 2026-10-21 22:22:33 -05:00
* feat(bfi): add validated capture and bounded research tooling * fix(deps): patch Wasmtime for RUSTSEC-2026-0316
261 lines
13 KiB
Python
261 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""Explicit, bounded own-AP capture with Ethernet preflight and Wi-Fi restoration.
|
|
|
|
Run as the normal user with passwordless sudo for iw/ip/nmcli/tcpdump. Raw
|
|
packets stay in a new private directory outside the source checkout. This tool
|
|
does not inject frames, install drivers, or configure an access point.
|
|
"""
|
|
import argparse
|
|
import datetime
|
|
try:
|
|
import fcntl
|
|
except ImportError: # Permit mocked tests on non-Linux development hosts.
|
|
fcntl = None
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import shlex
|
|
import signal
|
|
import subprocess
|
|
import tempfile
|
|
import time
|
|
import uuid as uuid_module
|
|
|
|
|
|
def command(args, privileged=False, timeout=30, check=True):
|
|
result = subprocess.run((["sudo", "-n"] if privileged else []) + args,
|
|
capture_output=True, text=True, timeout=timeout)
|
|
if check and result.returncode:
|
|
raise RuntimeError(f"{shlex.join(args)} failed ({result.returncode}): {result.stderr.strip()}")
|
|
return result
|
|
|
|
|
|
def now():
|
|
return datetime.datetime.now(datetime.timezone.utc).isoformat()
|
|
|
|
|
|
def evidence_in_checkout(output):
|
|
"""Recognize real checkouts, including worktrees whose .git is a file.
|
|
|
|
The script can be copied outside the repository for a hardware trial;
|
|
its installation depth must never turn /var into a presumed checkout.
|
|
"""
|
|
output = Path(output).resolve()
|
|
return any((parent / ".git").exists() for parent in (output, *output.parents))
|
|
|
|
|
|
def release_wireless(iface):
|
|
"""Wait for observed NM and association teardown before changing type."""
|
|
command(["nmcli", "--wait", "10", "device", "disconnect", iface], True, timeout=15)
|
|
command(["nmcli", "device", "set", iface, "managed", "no"], True)
|
|
deadline = time.monotonic() + 10
|
|
state = link = ""
|
|
while time.monotonic() < deadline:
|
|
state = command(["nmcli", "-g", "GENERAL.STATE", "device", "show", iface], timeout=3).stdout.strip()
|
|
link = command(["iw", "dev", iface, "link"], timeout=3).stdout.strip()
|
|
if state.split(maxsplit=1)[:1] == ["10"] and link == "Not connected.":
|
|
return {"nm_state": state, "link": link}
|
|
time.sleep(0.25)
|
|
raise RuntimeError(f"Wireless release did not verify: NM={state!r}, link={link!r}")
|
|
|
|
|
|
def restore(iface, uuid):
|
|
# Stop NM first: it must not race the initial type change.
|
|
command(["nmcli", "device", "set", iface, "managed", "no"], True)
|
|
command(["ip", "link", "set", iface, "down"], True)
|
|
command(["iw", "dev", iface, "set", "type", "managed"], True)
|
|
command(["nmcli", "device", "set", iface, "managed", "yes"], True)
|
|
command(["ip", "link", "set", iface, "up"], True)
|
|
info = command(["iw", "dev", iface, "info"]).stdout
|
|
if "type managed" not in info:
|
|
# Observed on MT7927: the ownership handoff can restore the old
|
|
# monitor type. Correct that specific state after NM owns the device.
|
|
command(["ip", "link", "set", iface, "down"], True)
|
|
command(["iw", "dev", iface, "set", "type", "managed"], True)
|
|
command(["ip", "link", "set", iface, "up"], True)
|
|
for _ in range(20):
|
|
state = command(["nmcli", "-g", "GENERAL.STATE", "device", "show", iface]).stdout
|
|
if not state.startswith(("10 ", "20 ")):
|
|
break
|
|
time.sleep(0.5)
|
|
command(["nmcli", "--wait", "30", "connection", "up", "uuid", uuid,
|
|
"ifname", iface], True, timeout=35)
|
|
info = command(["iw", "dev", iface, "info"]).stdout
|
|
link = command(["iw", "dev", iface, "link"]).stdout
|
|
actual_uuid = command(["nmcli", "-g", "GENERAL.CON-UUID", "device", "show", iface]).stdout.strip()
|
|
if "type managed" not in info or "Connected to " not in link or actual_uuid != uuid:
|
|
raise RuntimeError("Wi-Fi restoration did not verify")
|
|
return {"interface": info, "link": link}
|
|
|
|
|
|
def capture(cmd, packet_file, seconds, active_path=None):
|
|
"""Reap the complete privileged process group before radio restoration."""
|
|
proc = subprocess.Popen(cmd, stdout=packet_file, stderr=subprocess.PIPE,
|
|
start_new_session=True)
|
|
marker_created = False
|
|
try:
|
|
if active_path is not None:
|
|
with active_path.open("x"):
|
|
pass
|
|
marker_created = True
|
|
_, stderr = proc.communicate(timeout=seconds + 10)
|
|
return subprocess.CompletedProcess(cmd, proc.returncode, stderr=stderr)
|
|
except BaseException:
|
|
# This group belongs only to this invocation. sudo/timeout/tcpdump
|
|
# may run as root, so signaling the unprivileged wrapper is insufficient.
|
|
command(["kill", "-INT", "--", f"-{proc.pid}"], True, check=False)
|
|
try:
|
|
proc.communicate(timeout=7)
|
|
except subprocess.TimeoutExpired:
|
|
command(["kill", "-KILL", "--", f"-{proc.pid}"], True, check=False)
|
|
proc.communicate(timeout=5)
|
|
raise
|
|
finally:
|
|
if marker_created:
|
|
active_path.unlink(missing_ok=True)
|
|
|
|
|
|
def verify_monitor(info, frequency, width, center):
|
|
if "type monitor" not in info or not re.search(rf"\({frequency} MHz\)", info):
|
|
raise RuntimeError("Monitor interface type/frequency did not verify")
|
|
if not re.search(rf"width: {width} MHz\b", info):
|
|
raise RuntimeError("Monitor channel width did not verify")
|
|
if width == 80 and not re.search(rf"center1: {center} MHz\b", info):
|
|
raise RuntimeError("Monitor center frequency did not verify")
|
|
|
|
|
|
def main():
|
|
p = argparse.ArgumentParser(description=__doc__)
|
|
p.add_argument("--interface", required=True)
|
|
p.add_argument("--ethernet", required=True)
|
|
p.add_argument("--management-ip", required=True)
|
|
p.add_argument("--bssid", required=True)
|
|
p.add_argument("--frequency", type=int, required=True)
|
|
p.add_argument("--width", type=int, choices=(20, 80), default=80)
|
|
p.add_argument("--center", type=int)
|
|
p.add_argument("--seconds", type=int, default=30)
|
|
p.add_argument("--packets", type=int, default=2000)
|
|
frame_scope = p.add_mutually_exclusive_group()
|
|
frame_scope.add_argument("--beacons-only", action="store_true")
|
|
frame_scope.add_argument("--include-link-traffic", action="store_true",
|
|
help="Diagnostic: also retain own-AP control/data frames")
|
|
p.add_argument("--output", type=Path, required=True)
|
|
p.add_argument("--confirm-capture", action="store_true")
|
|
a = p.parse_args()
|
|
if fcntl is None:
|
|
p.error("capture requires Linux")
|
|
import ipaddress
|
|
ipaddress.ip_address(a.management_ip)
|
|
for name in (a.interface, a.ethernet):
|
|
if not re.fullmatch(r"[A-Za-z0-9_.-]{1,15}", name):
|
|
p.error("invalid interface name")
|
|
if not a.confirm_capture:
|
|
p.error("--confirm-capture is required for the radio change")
|
|
if not re.fullmatch(r"(?:[0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}", a.bssid):
|
|
p.error("invalid BSSID")
|
|
# 2000 full 65535-byte records plus headers fit the decoder's 128 MiB limit.
|
|
if not 1 <= a.seconds <= 120 or not 1 <= a.packets <= 2000:
|
|
p.error("capture must be bounded to 1..120 seconds and 1..2000 packets")
|
|
if not 2400 <= a.frequency <= 7125 or (a.width == 80 and not a.center):
|
|
p.error("invalid frequency or missing 80 MHz center frequency")
|
|
if a.center is not None and not 2400 <= a.center <= 7125:
|
|
p.error("invalid center frequency")
|
|
if a.width == 80 and abs(a.frequency - a.center) not in (10, 30):
|
|
p.error("80 MHz primary/center frequencies must differ by 10 or 30 MHz")
|
|
out = a.output.expanduser().resolve()
|
|
if evidence_in_checkout(out):
|
|
p.error("raw evidence must be outside the source checkout")
|
|
os.umask(0o077)
|
|
out.mkdir(mode=0o700, parents=False, exist_ok=False)
|
|
# Per-user lock spans preflight, mutation, capture and restoration.
|
|
lock_path = Path(tempfile.gettempdir()) / f"ruview-bfi-radio-{os.getuid()}.lock"
|
|
fd = os.open(lock_path, os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
|
|
with os.fdopen(fd, "w") as lock:
|
|
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
before = command(["iw", "dev", a.interface, "info"]).stdout
|
|
if "type managed" not in before:
|
|
raise RuntimeError("Expected a managed interface before capture")
|
|
uuid = command(["nmcli", "-g", "GENERAL.CON-UUID", "device", "show", a.interface]).stdout.strip()
|
|
try:
|
|
uuid_module.UUID(uuid)
|
|
except ValueError:
|
|
raise RuntimeError("No active NetworkManager connection to restore")
|
|
ethernet_type = command(["nmcli", "-g", "GENERAL.TYPE", "device", "show", a.ethernet]).stdout.strip()
|
|
if ethernet_type != "ethernet":
|
|
raise RuntimeError("Management interface is not Ethernet")
|
|
route = command(["ip", "route", "get", a.management_ip]).stdout
|
|
if f"dev {a.ethernet} " not in route or a.ethernet == a.interface:
|
|
raise RuntimeError("Management route is not using the specified Ethernet interface")
|
|
bssid = a.bssid.lower()
|
|
addresses = f"(wlan addr1 {bssid} or wlan addr2 {bssid} or wlan addr3 {bssid})"
|
|
filt = ("type mgt subtype beacon and wlan addr2 " + bssid if a.beacons_only else
|
|
addresses if a.include_link_traffic else "type mgt and " + addresses)
|
|
cmd = ["sudo", "-n", "timeout", "--signal=INT", "--kill-after=5s", str(a.seconds)+"s",
|
|
"tcpdump", "-i", a.interface, "-y", "IEEE802_11_RADIO", "-s", "65535",
|
|
"-c", str(a.packets), "-U", "-w", "-", filt]
|
|
manifest = {"schema": 1, "before": before, "original_connection_uuid": uuid,
|
|
"route": route, "command": cmd,
|
|
"requested_seconds": a.seconds, "started_at": now()}
|
|
changed = False
|
|
error = None
|
|
def interrupted(signum, frame):
|
|
raise RuntimeError(f"capture interrupted by signal {signum}")
|
|
old_handlers = {sig: signal.signal(sig, interrupted)
|
|
for sig in (signal.SIGTERM, signal.SIGHUP, signal.SIGINT)}
|
|
try:
|
|
changed = True
|
|
manifest["released"] = release_wireless(a.interface)
|
|
command(["ip", "link", "set", a.interface, "down"], True)
|
|
command(["iw", "dev", a.interface, "set", "type", "monitor"], True)
|
|
monitor_flags = ["otherbss", "control"] if a.include_link_traffic else ["otherbss"]
|
|
command(["iw", "dev", a.interface, "set", "monitor"] + monitor_flags, True)
|
|
command(["ip", "link", "set", a.interface, "up"], True)
|
|
frequency = [str(a.frequency), "HT20"] if a.width == 20 else [str(a.frequency), "80", str(a.center)]
|
|
command(["iw", "dev", a.interface, "set", "freq"] + frequency, True)
|
|
manifest["monitor_before"] = command(["iw", "dev", a.interface, "info"]).stdout
|
|
verify_monitor(manifest["monitor_before"], a.frequency, a.width, a.center)
|
|
started = time.monotonic()
|
|
manifest["capture_started_at"] = now()
|
|
with (out / "capture.pcap").open("xb") as packet_file:
|
|
result = capture(cmd, packet_file, a.seconds, out / "capture.active")
|
|
manifest["capture_seconds"] = time.monotonic() - started
|
|
manifest["capture_ended_at"] = now()
|
|
manifest["capture_exit"] = result.returncode
|
|
manifest["capture_log"] = result.stderr.decode(errors="replace")
|
|
manifest["monitor_after"] = command(["iw", "dev", a.interface, "info"]).stdout
|
|
verify_monitor(manifest["monitor_after"], a.frequency, a.width, a.center)
|
|
with (out / "capture.pcap").open("rb") as evidence:
|
|
manifest["sha256"] = hashlib.file_digest(evidence, "sha256").hexdigest()
|
|
if result.returncode not in (0, 124):
|
|
raise RuntimeError("tcpdump failed; see capture_log")
|
|
except BaseException as exc:
|
|
error = exc
|
|
manifest["error"] = str(exc)
|
|
finally:
|
|
# A second terminal signal must not interrupt the restoration.
|
|
for sig in old_handlers:
|
|
signal.signal(sig, signal.SIG_IGN)
|
|
if changed:
|
|
try:
|
|
manifest["restored"] = restore(a.interface, uuid)
|
|
final_route = command(["ip", "route", "get", a.management_ip]).stdout
|
|
manifest["route_after"] = final_route
|
|
if f"dev {a.ethernet} " not in final_route:
|
|
raise RuntimeError("Ethernet management route changed")
|
|
except Exception as exc:
|
|
manifest["restore_error"] = str(exc)
|
|
error = error or exc
|
|
manifest["ended_at"] = now()
|
|
(out / "manifest.json").write_text(json.dumps(manifest, indent=2)+"\n")
|
|
for sig, handler in old_handlers.items():
|
|
signal.signal(sig, handler)
|
|
print(json.dumps({"output": str(out), "capture_log": manifest.get("capture_log"),
|
|
"restored": "restored" in manifest, "error": str(error) if error else None}))
|
|
return 1 if error else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|