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:
+119
-21
@@ -37,6 +37,94 @@ import struct
|
||||
import sys
|
||||
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_ERR, CC_GET_DIR, CC_GET_FILE = 0x40, 0x41, 0x42
|
||||
CC_BYE, CC_STAT = 0x4A, 0x4D
|
||||
@@ -163,6 +251,12 @@ def main():
|
||||
ap.add_argument("--root", default=".", help="directory to serve")
|
||||
ap.add_argument("--password", default=None,
|
||||
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("--port", type=int, default=21)
|
||||
ap.add_argument("--quiet-reads", action="store_true",
|
||||
@@ -180,18 +274,21 @@ def main():
|
||||
a = ap.parse_args()
|
||||
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:
|
||||
s.bind((a.bind, a.port))
|
||||
if a.iface:
|
||||
tr = RawTransport(a.iface, a.port)
|
||||
else:
|
||||
tr = UdpTransport(a.bind, a.port)
|
||||
except PermissionError:
|
||||
sys.exit(f"port {a.port} needs root; run with sudo")
|
||||
print(f"fsp-server on {a.bind}:{a.port} root {root}")
|
||||
sys.exit(f"needs root (port {a.port} / AF_PACKET); run with sudo")
|
||||
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" point Swiss's FSP device at this host and port\n")
|
||||
|
||||
def reply(peer_, pkt_, what):
|
||||
s.sendto(pkt_, peer_)
|
||||
def reply(ctx_, pkt_, what):
|
||||
tr.send(pkt_, ctx_)
|
||||
if a.trace:
|
||||
print(f" -> {len(pkt_):4d} B {what}")
|
||||
print(f" {pkt_[:48].hex()}")
|
||||
@@ -203,7 +300,8 @@ def main():
|
||||
fh_path, fh = None, None
|
||||
|
||||
while True:
|
||||
pkt, peer = s.recvfrom(FSP_SPACE + HSIZE + 64)
|
||||
pkt, ctx = tr.recv()
|
||||
peer = tr.peer(ctx)
|
||||
r = unpack(pkt)
|
||||
if r is None:
|
||||
print(f" short packet from {peer[0]} ({len(pkt)} B)")
|
||||
@@ -227,12 +325,12 @@ def main():
|
||||
print(f" {pkt[:48].hex()}")
|
||||
|
||||
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")
|
||||
print(f"[{peer[0]}] VERSION")
|
||||
continue
|
||||
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")
|
||||
continue
|
||||
|
||||
@@ -240,10 +338,10 @@ def main():
|
||||
real = safe_join(root, path)
|
||||
if not pw_ok:
|
||||
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
|
||||
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
|
||||
|
||||
if cmd == CC_STAT:
|
||||
@@ -257,7 +355,7 @@ def main():
|
||||
else:
|
||||
body = struct.pack("!IIB", 0, 0, 0) # type 0 == ENOENT
|
||||
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
|
||||
|
||||
if cmd == CC_GET_PRO:
|
||||
@@ -267,12 +365,12 @@ def main():
|
||||
# A non-existent directory must be CC_ERR so fsplib reports
|
||||
# ENOENT: Swiss probes "swiss/patches", then "swiss", then "".
|
||||
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; "
|
||||
f"Swiss falls back)")
|
||||
continue
|
||||
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"),
|
||||
bytes([pro])), "GET_PRO")
|
||||
print(f"[{peer[0]}] GET_PRO {path!r} {tag} -> {pro:#04x}"
|
||||
@@ -282,13 +380,13 @@ def main():
|
||||
|
||||
if cmd == CC_GET_DIR:
|
||||
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
|
||||
blk = dir_block(real)
|
||||
# The client reads blocks until one comes back shorter than the
|
||||
# first; so the whole listing, then an empty reply.
|
||||
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")
|
||||
continue
|
||||
|
||||
@@ -302,12 +400,12 @@ def main():
|
||||
fh = open(real, "rb")
|
||||
fh_path = real
|
||||
except OSError as e:
|
||||
s.sendto(pack(CC_ERR, SERVER_KEY, seq, pos,
|
||||
f"{e}\0".encode()), peer)
|
||||
reply(ctx, pack(CC_ERR, SERVER_KEY, seq, pos,
|
||||
f"{e}\0".encode()), "ERR")
|
||||
continue
|
||||
fh.seek(pos)
|
||||
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)
|
||||
reads += 1
|
||||
now = time.time()
|
||||
@@ -326,7 +424,7 @@ def main():
|
||||
continue
|
||||
|
||||
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__":
|
||||
|
||||
Reference in New Issue
Block a user