diff --git a/scripts/fsp-server.py b/scripts/fsp-server.py index 884d732..d6f0ca4 100755 --- a/scripts/fsp-server.py +++ b/scripts/fsp-server.py @@ -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__":