Netboot¶
panio netboots a target over a direct USB-Ethernet cable, with its own DHCP, TFTP (the boot-ROM file protocol), and HTTP servers. No router, switch, or upstream DHCP server is involved.
netbootd picks its reply from the client's DHCP vendor class (option 60):
| Client | How it boots | Served over |
|---|---|---|
| Raspberry Pi 5 bootloader | no vendor class → legacy reply | TFTP |
| UEFI PXE client (e.g. EDK2 on an Indiedroid Nova) | PXEClient → bootfile + PXEClient echo |
TFTP |
| UEFI HTTP Boot client | HTTPClient → http:// URL + HTTPClient echo |
HTTP |
For UEFI clients, prefer HTTP Boot where the firmware allows plain HTTP: it is faster and robust under host load. Many EDK2 builds allow only HTTPS; on those, use PXE. See UEFI clients.
Hardware setup¶
- Plug a USB-to-Ethernet adapter into your Mac.
- Cable the adapter directly to the target's Ethernet port (no switch needed).
- Find the macOS interface name:
Target configuration¶
Config lives in the lab file (see config-redesign.md),
as a per-target netboot channel:
# Create the target, then configure its netboot channel
panio target add target-machine
panio netboot set -t target-machine \
--interface en3 \
--tftp-root ~/src/fuchsia/pxe/tftp-root
# List candidate USB-Ethernet interfaces (primary NIC excluded)
panio netboot devices
# Show all configured targets / a specific one
panio target show
panio target show target-machine
# Remove the netboot channel, or the whole target
panio netboot rm -t target-machine
panio target rm target-machine
netboot channel fields:
| Field | Default | Description |
|---|---|---|
--interface |
(required) | USB-Ethernet interface name (e.g. en3). Every netbootd listener is pinned to it — see Interface pinning |
--host-ip |
192.168.99.1 |
Static IP assigned to the interface; also the TFTP/HTTP server address and the router the client is told about. The client's lease is derived from it (same /24, last octet 100 — 192.168.99.100 by default) — see Lease. The default is for the first link on a host only: every further link needs its own /24 — see One subnet per link |
--tftp-root |
(none) | Directory whose contents are served over TFTP and HTTP |
--boot-file |
kernel_2712.img |
Boot program (filename under the root, e.g. grubaa64.efi); served as a TFTP filename to PXE and wrapped in an http:// URL for HTTP Boot |
--http-port |
80 |
HTTP server port; also embedded in the HTTP Boot URL (omitted from the URL when 80). 0 binds an OS-assigned ephemeral port — the URL always carries the port actually bound, never a literal 0 |
--content-type |
application/octet-stream |
Content-Type for HTTP responses (UEFI treats octet-stream as an EFI application) |
--host |
target default | Lab host the channel lives on |
Power and DTR control live on the power channel (panio power set …, see
power.md).
Starting and stopping¶
Naming the target. Omit [target-machine] when only one target is
configured. The runtime verbs (start, stop, status, logs, tftp-root)
also accept -t/--target, but not both at once. The config verbs (set, rm)
require -t and take no positional.
start assigns the static host_ip to the interface and launches
netbootd, a single Rust binary serving DHCP, TFTP, and HTTP. No dnsmasq or
tftp-now is needed.
start watches netbootd for about two seconds. If it exits (port in use,
interface it cannot pin, bad client IP), start fails with the last 20 lines
of its log (netbootd exited with … during startup; last lines of
~/.local/share/panio/<name>/netboot.log: …) and writes no state file.
stop sends SIGTERM (via sudo kill when the daemon is root's), waits up
to 3 s, SIGKILLs a holdout and waits 2 s more, then clears the state file and
restores the interface. It signals only a pid whose command line still names
netbootd. If the daemon survives SIGKILL, stop fails and keeps the state
file.
One netboot per interface. start refuses a second target on an interface
whose netbootd is alive (netboot for '<other>' is already running on <iface>
… stop it first). Give each target its own adapter.
One subnet per link¶
Several targets can netboot from one control host, one adapter each, as long as every link is in its own /24. Otherwise ssh, the AMT helper, ffx and fastboot follow the routing table out whichever interface the kernel listed first, and the target is reachable only some of the time.
The default 192.168.99.1 is for the first link on a host. Every further
link sets --host-ip in an unused /24 (192.168.100.1, 192.168.101.1, …);
the client lease follows.
panio enforces this:
netboot setrefuses a link whose /24 another target already uses on a different interface of the same host, default included:target 'pi4' netboot: host_ip 192.168.99.1 (the default, since none is set) puts eth4 in 192.168.99.0/24, which target 'optiplex' already uses on eth3 of host 'bench1'. An existing clashing lab file still loads (sonetboot stopworks);panio doctorreports it asCONFLICT.netboot startandnetif mode linkrefuse when any other interface on the host holds that /24 now:refusing to put 'eth4' in 192.168.99.0/24: 'eth3' already holds 192.168.99.1 on this host. Release it (panio netif mode off <target>) or pick another subnet.panio doctorreportsMISMATCHwhen the interface holds IPv4 addresses but not the effective host IP (a stalenetif mode link, a hand-set address, an edit made while the daemon was down). Not a mismatch: no IPv4 at all (netif mode offor IPv6-onlymode ffx), or the configured address alongside another. This applies to both local and remote checks; a successful empty address listing is not an unreachable host. Fix withpanio netif mode link <target>ornetboot start <target>.target showandnetboot statusprint the host IP, marked(default)when unset.
Two targets sharing one adapter (re-cabled between boards) may keep the same address.
macOS and Linux can serve multiple netboot links concurrently, each on
its own adapter and /24. On macOS, listeners use SO_REUSEPORT (shared port
binding) alongside IP_BOUND_IF (interface pinning). A process-held lock per
interface rejects duplicate netbootd instances even when launched directly,
and releases automatically on exit or crash. Its file under
/tmp/panio-netboot-interface-<index>.lock deliberately remains after exit;
do not delete it while a daemon is running. Linux retains exclusive
SO_BINDTODEVICE binds without port sharing. When upgrading from an older
macOS daemon, stop it before starting multiple links: its sockets do not
permit port sharing.
Privileges and sudo¶
Privileged ports (67/69, and 80 by default). On macOS only assigning the
static IP needs sudo. On Linux, start runs netbootd and ip addr add
under sudo. Configure NOPASSWD sudo on the control host for unattended
use. To avoid a privileged HTTP bind, set --http-port high (e.g. 8080); the
boot URL follows it.
netbootd gives root back (Linux). After binding and pinning, before serving,
netbootd drops to the invoking user (SUDO_UID/SUDO_GID) with
setgroups/setgid/setuid, and refuses to start if any fails. The log
records dropped privileges to uid N gid M. The ARP pin (ip neigh) and the
interface-IP monitor (ip addr add) then run through sudo as that user, so
passwordless sudo is required on Linux. Started as root without sudo,
netbootd stays root and logs it. On macOS netbootd never has root.
HTTP is optional; DHCP and TFTP are not. If the HTTP port cannot be bound,
netbootd logs a warning and keeps serving DHCP + TFTP (Pi and PXE still work).
An HTTPClient request then gets no offer, with a rate-limited log warning.
Free the port or set --http-port. A DHCP or TFTP socket that cannot be bound
or pinned is fatal.
Interface safety. start refuses an interface that carries the system
default route; reconfiguring it would break your networking. Use a dedicated
USB-Ethernet adapter.
Interface pinning¶
netbootd leases to any DHCP client and serves the TFTP root to anyone, so every
listen socket (DHCP 67, TFTP 69, HTTP) is pinned to the netboot interface
before binding: IP_BOUND_IF on macOS, SO_BINDTODEVICE on Linux. Traffic on
other interfaces is never seen, and replies leave only via the netboot link.
A pin that cannot be applied is fatal, which is why netbootd's --interface
flag is required. Sockets bind the wildcard address, so they survive link
flaps.
TFTP reply sockets are pinned too, on both platforms. On Linux they are
created after netbootd drops root (see netbootd gives root
back); SO_BINDTODEVICE has not needed CAP_NET_RAW
since Linux 5.7 (Pi OS Trixie 6.x, Debian 12 6.1 are fine). On an older kernel
the pin fails with EPERM/EACCES; netbootd logs one warn! and binds the
reply socket to the interface IP only. Either way the reply socket binds to
host_ip, not the wildcard.
Without the reply pin, two links in the same /24 send OACK and DATA out the
wrong interface and the log repeats no ACK for OACK. To verify: put one
target at 192.168.99.1/24 in netif mode
link, panio netboot start a target
on the other link, and PXE-boot it; the log should show completed <file>.
Just the link, no daemon. To assign or release the host IP without serving
anything, use panio netif mode link and panio netif mode
off. "Off" does not force the carrier down (Wake-on-LAN keeps it up). See
Link mode.
The netbootd engine¶
On macOS, netbootd sends raw frames through a /dev/bpf descriptor (BPF, the
raw-frame kernel interface), needed for reliable delivery on Sequoia.
panio setup (one sudo) installs a tiny setuid-root helper,
netbootd-bpf-helper, that opens /dev/bpf, binds the interface, and hands
the descriptor to the unprivileged netbootd. It is the only panio component
that runs as root. If it is missing or not setuid, netbootd warns and falls
back to the kernel send path, which is unreliable on macOS 15+.
The helper is restricted:
- Only the installing user may invoke it (root excepted); others get
refused: caller uid N is not the installing user (uid M). - It refuses the default-route interface (the one
route -n get defaultnames):refused: <iface> carries the default route. - The descriptor is write-only (
O_WRONLY) with a reject-allBIOCSETFfilter, so the daemon can inject frames but not capture.
A failed handoff is logged with the helper's message (BPF handoff failed
(netbootd-bpf-helper exited with exit status: 1: refused: …)).
Status and logs¶
panio netboot status [target-machine] # running? interface? uptime?
panio netboot logs [target-machine] # tail the combined DHCP + TFTP log
panio netboot logs -f [target-machine] # follow
Getting the TFTP root path¶
Prints the bare path, for shell substitution:
TFTP_ROOT=$(ssh control-mac "panio netboot tftp-root target-machine")
scp kernel_2712.img control-mac:"${TFTP_ROOT}/kernel_2712.img"
An unset or empty tftp_root exits 3 (not_configured), with the target,
netboot channel, and channel host in the JSON error context. netboot start
and netif mode netboot reject the same missing configuration before touching
the interface or starting a daemon. panio doctor [target] reports it as
MISSING and exits 1; these checks never create a directory or edit the lab.
Set the path explicitly with panio netboot set -t <target> --tftp-root <dir>.
Expected TFTP sequence for Raspberry Pi 5¶
The 404s are normal:
404 <serial>/<mac>/start.elf ← Pi 5 doesn't need it; 404 expected
200 config.txt
200 bcm2712-rpi-5-b.dtb
200 kernel_2712.img ← your boot shim or kernel
The TFTP root must contain at least config.txt, bcm2712-rpi-5-b.dtb, and
kernel_2712.img.
UEFI clients (PXE / HTTP Boot)¶
netbootd serves UEFI PXE and HTTP Boot (IPv4) from the same channel. You
only configure the boot program (NBP, network boot program):
panio netboot set -t indiedroid \
--interface en7 \
--tftp-root ~/indiedroid/boot-root \
--boot-file grubaa64.efi # any UEFI NBP: grubaa64.efi, ipxe.efi, a UKI…
panio netboot start indiedroid
PXE (hardware-verified). Pick UEFI PXEv4 in the boot menu. A
PXEClient (arch 11 = ARM64 UEFI) gets the TFTP reply, a PXEClient echo,
and DHCP option 43 with PXE_DISCOVERY_CONTROL=0x08, which tells it to
boot boot_file directly (strict EDK2 otherwise prints "no valid offer
returned"). The log shows RRQ <boot_file> … completed.
HTTP Boot. Pick HTTP Boot (IPv4). An HTTPClient (arch 19 = ARM64
UEFI HTTP) gets the HTTPClient echo and an
http://<host_ip>[:<http_port>]/<boot_file> URL in option 67.
panio netboot logs -f indiedroid shows the DISCOVER (carrying
HTTPClient:Arch:00019), the offer, then HEAD + GET /grubaa64.efi. HTTP
Boot uses ordinary kernel TCP: no /dev/bpf, setuid helper, or static ARP
entry.
Many EDK2 builds reject plain HTTP. With
PcdAllowHttpConnections=FALSE(the default) the firmware reports "HTTPS only". netbootd has no TLS, so on such firmware (e.g. the Indiedroid Nova) use PXE.
IPv6 and HTTPS are not supported. See
notes/uefi-http-boot-design.md
for the design and IPv6 future work.
DHCP / TFTP behavior notes¶
Lease. One fixed lease derived from host_ip: same /24, last octet
100 (or 101 when the host is .100). 192.168.99.1 leases
192.168.99.100; 10.20.30.1 leases 10.20.30.100.
- Mask
255.255.255.0, routerhost_ip. netbootdaccepts a--client-ipoverride (in the host's /24, not the host, network or broadcast address, or it refuses to start). The lab file does not expose it.- Both
siaddrand DHCP option 66 are set tohost_ip(the Pi 5 EEPROM prefers 66). - Replies go to the limited broadcast
255.255.255.255, which EDK2 requires. - A DHCPREQUEST for any other address (option 50 or
ciaddr) gets a DHCPNAK. - A REQUEST addressed to another server (option 54) is ignored.
- Only Ethernet clients with a 6-byte hardware address are answered.
Single client. The first DISCOVER, or first REQUEST netbootd answers, locks
that MAC in for the life of the process. Any other MAC gets no reply, only a
rate-limited warning in netboot logs. DHCPINFORM, DHCPDECLINE, DHCPRELEASE,
and REQUESTs for another server cannot take the lock. To switch devices on a
link, stop and start netboot.
TFTP.
- Read-only (RFC 1350); negotiates
blksize/tsize. - One source only: requests from any IP but the lease are ignored.
- At most
MAX_TRANSFERS(4) transfers at once; extra requests are dropped, not queued. - Streaming and timeouts. Files stream block by block; each retransmit attempt has a one-second deadline, six attempts maximum.
- Repeated RRQ from the same client port replaces the transfer in flight
(still one of the
MAX_TRANSFERSslots). - Block size on macOS BPF is capped at 1468 bytes.
- No escapes. Only regular files whose real path is under the root are
served; a symlink out of the root gets TFTP
file not found/ HTTP 404.
HTTP. The server:
- sends exactly the announced
Content-Length(a growing file is cut; a shrinking one drops the connection); - closes HTTP/1.0 connections unless the client asks for keep-alive;
- drops a connection with no complete request within 10 s;
- serves at most 64 connections at once;
- sanitizes request names before logging them.
Both log to ~/.local/share/panio/<name>/netboot.log.
Switching to ffx-over-network? With NET-first boot order, a running netboot makes the next power-cycle TFTP-boot instead of the SD card. Use
panio netif mode ffxto stop netboot and ready the host IPv6 side in one step.
Runtime paths¶
| Purpose | Path |
|---|---|
| Daemon state (netbootd PID, uptime) | ~/.local/share/panio/<name>/netboot.json |
| Combined log | ~/.local/share/panio/<name>/netboot.log |
Known issue: TFTP responsiveness under host load¶
A heavily loaded control host can starve TFTP so the client (e.g. the Pi 5
EEPROM) times out. The stopgap is raising the server's priority with renice
to a negative nice value. netbootd has not yet been re-tested under load;
making the send path load-robust is future work.
This affects TFTP clients only (the Pi, UEFI PXE). UEFI firmware that allows plain HTTP can use HTTP Boot instead.