fsp-server: add a raw-ethernet transport, because UDP replies never arrived

The trace settled it: our reply satisfies EVERY check fsplib makes --
fsp_pkt_read's checksum (0x9a computed, 0x9a present), len <= recv_len,
(reply.seq & 0xfff8) == s->seq, cmd match, and fsp_getpro's pos ==
FSP_PRO_BYTES with the flags byte at buf[len].  Yet the console retried the
same transaction (seq 0x8ccb -> 0x8ccc, txn 0x8cc8 try 3 -> try 4).

The decisive evidence is the key: fsplib calls client_set_key(rpkt->key) on any
ACCEPTED reply, so the next request would carry our 0x1234.  Every request
still carries 0x8022.  No reply has ever been accepted -- so they are not
arriving, and the problem was never the protocol.

What differs from the thing that worked: mini-dhcpd.py replies over AF_PACKET
and got a DHCP lease immediately, while this server used a normal UDP socket,
whose reply depends on the kernel having a route to the console, an ARP entry
for it, and choosing the right source address.  (AF_PACKET also never populates
the kernel ARP table, so DHCP succeeding tells us nothing about those.)

--iface <dev> now replies with raw ethernet frames that MIRROR the request's
own addresses -- MACs swapped, IPs swapped, ports swapped -- which removes
routing, ARP and source-address selection in one go.  The UDP path is kept for
when it is not needed.

