weavesignal/tests/ports/scan.py
The WeaveSignal contributors 466b32ab6a WeaveSignal 1.0.0 — release one
The first public release. What it does: https://weavesignal.top; what changed: CHANGELOG.md.
2026-11-04 12:15:42 +00:00

99 lines
4.1 KiB
Python
Executable file

#!/usr/bin/env python3
# SPDX-License-Identifier: AGPL-3.0-only
# SPDX-FileCopyrightText: 2026 The WeaveSignal contributors
#
# The port scan gate (release-one plan, M1): every socket the given containers listen on,
# and a failure for any that is not on loopback unless it is named as public. An unconnected
# UDP socket on an ephemeral port is a client's (an SRT caller's, say): it is reported as a
# warning, not a failure, because no setting binds it; it is reachable from the network all
# the same, so a host firewall must keep the ephemeral range closed.
# Python by exception, like the bench scripts: a check around the product, run under
# `podman unshare` so it can read the containers' processes (rootless Podman).
#
# podman unshare python3 -I tests/ports/scan.py --public 7882/udp weavesignal-livekit-1 ...
import argparse, ipaddress, json, os, subprocess, sys
def hexaddr(h):
ip, port = h.split(":")
raw = bytes.fromhex(ip)
if len(raw) == 4:
addr = ipaddress.IPv4Address(raw[::-1])
else: # four little-endian 32-bit words
addr = ipaddress.IPv6Address(b"".join(raw[i:i + 4][::-1] for i in range(0, 16, 4)))
return addr, int(port, 16)
def sockets(pid):
"""Listening TCP and bound UDP sockets in the process's network namespace: inode -> (proto, addr, port)."""
out = {}
for proto, f, listen_state in (("tcp", "tcp", "0A"), ("tcp", "tcp6", "0A"), ("udp", "udp", None), ("udp", "udp6", None)):
try:
lines = open(f"/proc/{pid}/net/{f}").read().splitlines()[1:]
except OSError:
continue
for line in lines:
cols = line.split()
if listen_state and cols[3] != listen_state:
continue
addr, port = hexaddr(cols[1])
out[cols[9]] = (proto, addr, port)
return out
def tree(pid):
pids, todo = [], [pid]
while todo:
p = todo.pop()
pids.append(p)
try:
for t in os.listdir(f"/proc/{p}/task"):
todo += open(f"/proc/{p}/task/{t}/children").read().split()
except OSError:
pass
return pids
def ephemeral():
lo, hi = open("/proc/sys/net/ipv4/ip_local_port_range").read().split()
return int(lo), int(hi)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--public", action="append", default=[], help="PORT/PROTO allowed off loopback")
ap.add_argument("containers", nargs="+")
a = ap.parse_args()
public = {tuple(x.split("/")) for x in a.public}
bad, warn, seen = [], [], []
lo, hi = ephemeral()
for c in a.containers:
pid = subprocess.run(["podman", "inspect", "-f", "{{.State.Pid}}", c], capture_output=True, text=True).stdout.strip()
if not pid or pid == "0":
sys.exit(f"{c} is not running")
socks = sockets(pid)
found = set()
for p in tree(pid):
try:
fds = os.listdir(f"/proc/{p}/fd")
except OSError:
continue
for fd in fds:
try:
link = os.readlink(f"/proc/{p}/fd/{fd}")
except OSError:
continue
if link.startswith("socket:[") and link[8:-1] in socks:
found.add(socks[link[8:-1]])
for proto, addr, port in sorted(found, key=lambda s: (s[2], s[0], str(s[1]))):
ok = addr.is_loopback or (str(port), proto) in public
client = not ok and proto == "udp" and lo <= port <= hi
seen.append({"container": c, "proto": proto, "address": str(addr), "port": port,
"ok": ok, "client": client})
if client:
warn.append(f"{c}: {proto} {addr}:{port}")
elif not ok:
bad.append(f"{c}: {proto} {addr}:{port}")
print(json.dumps(seen, indent=1))
if warn:
print("warning: client sockets on ephemeral ports, reachable unless firewalled:\n " + "\n ".join(warn))
if bad:
sys.exit("listening off loopback and not public:\n " + "\n ".join(bad))
print(f"port scan: {len(seen)} sockets; only the public ones named are off loopback")
main()