mirror of
https://git.k-space.ee/arti/vt1200.git
synced 2026-10-30 12:03:54 +02:00
harness/serve.py: optional BOOTP responder (--bootp) so the VT1200 gets an IP at boot
The terminal broadcasts an RFC 951 BOOTP request at power-up. Add an opt-in responder that answers it with yiaddr 192.168.99.2 and siaddr 192.168.99.1, echoing htype/hlen/xid/chaddr. It offers no boot file: the request's sname and file are empty, so the reply's are too. Off by default -- a bare 'serve.py 192.168.99.1' behaves exactly as before (TFTP on port 69, nothing else). Enable with --bootp. Delivery note: the host IP stack never receives the source-0.0.0.0 broadcast, so the responder attaches to the MAME tap's L2 (AF_PACKET) and writes the reply frame back. See harness/BOOTP_REPORT.md for the evidence and the still-open 'User settings invalid' / 'Disk full' symptoms.
This commit is contained in:
1 parent
d4a3ccc42c
commit
264d5330ad
2 files changed
+359
-4
No files matched your search
@@ -0,0 +1,119 @@
|
||||
# BOOTP responder for the VT1200 (`harness/serve.py --bootp`)
|
||||
|
||||
Date: 2026-10-16. Scope: `harness/serve.py` only (a new optional responder).
|
||||
`harness/mame/vt1200.cpp` was read, not touched; the ROM is byte-identical
|
||||
(`md5 b46a3e442ccb86134d2356bd70d22fd2`).
|
||||
|
||||
## What was added
|
||||
|
||||
`serve.py` can now answer the BOOTP request the terminal broadcasts at power-up,
|
||||
so its IP address no longer has to be typed in at the Terminal Manager.
|
||||
|
||||
sudo python3 -u harness/serve.py 192.168.99.1 --bootp
|
||||
|
||||
`--bootp` is **off by default**. A bare `serve.py 192.168.99.1` behaves exactly
|
||||
as before: TFTP font serving on port 69 and nothing else (no port 67 socket, no
|
||||
AF_PACKET socket, no banner). Extra options: `--bootp-client` (default
|
||||
`192.168.99.2`), `--bootp-server` (default the TFTP host), `--bootp-iface`
|
||||
(default: auto-detect `tap-mess-*`).
|
||||
|
||||
## The request, as the terminal actually sends it
|
||||
|
||||
Captured on the tap (`tcpdump -i tap-mess-1000-0 'udp port 67'`) while MAME ran
|
||||
`run.sh net` headless. It is a plain RFC 951 request with **no** DHCP extensions:
|
||||
|
||||
```
|
||||
op=1 htype=1 hlen=6 hops=0
|
||||
xid=0xf81fccff secs=0,1024,2048,... flags=0x0000
|
||||
ciaddr=0 yiaddr=0 siaddr=0 giaddr=0
|
||||
chaddr = 08:00:2b:16:4b:5a:c1:86 00 00 00 00 00 00 00 00 (8 bytes!)
|
||||
sname = (empty) file = (empty)
|
||||
vend = 64 zero bytes (no 0x63825363 magic cookie)
|
||||
```
|
||||
|
||||
Two things worth noting. The request is sent from source `0.0.0.0`, UDP port 68
|
||||
to `255.255.255.255:67`, and `chaddr` carries **eight** bytes: the six-byte MAC
|
||||
plus the two checksum bytes the firmware read from its Ethernet address ROM. The
|
||||
`htype`/`hlen` (1/6) say six bytes are meaningful.
|
||||
|
||||
**It asked for no boot file.** `sname` and `file` are empty, so the reply leaves
|
||||
them empty too. No filename was invented.
|
||||
|
||||
## The reply
|
||||
|
||||
`op=2`, the `htype`/`hlen`/`xid`/`secs`/`flags`/`chaddr` echoed, `yiaddr` = the
|
||||
client address, `siaddr` = the server address, `ciaddr` and `giaddr` zero,
|
||||
`sname`/`file` empty, 300 bytes total (236-byte header + zero vend).
|
||||
|
||||
## Why it is an L2 responder (AF_PACKET), not a plain UDP socket
|
||||
|
||||
The host **IP stack never receives the request**. The terminal sends it from
|
||||
`0.0.0.0` into the MAME tap, which is enslaved to the `vt1200` bridge. With a UDP
|
||||
socket bound to `0.0.0.0:67` on the host, no datagram arrives (verified: a
|
||||
listener saw zero, and `net.ipv4.conf.vt1200.rp_filter=0` made no difference).
|
||||
The frame is, however, visible at the tap's L2 — which is where `tcpdump` sees
|
||||
it — and an `AF_PACKET` socket bound to `tap-mess-1000-0` does receive it. The
|
||||
responder therefore reads the tap at L2 and writes the reply frame back. This is
|
||||
a *delivery* choice; the packet is still an RFC 951 reply. (`ETH_P_ALL` is used
|
||||
because `ETH_P_IP` misses the bridged-in frames.)
|
||||
|
||||
The reply is a broadcast: `ff:ff:ff:ff:ff:ff`, source MAC = the bridge's MAC
|
||||
(`c6:fc:cc:16:f5:66`), IP `192.168.99.1` → `255.255.255.255`, UDP 67 → 68.
|
||||
|
||||
## Evidence the terminal adopted `192.168.99.2`
|
||||
|
||||
Server log (`--bootp`):
|
||||
|
||||
```
|
||||
bootp: L2 responder on tap-mess-1000-0: handing out 192.168.99.2,
|
||||
siaddr 192.168.99.1 (src mac c6:fc:cc:16:f5:66)
|
||||
bootp: request from 08:00:2b:16:4b:5a xid=f81fccff -> 192.168.99.2
|
||||
```
|
||||
|
||||
`tcpdump` on the tap (abridged):
|
||||
|
||||
```
|
||||
08:00:2b:16:4b:5a > ff:ff:ff:ff:ff:ff 255.255.255.255.68 > 255.255.255.255.67 BOOTP Request
|
||||
c6:fc:cc:16:f5:66 > ff:ff:ff:ff:ff:ff 192.168.99.1.67 > 255.255.255.255.68 BOOTP Reply Your-IP 192.168.99.2
|
||||
08:00:2b:16:4b:5a > ff:ff:ff:ff:ff:ff 192.168.99.2.1024 > 192.168.99.255.771 UDP, payload "RCP\0DEC VT1000\0..."
|
||||
```
|
||||
|
||||
The **only one** request (previously five retries over ~6 s) is itself the reply
|
||||
being accepted; and the next frame is sourced from `192.168.99.2`.
|
||||
|
||||
The terminal's own screen (frame dump, `VT1200_DUMP_FRAMES`), Terminal Manager:
|
||||
|
||||
```
|
||||
User settings invalid.
|
||||
Using local fonts.
|
||||
System defaults loaded.
|
||||
IP address set to 192.168.99.2 IP subnet mask set to 255.255.255.0.
|
||||
TCP/IP is now up.
|
||||
|
||||
Ethernet Address 08-00-2B-16-4B-5A IP Address 192.168.99.2
|
||||
```
|
||||
|
||||
## The two live symptoms
|
||||
|
||||
1. **"User settings invalid." / NVRAM does not persist — STILL PRESENT, not made
|
||||
moot by BOOTP.** BOOTP fixes only the address: the terminal now prints
|
||||
`IP address set to 192.168.99.2 ... TCP/IP is now up` instead of
|
||||
`Could not determine IP address after 5 retries / TCP/IP is now down`. The
|
||||
`User settings invalid. / System defaults loaded.` banner remains, and the
|
||||
emulated NVRAM file (`~/.mame/nvram/vt1200/nvram`) is written back all zeroes
|
||||
— a storage problem, and the driver holding it is out of scope here.
|
||||
|
||||
2. **Font fetch aborting with TFTP error 3 ("Disk full or allocation exceeded")
|
||||
— NOT REPRODUCED on this boot, and unrelated to BOOTP.** With settings
|
||||
invalid the terminal prints `Using local fonts.` and never configures a TFTP
|
||||
font path, so it issues **no** RRQ at all in a fresh run; the server log shows
|
||||
no TFTP request. The abort is a **client-side** condition (the terminal
|
||||
itself replies `ERROR ENOSPACE`), which a server-side change cannot cause or
|
||||
cure — as the task preamble anticipated. Reproducing it needs a configured
|
||||
TFTP font path, which needs the settings NVRAM to persist first.
|
||||
|
||||
## Default behaviour
|
||||
|
||||
A bare `serve.py 192.168.99.1` was run and checked: it prints only the TFTP
|
||||
banner, listens on `192.168.99.1:69`, and opens no port-67 / AF_PACKET socket.
|
||||
Unchanged.
|
||||
+240
-4
@@ -1,14 +1,27 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Serve VT1200 font files over TFTP.
|
||||
"""Serve VT1200 font files over TFTP, optionally with a BOOTP responder.
|
||||
|
||||
The terminal's "TFTP Font Path" is ``<host>:<filename>``; it requests that exact
|
||||
filename over TFTP and (if it parses) loads it as a font. This serves several
|
||||
plausible names so we catch whatever it asks for.
|
||||
|
||||
sudo python3 -u harness/serve.py [host] [port]
|
||||
|
||||
The VT1200 also broadcasts a BOOTP request (RFC 951) at power-up so it can learn
|
||||
its own IP address. ``--bootp`` adds a minimal responder for that, so the
|
||||
terminal no longer needs its address typed in by hand. It is **off by default**:
|
||||
without the flag the server behaves exactly as before. The responder answers
|
||||
*any* MAC that asks (the known terminal MAC is only used to label the log).
|
||||
|
||||
sudo python3 -u harness/serve.py 192.168.99.1 --bootp
|
||||
"""
|
||||
import argparse
|
||||
import fcntl
|
||||
import os
|
||||
import socket
|
||||
import struct
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
@@ -19,13 +32,224 @@ import fontgen
|
||||
|
||||
FONTS = os.path.join(ROOT, "reference", "fonts")
|
||||
|
||||
# The terminal's known Ethernet address (DEC OUI 08:00:2b). Used only to label
|
||||
# the BOOTP log; the responder answers whatever MAC sends a request.
|
||||
VT1200_MAC = "08:00:2b:16:4b:5a"
|
||||
|
||||
# --------------------------------------------------------------------------- BOOTP
|
||||
#
|
||||
# The terminal broadcasts a BOOTP request (op=1) at power-up to learn its own IP
|
||||
# address. It is sent from source 0.0.0.0 to 255.255.255.255:67 into the MAME
|
||||
# tap (``tap-mess-<uid>-0``, enslaved to the host bridge). A UDP socket bound to
|
||||
# 0.0.0.0:67 on the host never receives it (verified, with and without
|
||||
# rp_filter) -- but the tap's L2 does carry it, which is where tcpdump sees it.
|
||||
# So the responder reads the tap at the L2 layer (AF_PACKET) and writes the reply
|
||||
# frame back. This is a delivery-layer choice, not a protocol one: it is still
|
||||
# an RFC 951 reply.
|
||||
|
||||
BOOTP_REQUEST = 1
|
||||
BOOTP_REPLY = 2
|
||||
BOOTP_SERVER_PORT = 67
|
||||
BOOTP_CLIENT_PORT = 68
|
||||
ETH_P_ALL = 0x0003 # ETH_P_IP misses frames bridged in from the tap
|
||||
BROADCAST_MAC = b"\xff" * 6
|
||||
BROADCAST_IP = "255.255.255.255"
|
||||
# Linux ifreq ioctls; the IPv4 address / netmask sit at bytes 20..24 and the
|
||||
# hardware address at 18..24 of the returned 16-byte-name + sockaddr buffer.
|
||||
SIOCGIFADDR = 0x8915
|
||||
SIOCGIFNETMASK = 0x891B
|
||||
SIOCGIFHWADDR = 0x8927
|
||||
|
||||
|
||||
def _ifreq(ifname, request):
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
try:
|
||||
return fcntl.ioctl(s.fileno(), request,
|
||||
struct.pack("256s", ifname.encode()[:15]))
|
||||
except OSError:
|
||||
return None
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def _iface_ipv4(ip):
|
||||
"""Return (ifname, ip, netmask) for the interface carrying *ip*, or None."""
|
||||
want = socket.inet_aton(ip)
|
||||
for _, name in socket.if_nameindex(): # (if_index, if_name)
|
||||
r = _ifreq(name, SIOCGIFADDR)
|
||||
if r is None or r[20:24] != want:
|
||||
continue
|
||||
m = _ifreq(name, SIOCGIFNETMASK)
|
||||
return name, r[20:24], (m[20:24] if m else b"\xff\xff\xff\x00")
|
||||
return None
|
||||
|
||||
|
||||
def _iface_hwaddr(ifname):
|
||||
r = _ifreq(ifname, SIOCGIFHWADDR) if ifname else None
|
||||
return r[18:24] if r is not None else None
|
||||
|
||||
|
||||
def _find_tap():
|
||||
"""The MAME tap is named ``tap-mess-<uid>-0``; find any tap-mess device."""
|
||||
names = [n for _, n in socket.if_nameindex() if n.startswith("tap-mess-")]
|
||||
for n in names:
|
||||
if n.endswith("-0"):
|
||||
return n
|
||||
return names[0] if names else None
|
||||
|
||||
|
||||
def _ip_checksum(data):
|
||||
if len(data) & 1:
|
||||
data += b"\x00"
|
||||
total = sum(struct.unpack(">%dH" % (len(data) // 2), data))
|
||||
while total >> 16:
|
||||
total = (total & 0xFFFF) + (total >> 16)
|
||||
return (~total) & 0xFFFF
|
||||
|
||||
|
||||
def MAC_BYTES(b):
|
||||
return ":".join(f"{x:02x}" for x in b)
|
||||
|
||||
|
||||
class BootpResponder:
|
||||
"""Minimal RFC 951 BOOTP responder, attached to the MAME tap's L2.
|
||||
|
||||
Answers only ``op=1`` requests, filling ``yiaddr`` (the client) and
|
||||
``siaddr`` (this server). It serves **no boot file**: the VT1200 request
|
||||
carries an empty ``sname``/``file``, so the reply's are empty too -- inventing
|
||||
a filename it never asked for would be a protocol fiction. It answers any
|
||||
MAC that asks; the known terminal MAC is only used to label the log.
|
||||
|
||||
The VT1200 sends to 255.255.255.255:67 from port 68, ``flags=0``, and no DHCP
|
||||
magic cookie, so the reply is a plain 300-byte RFC 951 broadcast on port 68.
|
||||
"""
|
||||
|
||||
def __init__(self, server_ip, client_ip, iface=None, log=None):
|
||||
self.server_ip = server_ip
|
||||
self.client_ip = client_ip
|
||||
self.log = log if log is not None else []
|
||||
self._server_ip_b = socket.inet_aton(server_ip)
|
||||
self._client_ip_b = socket.inet_aton(client_ip)
|
||||
info = _iface_ipv4(server_ip)
|
||||
self.server_iface = info[0] if info else None
|
||||
self.iface = iface or _find_tap()
|
||||
if self.iface is None:
|
||||
raise SystemExit(
|
||||
"bootp: no tap-mess-* interface found (is MAME's tap created?); "
|
||||
"pass --bootp-iface to name it")
|
||||
self.src_mac = (_iface_hwaddr(self.server_iface)
|
||||
or _iface_hwaddr(self.iface)
|
||||
or b"\x02\x00\x00\x00\x00\x01")
|
||||
self._stop = threading.Event()
|
||||
self._sock = None
|
||||
self._thread = None
|
||||
|
||||
def start(self):
|
||||
self._sock = socket.socket(socket.AF_PACKET, socket.SOCK_RAW,
|
||||
socket.htons(ETH_P_ALL))
|
||||
self._sock.bind((self.iface, 0))
|
||||
self._sock.settimeout(0.5)
|
||||
self._thread = threading.Thread(target=self._serve, daemon=True)
|
||||
self._thread.start()
|
||||
return self
|
||||
|
||||
def stop(self):
|
||||
self._stop.set()
|
||||
if self._sock:
|
||||
try:
|
||||
self._sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
if self._thread:
|
||||
self._thread.join(timeout=1)
|
||||
|
||||
def _serve(self):
|
||||
while not self._stop.is_set():
|
||||
try:
|
||||
frame, _addr = self._sock.recvfrom(2048)
|
||||
except socket.timeout:
|
||||
continue
|
||||
except OSError as exc:
|
||||
print(f"bootp: receive socket error: {exc!r}",
|
||||
file=sys.stderr, flush=True)
|
||||
return
|
||||
try:
|
||||
self._handle(frame)
|
||||
except Exception as exc: # never let one request kill it
|
||||
print(f"bootp: request error: {exc!r}", file=sys.stderr, flush=True)
|
||||
|
||||
def _handle(self, frame):
|
||||
if len(frame) < 14 + 20 + 8 + 236 or frame[12:14] != b"\x08\x00":
|
||||
return
|
||||
ip = frame[14:]
|
||||
if (ip[0] >> 4) != 4 or ip[9] != 17:
|
||||
return
|
||||
ihl = (ip[0] & 0x0F) * 4
|
||||
udp = ip[ihl:]
|
||||
sport, dport, ulen = struct.unpack(">HHH", udp[:6])
|
||||
if dport != BOOTP_SERVER_PORT:
|
||||
return
|
||||
payload = udp[8:ulen]
|
||||
if len(payload) < 236 or payload[0] != BOOTP_REQUEST:
|
||||
return
|
||||
mac = MAC_BYTES(payload[28:34])
|
||||
self._sock.send(self._frame(payload))
|
||||
self.log.append((time.time(), mac, payload[4:8].hex()))
|
||||
note = "" if mac == VT1200_MAC else " (not the known VT1200 MAC)"
|
||||
print(f"bootp: request from {mac} xid={payload[4:8].hex()} "
|
||||
f"-> {self.client_ip} (siaddr {self.server_ip}, "
|
||||
f"tap {self.iface}){note}", file=sys.stderr, flush=True)
|
||||
|
||||
def _frame(self, req):
|
||||
# RFC 951 fixed 236-byte header plus a zero 64-byte vend area = 300.
|
||||
bootp = bytearray(300)
|
||||
bootp[0] = BOOTP_REPLY
|
||||
bootp[1] = req[1] # htype, echoed
|
||||
bootp[2] = req[2] # hlen, echoed
|
||||
bootp[3] = 0 # hops
|
||||
bootp[4:8] = req[4:8] # xid, echoed
|
||||
bootp[8:10] = req[8:10] # secs, echoed
|
||||
bootp[10:12] = req[10:12] # flags, echoed
|
||||
# ciaddr and giaddr stay zero; this is not a relay.
|
||||
bootp[16:20] = self._client_ip_b # yiaddr
|
||||
bootp[20:24] = self._server_ip_b # siaddr
|
||||
bootp[28:44] = req[28:44] # chaddr, echoed verbatim
|
||||
# sname[44:108] and file[108:236] stay empty: the client asked for no
|
||||
# boot file, so none is offered.
|
||||
udp = struct.pack(">HHHH", BOOTP_SERVER_PORT, BOOTP_CLIENT_PORT,
|
||||
8 + len(bootp), 0) + bytes(bootp)
|
||||
ip = bytearray(struct.pack(">BBHHHBBH4s4s", 0x45, 0,
|
||||
20 + len(udp), 0, 0, 64, 17, 0,
|
||||
self._server_ip_b,
|
||||
socket.inet_aton(BROADCAST_IP)))
|
||||
struct.pack_into(">H", ip, 10, _ip_checksum(bytes(ip)))
|
||||
return BROADCAST_MAC + self.src_mac + b"\x08\x00" + bytes(ip) + udp
|
||||
|
||||
|
||||
def load(name):
|
||||
with open(os.path.join(FONTS, name), "rb") as fh:
|
||||
return fh.read()
|
||||
|
||||
|
||||
def main():
|
||||
def main(argv=None):
|
||||
ap = argparse.ArgumentParser(
|
||||
description="Serve VT1200 fonts over TFTP (optionally answer BOOTP).")
|
||||
ap.add_argument("host", nargs="?", default="100.103.255.254",
|
||||
help="address to bind the TFTP server to (default: %(default)s)")
|
||||
ap.add_argument("port", nargs="?", type=int, default=69,
|
||||
help="TFTP port (default: %(default)s)")
|
||||
ap.add_argument("--bootp", action="store_true",
|
||||
help="also answer BOOTP requests so the terminal learns its "
|
||||
"IP address at power-up (off by default; needs root)")
|
||||
ap.add_argument("--bootp-client", default="192.168.99.2",
|
||||
help="address to hand the client (default: %(default)s)")
|
||||
ap.add_argument("--bootp-server", default=None,
|
||||
help="siaddr for the reply (default: the TFTP host)")
|
||||
ap.add_argument("--bootp-iface", default=None,
|
||||
help="MAME tap interface to attach to on L2 "
|
||||
"(default: auto-detect tap-mess-*)")
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
baseline = load("baseline.pcf")
|
||||
evil = load("evil-real.pcf")
|
||||
probe = load("probe-uniq.pcf")
|
||||
@@ -50,8 +274,8 @@ def main():
|
||||
if os.path.exists(os.path.join(FONTS, "fonts.dir")):
|
||||
files["fonts.dir"] = load("fonts.dir")
|
||||
|
||||
host = sys.argv[1] if len(sys.argv) > 1 else "100.103.255.254"
|
||||
port = int(sys.argv[2]) if len(sys.argv) > 2 else 69
|
||||
host = args.host
|
||||
port = args.port
|
||||
def resolve(name):
|
||||
# The terminal first fetches the bare path, then "<dir>/fonts.dir".
|
||||
# Accept ANY name: the index for fonts.dir, the payload otherwise.
|
||||
@@ -73,6 +297,16 @@ def main():
|
||||
resolver=resolve).start()
|
||||
print(f"serving {len(files)} names on {srv.address}", flush=True)
|
||||
print(" names: " + ", ".join(sorted(files)), flush=True)
|
||||
|
||||
bootp = None
|
||||
if args.bootp:
|
||||
bootp = BootpResponder(
|
||||
server_ip=args.bootp_server or host,
|
||||
client_ip=args.bootp_client,
|
||||
iface=args.bootp_iface).start()
|
||||
print(f"bootp: L2 responder on {bootp.iface}: handing out "
|
||||
f"{bootp.client_ip}, siaddr {bootp.server_ip} "
|
||||
f"(src mac {MAC_BYTES(bootp.src_mac)})", flush=True)
|
||||
try:
|
||||
while True:
|
||||
time.sleep(0.5)
|
||||
@@ -84,6 +318,8 @@ def main():
|
||||
pass
|
||||
finally:
|
||||
srv.stop()
|
||||
if bootp:
|
||||
bootp.stop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in new issue
Block a user