Frame construction is tested: the reply carries the console's MAC as
destination and ours as source, source IP 10.0.0.1 and destination 10.0.0.64
taken from the request, ports 21 -> 4096, a valid IP checksum, correct UDP
length, and padding to the 60-byte ethernet minimum.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-10 16:26:00 +02:00
parent e56f21341a
commit 7b94da0be5
+119 -21
View File
@@ -37,6 +37,94 @@ import struct
import sys import sys
import time import time
ETH_P_ALL, ETH_P_IP = 0x0003, 0x0800
def ip_csum(data):
if len(data) % 2:
data += b"\0"
t = sum(struct.unpack("!%dH" % (len(data) // 2), data))
while t >> 16:
t = (t & 0xFFFF) + (t >> 16)
return (~t) & 0xFFFF
class RawTransport:
"""Reply with raw ethernet frames, swapping the addresses from the request.
⚠️ This exists because the UDP-socket version did not work and the raw
one did. With a normal socket the reply depends on the kernel having a
route to the console, an ARP entry for it, and picking the right source
address; mini-dhcpd.py bypassed all three with AF_PACKET and got a DHCP
lease on the first try, while this server's replies never arrived at all
(proven by fsplib never adopting the key from any reply). Mirroring the
request's own addresses removes routing, ARP and source selection as
variables in one go.
"""
def __init__(self, iface, port):
self.port = port
self.s = socket.socket(socket.AF_PACKET, socket.SOCK_RAW,
socket.htons(ETH_P_ALL))
self.s.bind((iface, 0))
self.our_mac = self.s.getsockname()[4][:6]
def recv(self):
"""Return (payload, reply_ctx) for a UDP datagram aimed at our port."""
while True:
frame = self.s.recv(65535)
if len(frame) < 34 or struct.unpack("!H", frame[12:14])[0] != ETH_P_IP:
continue
ip = frame[14:]
ihl = (ip[0] & 0x0F) * 4
if ip[0] >> 4 != 4 or ip[9] != socket.IPPROTO_UDP:
continue
udp = ip[ihl:]
if len(udp) < 8:
continue
sport, dport, ulen, _ = struct.unpack("!HHHH", udp[:8])
if dport != self.port:
continue
payload = udp[8:max(ulen, 8)]
ctx = dict(dst_mac=frame[6:12], src_mac=frame[0:6],
dst_ip=ip[12:16], src_ip=ip[16:20],
dst_port=sport, src_port=dport)
return payload, ctx
def send(self, payload, ctx):
udp = struct.pack("!HHHH", ctx["src_port"], ctx["dst_port"],
8 + len(payload), 0) + payload
ip = struct.pack("!BBHHHBBH4s4s", 0x45, 0, 20 + len(udp), 0, 0, 64,
socket.IPPROTO_UDP, 0, ctx["src_ip"], ctx["dst_ip"])
ip = ip[:10] + struct.pack("!H", ip_csum(ip)) + ip[12:]
frame = (ctx["dst_mac"] + ctx["src_mac"]
+ struct.pack("!H", ETH_P_IP) + ip + udp)
if len(frame) < 60:
frame += b"\0" * (60 - len(frame))
self.s.send(frame)
def peer(self, ctx):
return (socket.inet_ntoa(ctx["dst_ip"]), ctx["dst_port"])
class UdpTransport:
"""Ordinary UDP socket. Simpler, but needs routing and ARP to cooperate."""
def __init__(self, bind, port):
self.s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
self.s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self.s.bind((bind, port))
def recv(self):
pkt, peer = self.s.recvfrom(FSP_SPACE + HSIZE + 64)
return pkt, peer
def send(self, payload, ctx):
self.s.sendto(payload, ctx)
def peer(self, ctx):
return ctx
CC_VERSION, CC_INFO = 0x10, 0x11 CC_VERSION, CC_INFO = 0x10, 0x11
CC_ERR, CC_GET_DIR, CC_GET_FILE = 0x40, 0x41, 0x42 CC_ERR, CC_GET_DIR, CC_GET_FILE = 0x40, 0x41, 0x42
CC_BYE, CC_STAT = 0x4A, 0x4D CC_BYE, CC_STAT = 0x4A, 0x4D
@@ -163,6 +251,12 @@ def main():
ap.add_argument("--root", default=".", help="directory to serve") ap.add_argument("--root", default=".", help="directory to serve")
ap.add_argument("--password", default=None, ap.add_argument("--password", default=None,
help="require this password (Swiss sends it with every request)") help="require this password (Swiss sends it with every request)")
ap.add_argument("--iface", default=None,
help="reply with RAW ethernet frames on this interface, "
"mirroring the request's own addresses. Use this if "
"replies are not arriving: it removes routing, ARP "
"and source-address selection from the picture, "
"which is what made mini-dhcpd work.")
ap.add_argument("--bind", default="0.0.0.0") ap.add_argument("--bind", default="0.0.0.0")
ap.add_argument("--port", type=int, default=21) ap.add_argument("--port", type=int, default=21)
ap.add_argument("--quiet-reads", action="store_true", ap.add_argument("--quiet-reads", action="store_true",
@@ -180,18 +274,21 @@ def main():
a = ap.parse_args() a = ap.parse_args()
root = os.path.realpath(a.root) root = os.path.realpath(a.root)
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try: try:
s.bind((a.bind, a.port)) if a.iface:
tr = RawTransport(a.iface, a.port)
else:
tr = UdpTransport(a.bind, a.port)
except PermissionError: except PermissionError:
sys.exit(f"port {a.port} needs root; run with sudo") sys.exit(f"needs root (port {a.port} / AF_PACKET); run with sudo")
print(f"fsp-server on {a.bind}:{a.port} root {root}") s = tr # everything below talks through the transport
where = (f"raw on {a.iface}" if a.iface else f"{a.bind}") + f":{a.port}"
print(f"fsp-server {where} root {root}")
print(f" password {'set' if a.password else 'NONE'}") print(f" password {'set' if a.password else 'NONE'}")
print(f" point Swiss's FSP device at this host and port\n") print(f" point Swiss's FSP device at this host and port\n")
def reply(peer_, pkt_, what): def reply(ctx_, pkt_, what):
s.sendto(pkt_, peer_) tr.send(pkt_, ctx_)
if a.trace: if a.trace:
print(f" -> {len(pkt_):4d} B {what}") print(f" -> {len(pkt_):4d} B {what}")
print(f" {pkt_[:48].hex()}") print(f" {pkt_[:48].hex()}")
@@ -203,7 +300,8 @@ def main():
fh_path, fh = None, None fh_path, fh = None, None
while True: while True:
pkt, peer = s.recvfrom(FSP_SPACE + HSIZE + 64) pkt, ctx = tr.recv()
peer = tr.peer(ctx)
r = unpack(pkt) r = unpack(pkt)
if r is None: if r is None:
print(f" short packet from {peer[0]} ({len(pkt)} B)") print(f" short packet from {peer[0]} ({len(pkt)} B)")
@@ -227,12 +325,12 @@ def main():
print(f" {pkt[:48].hex()}") print(f" {pkt[:48].hex()}")
if cmd == CC_VERSION: if cmd == CC_VERSION:
reply(peer, pack(CC_VERSION, SERVER_KEY, seq, 0, reply(ctx, pack(CC_VERSION, SERVER_KEY, seq, 0,
b"fsp-server (rebbarb)\0"), "VERSION") b"fsp-server (rebbarb)\0"), "VERSION")
print(f"[{peer[0]}] VERSION") print(f"[{peer[0]}] VERSION")
continue continue
if cmd == CC_BYE: if cmd == CC_BYE:
reply(peer, pack(CC_BYE, SERVER_KEY, seq, 0), "BYE") reply(ctx, pack(CC_BYE, SERVER_KEY, seq, 0), "BYE")
print(f"[{peer[0]}] BYE") print(f"[{peer[0]}] BYE")
continue continue
@@ -240,10 +338,10 @@ def main():
real = safe_join(root, path) real = safe_join(root, path)
if not pw_ok: if not pw_ok:
print(f"[{peer[0]}] {cmd:#04x} {path!r} -> WRONG PASSWORD") print(f"[{peer[0]}] {cmd:#04x} {path!r} -> WRONG PASSWORD")
reply(peer, pack(CC_ERR, SERVER_KEY, seq, pos, b"bad password\0"), "ERR") reply(ctx, pack(CC_ERR, SERVER_KEY, seq, pos, b"bad password\0"), "ERR")
continue continue
if real is None: if real is None:
reply(peer, pack(CC_ERR, SERVER_KEY, seq, pos, b"bad path\0"), "ERR") reply(ctx, pack(CC_ERR, SERVER_KEY, seq, pos, b"bad path\0"), "ERR")
continue continue
if cmd == CC_STAT: if cmd == CC_STAT:
@@ -257,7 +355,7 @@ def main():
else: else:
body = struct.pack("!IIB", 0, 0, 0) # type 0 == ENOENT body = struct.pack("!IIB", 0, 0, 0) # type 0 == ENOENT
print(f"[{peer[0]}] STAT {path!r} -> not found") print(f"[{peer[0]}] STAT {path!r} -> not found")
reply(peer, pack(CC_STAT, SERVER_KEY, seq, pos, body), "STAT") reply(ctx, pack(CC_STAT, SERVER_KEY, seq, pos, body), "STAT")
continue continue
if cmd == CC_GET_PRO: if cmd == CC_GET_PRO:
@@ -267,12 +365,12 @@ def main():
# A non-existent directory must be CC_ERR so fsplib reports # A non-existent directory must be CC_ERR so fsplib reports
# ENOENT: Swiss probes "swiss/patches", then "swiss", then "". # ENOENT: Swiss probes "swiss/patches", then "swiss", then "".
if not os.path.isdir(real): if not os.path.isdir(real):
reply(peer, pack(CC_ERR, SERVER_KEY, seq, pos, b"no such dir\0"), "ERR") reply(ctx, pack(CC_ERR, SERVER_KEY, seq, pos, b"no such dir\0"), "ERR")
print(f"[{peer[0]}] GET_PRO {path!r} {tag} -> ENOENT (expected; " print(f"[{peer[0]}] GET_PRO {path!r} {tag} -> ENOENT (expected; "
f"Swiss falls back)") f"Swiss falls back)")
continue continue
pro = PRO_GET | PRO_LIST | (PRO_OWNER if a.claim_owner else 0) pro = PRO_GET | PRO_LIST | (PRO_OWNER if a.claim_owner else 0)
reply(peer, pack(CC_GET_PRO, SERVER_KEY, seq, PRO_BYTES, reply(ctx, pack(CC_GET_PRO, SERVER_KEY, seq, PRO_BYTES,
path.encode("latin-1", "replace"), path.encode("latin-1", "replace"),
bytes([pro])), "GET_PRO") bytes([pro])), "GET_PRO")
print(f"[{peer[0]}] GET_PRO {path!r} {tag} -> {pro:#04x}" print(f"[{peer[0]}] GET_PRO {path!r} {tag} -> {pro:#04x}"
@@ -282,13 +380,13 @@ def main():
if cmd == CC_GET_DIR: if cmd == CC_GET_DIR:
if not os.path.isdir(real): if not os.path.isdir(real):
reply(peer, pack(CC_ERR, SERVER_KEY, seq, pos, b"not a dir\0"), "ERR") reply(ctx, pack(CC_ERR, SERVER_KEY, seq, pos, b"not a dir\0"), "ERR")
continue continue
blk = dir_block(real) blk = dir_block(real)
# The client reads blocks until one comes back shorter than the # The client reads blocks until one comes back shorter than the
# first; so the whole listing, then an empty reply. # first; so the whole listing, then an empty reply.
out = blk if pos == 0 else b"" out = blk if pos == 0 else b""
reply(peer, pack(CC_GET_DIR, SERVER_KEY, seq, pos, out), "GET_DIR") reply(ctx, pack(CC_GET_DIR, SERVER_KEY, seq, pos, out), "GET_DIR")
print(f"[{peer[0]}] GET_DIR {path!r} pos={pos} -> {len(out)} B") print(f"[{peer[0]}] GET_DIR {path!r} pos={pos} -> {len(out)} B")
continue continue
@@ -302,12 +400,12 @@ def main():
fh = open(real, "rb") fh = open(real, "rb")
fh_path = real fh_path = real
except OSError as e: except OSError as e:
s.sendto(pack(CC_ERR, SERVER_KEY, seq, pos, reply(ctx, pack(CC_ERR, SERVER_KEY, seq, pos,
f"{e}\0".encode()), peer) f"{e}\0".encode()), "ERR")
continue continue
fh.seek(pos) fh.seek(pos)
data = fh.read(want) data = fh.read(want)
reply(peer, pack(CC_GET_FILE, SERVER_KEY, seq, pos, data), "GET_FILE") reply(ctx, pack(CC_GET_FILE, SERVER_KEY, seq, pos, data), "GET_FILE")
served += len(data) served += len(data)
reads += 1 reads += 1
now = time.time() now = time.time()
@@ -326,7 +424,7 @@ def main():
continue continue
print(f"[{peer[0]}] unsupported command {cmd:#04x} {path!r}") print(f"[{peer[0]}] unsupported command {cmd:#04x} {path!r}")
reply(peer, pack(CC_ERR, SERVER_KEY, seq, pos, b"unsupported\0"), "ERR") reply(ctx, pack(CC_ERR, SERVER_KEY, seq, pos, b"unsupported\0"), "ERR")
if __name__ == "__main__": if __name__ == "__main__":