feat: the whole boot chain, and a directory per identity - #2
Merged
Conversation
Phase 1 of plans/boot-media.md, the part with no wiring: reading an image, placing it, listing what is held, and writing the boot stanza each family needs. All dependency-free, as the plan's budget requires. - `iso.rs` reads ISO9660 far enough to turn a path into an offset and a length. A file in an image is one contiguous extent, so "extract the kernel" is a seek — nothing is unpacked and nothing is copied. Rock Ridge names win over the mangled identifiers, which is what makes `auto-installer-mode.toml` findable at all. Its test builder writes images in memory: no binary fixture in the repository. - `probe.rs` places an image from a table of markers. `/.disk/info` is read first, and Proxmox is why: `prepare-iso --pxe` strips `/boot` from the ISO it emits, so the obvious marker misses exactly the image most likely to be dropped into a media directory. Reading that file is upstream's own identification method. A trimmed image also finds the vmlinuz and initrd.img the assistant left beside it. - `catalog.rs` discovers rather than declares, cached behind the directory mtime with the same backstop the answer listing uses. AppleDouble entries are skipped from the first commit; reserved names are refused rather than shadowing a route. - `stanza.rs` holds what each family needs on the wire. The Proxmox stanza is upstream's own output, `proxmox-start-auto-installer` included — the plan's table said Proxmox needs nothing on the command line, and that is wrong: without that parameter a machine boots the interactive installer. - `sha256.rs` and `cpio.rs`, hand-written, for digests and for `initrd+iso`. 45 tests, twelve of them watched failing first — two were real bugs, a `locate` that returned a sentinel instead of None and a SHA-256 `update` that dropped a partial block. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012TSRpBJgtVZg1T9nPQWbbL
Completes Phase 1 of plans/boot-media.md. A machine can now fetch the installer itself — kernel, initrd, image — from the same server that decides its answer, and the two cannot drift apart because one component knows both. The listener is its own socket, and that is forced rather than preferred: the answer endpoint answers on any path, its whole-connection deadline is ten seconds where a 1.5 GB transfer is two minutes, and its connection semaphore would be held for minutes by a download. `tests/media.rs` proves the consequence rather than asserting it — answers keep succeeding with four transfers in flight. Ranges, ETag, If-Range, HEAD and 416 are all here because real clients need them: five of the seven installers range-fetch, and UEFI HTTP Boot sends HEAD before it fetches. `initrd+iso` is synthesised on the wire — initrd, a cpio header naming proxmox.iso, the image — so old loaders work without a second 1.5 GB file on disk. Configuration gains RESCRIPTUM_PUBLIC_HOST (a host, never a URL — it is written into URLs for two listeners) plus the media directory, address, timeout, connection cap and CIDR allowlist. Media is off until a directory is named, so nothing changes for an existing deployment. Two bugs the tests caught rather than review: `HeaderName::from_static` panics on a name that is not lowercase, which killed the connection instead of answering 405; and the same-port guard refused two `:0` listeners, which can never collide because the kernel picks both. 93 new tests over the 333 that were here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012TSRpBJgtVZg1T9nPQWbbL
The plan asks for one feature covering media and TFTP, default on, so
`--no-default-features` still produces the smallest possible answer server.
It refuses loudly rather than ignoring a media directory it cannot serve —
the same shape `open_store` already uses for RESCRIPTUM_STORE=sqlite without
the sqlite feature.
Measured on armv7 (gnueabihf, floor 2.17), which is the target the budget is
written against:
sqlite + boot 2,602,056
sqlite only 2,482,000
neither 1,316,648
So boot costs 120,056 bytes. Two things worth recording: that is 71% of the
plan's ≤170 KB budget spent on Phase 1 alone, and the figures in CLAUDE.md
(2,103,456 / 944,928) are stale — they predate the switch from musl to
glibc, and taking them at face value made this look like a 293% overrun.
Measure before concluding.
Also fixes a silent hole in the self dev-dependency: without
`default-features = false` it re-enabled sqlite and boot for every test
build, so `cargo test --no-default-features` would have tested the full
binary and reported coverage that does not exist.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012TSRpBJgtVZg1T9nPQWbbL
The plan's own rule is that a gate must not be exceeded silently, so the budget finding goes in the plan rather than in a commit nobody re-reads: `boot` costs 120,056 bytes on armv7, which is 71% of the ≤170 KB allowance for Phase 1 alone. Phases 2 and 4 will not fit in what is left. Re-decide the figure or split the feature; do not drift past it. Three things the plan had wrong or open, now settled from sources: - The trimmed-Proxmox marker needed no bench. `inspect-iso` identifies an ISO by `/.disk/info` and PRODUCTLONG, and `--pxe` does not strip it. - Proxmox does need something on the kernel command line after all — `proxmox-start-auto-installer`. Without it the machine boots the interactive installer. - `;` separates iPXE commands only as a whole whitespace-delimited token, so Ubuntu's NoCloud argument needs no escaping. Read out of `core/exec.c`. CLAUDE.md gains the boot layout, the six new variables, the real sizes and four traps that each cost a red test — including that `HeaderName::from_static` panics on a name that is not lower-case, and that the size figures in that file go stale when a target changes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012TSRpBJgtVZg1T9nPQWbbL
A user-visible change lands with its documentation, so Phase 1 gets its page: getting an image in, what the probe can tell about it, the endpoints, why the listener is a second socket, generating a boot stanza, and why RESCRIPTUM_PUBLIC_HOST is a host and never a URL. The scope statement in the guide index needed correcting rather than extending. "Not a PXE/TFTP/DHCP server" was one claim doing three jobs, and one of them has stopped being true: DHCP stays a non-goal in any form, TFTP is not here *yet*, and the installer's kernel, initrd and image are served now. Saying so in one bullet would have been vague where it used to be exact. Configuration and CLI references gain the six variables and the four commands; the compile-time table gains `boot` and real ARMv7 figures, with the note that the old ones were stale. `notabene lint` is green — and note it lints against the last public build, so `docs:build` has to run first or a new page reads as a broken link. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012TSRpBJgtVZg1T9nPQWbbL
…ding Phase 2's first half. TFTP is core rather than optional — an appliance that needs somebody else's TFTP server is not an appliance — and it hands over exactly one file. At 1468 bytes a round-trip an image would take twenty minutes where HTTP takes fifteen seconds, so the rule is written into the module and into the root it is pointed at: the loader, and then HTTP. One table maps option 93 to a loader, and both the TFTP server and (next) `boot dhcp-snippet` read it, so what an operator pastes into their DHCP server and what this one hands out cannot drift. It carries the recorded exception the registry alone would get wrong: RFC 4578 called 0x0009 x86-64, IANA calls it EBC, and real firmware sends either. `tests/tftp.rs` speaks the protocol over real UDP, and it earned its keep immediately by finding two bugs of the "works by hand, never after a reboot" kind: - A file whose length is an exact multiple of the block size never ended. A short block is what finishes a transfer, and such a file has none — so it must end with an *empty* one. Watched red with the defect restored. - The per-peer cap counted datagrams rather than transfers, so four stray packets locked an address out. That is not a hostility threshold: **a PXE ROM retransmits its read request** when an answer is slow, a sleeping NAS disk is enough to cause it, and each retransmission is a fresh transfer from the same address. Junk now costs no slot, the cap is eight, and a test boots six retransmissions to pin it. Privilege dropping is bind-then-drop, groups before gid before uid, and it verifies the drop by trying to undo it — a process that thinks it dropped and did not is worse than one that never tried. `libc` becomes a direct dependency and costs zero new crates: tokio already had it. 467 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012TSRpBJgtVZg1T9nPQWbbL
The milestone the goal is written against: one binary boots an arbitrary machine into a menu, and a known machine into an unattended install, on a network whose DHCP server gained two options and was otherwise not touched. The bootstrap is where "a menu is the default answer" actually lives, and it is one `||`: chain to the answer endpoint with the machine's identity in the query string, and fall through to the menu when nothing claims it. No new concept in select.rs, and it is served by the media listener because it has to work when the answer set is empty — the state every new install starts in. The menu is rendered from the catalogue per request rather than kept in sync as a file, so an ISO dropped in the directory is in the menu on the next fetch. `item local` is first and the timeout falls through to it: a machine that PXE-boots by accident ends up on its own disk rather than waiting for a human who is not coming. `boot dhcp-snippet` writes six formats from the same table TFTP serves from, and `tests/cli.rs` pins the two together — a snippet naming a loader the server does not hand out fails silently at the ROM. Two findings went into it: a Windows policy cannot condition on option 93 at all (the architecture reaches it only inside the option 60 string), and Kea's own documentation says to prefer `user-context` over `#` comments because "most JSON tools detect them as errors". 507 tests. **The size budget is now exceeded and the plan records it rather than passing quietly**: `boot` costs 205,368 bytes on armv7 against a ≤170 KB allowance, with Phase 4 still unwritten. The number is not padding — it buys an ISO reader, a catalogue, a media listener, TFTP, a menu and six configuration generators — and the budget was set before any of it existed. The gate's job was to make that visible before a release. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012TSRpBJgtVZg1T9nPQWbbL
…uns it The last piece of Phase 2 that is not Rust: what TFTP actually hands out. `branding.h`, the embedded script, a SHA-pinned upstream commit, a build script and a CI job that builds all six loaders and then asks the server whether the set satisfies the table it serves from. **Written, not yet built, and the README says so in its first heading.** The pin was chosen from upstream's tag list and the make targets from upstream's documentation; neither has been compiled here. The first CI run is what turns that from plausible into verified, and `PINNED` records the fallback commit for the likely case that v2.0.0's major bump does not build cleanly. Two decisions worth keeping: - **The embedded script's port is a contract**, not a preference. It can read no configuration — it is baked in before any deployment exists — so 8001 is as fixed there as an answer URL baked into an ISO. `boot check` warns when the configured port has moved away from it. - **`PRODUCT_ERROR_URI` is deliberately left pointing at ipxe.org.** That database turns a 32-bit error code into a sentence and links the line of code that raised it. Redirecting it at us would replace a working diagnostic service with nothing, and the person staring at a hex code at 3am is exactly who it exists for. `PRODUCT_SHORT_NAME` stays "iPXE" for upstream's own stated reason. The GPL obligation is met by construction rather than bolted on: the loaders are separate files never linked into an MIT binary, and this directory is the written offer for their source. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012TSRpBJgtVZg1T9nPQWbbL
Phase 2's page: the four links from power-on, which of them are ours, the two lines their DHCP server needs, why the loader has to carry an embedded script at all, and what a machine actually sees. Three things it says plainly because they are the ways this fails quietly on somebody else's network: a UEFI HTTP Boot client discards an offer that does not echo `HTTPClient` in option 60, a Windows DHCP policy cannot condition on option 93 at all, and a snippet naming a loader that is not on disk fails silently at the ROM with nothing on any console. The scope statement needed another correction. "Not a TFTP server — not yet" was true for one commit; it is now a TFTP server, and the honest remaining non-goal is DHCP in any form. The blast-radius table moves into the guide too, because "a boot server" sounds load-bearing and is not: nothing this installs depends on it afterwards. The loaders page carries a warning rather than instructions that would not work — `packaging/ipxe/` is written and unbuilt, and the guide says so where somebody would otherwise go looking for a download. `notabene lint` green over 76 files. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012TSRpBJgtVZg1T9nPQWbbL
Phase 3's harness: three services on a network with `internal: true`, so a rig that runs a DHCP server is structurally unable to answer anything on the host's LAN — the same "did installing this break the network" hygiene the product lives by. No /dev/kvm anywhere: it has to pass under TCG, because the development machine is a Mac. Two markers, both deterministic and neither a screenshot. An unclaimed machine must reach a disk whose only content is a boot sector that prints a magic string to the serial console — proving it went through DHCP, the loader, the bootstrap and the menu, found nothing claiming it, and fell through. A claimed machine's answer fetches a sentinel, so that assertion is a line in the *server's* log rather than something the client printed. dnsmasq is configured from `boot dhcp-snippet`'s own output, which makes the rig a test of the snippet too: if what we tell operators to paste is wrong, nothing boots. **Building the loaders for the rig verified the whole packaging half**, and found two real bugs on the way: - The build must be amd64. iPXE's BIOS targets are 32-bit x86, and an ARM64 host's gcc produces a wall of `-m32` errors that reads like a broken Makefile. - ARM64 needed `CROSS_COMPILE=aarch64-linux-gnu-`, absent from the first version — the host compiler was used and died on `-mlittle-endian`. - The ISO and USB targets were being asked for in the EFI build directory rather than the BIOS one, and failed silently into a `||`. The pinned commit now produces all eight loaders; `strings` finds PRODUCT_NAME, PRODUCT_URI and `embed.ipxe` verbatim in the EFI builds; and `rescriptum boot check` agrees the set satisfies the loader table. What remains unproven is what firmware does with them, which is the rig and then real hardware — the standing rule that nothing ships on harness evidence alone is unchanged, and both READMEs say where the line is. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012TSRpBJgtVZg1T9nPQWbbL
Phase 4, and the last external tool goes. Proxmox reads `/auto-installer-mode.toml` from the mounted image to learn where its answer lives; every other family takes a URL on the kernel command line. Adding that one file used to mean `proxmox-auto-install-assistant prepare-iso`. It is tractable because **on the PXE path the image is never booted, only mounted** — the requirement is "still a readable ISO9660 filesystem exposing one more file", which is a far weaker problem than the one xorriso solves. An ISO9660 file is a contiguous extent, so adding one is three small overwrites and an append: the content past the end, a directory record in the slack at the end of the root extent, and the volume space size in both descriptors. So this produces a *plan* rather than a file — offsets and bytes, applied while streaming. No second copy on disk, the source never mutated so its published digest stays verifiable, ranges still work because the arithmetic is trivial, and changing the answer URL recomputes 300 bytes. The trap that decides whether it works is Rock Ridge: `auto-installer-mode.toml` is not a legal ISO9660 identifier, so the record is called `AUTO_INS.TOM;1` and the installer would never find its file. The real name lives in an `NM` entry, and in the Joliet tree too when there is one — which tree a mount reads is not ours to decide. With neither, this refuses, and refusing is complete: the fallback is one command on any Debian box whose output this server is happy to serve. A UDF image is refused outright, because patching the ISO9660 tree of a Windows ISO produces something that looks right and is not. Watched red: removing the `NM` entry turns `a_file_added_to_an_image_reads_back_under_its_real_name` red, which is the whole trap in one test. Also removes two duplicate fields clippy caught in the ISO reader — I had added `root_extent` beside the existing `root`, which is exactly the "two copies drift" failure this codebase warns about. 524 tests. The boot feature now costs 227,840 bytes on armv7 — 134% of the budget, recorded in the plan with the per-phase breakdown. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012TSRpBJgtVZg1T9nPQWbbL
The media guide gains `media prepare` and `media export` in both languages — what a sidecar is, why nothing is copied, when it refuses and what the way out is, and why a source that changed underneath is refused rather than patched in the wrong place. CLAUDE.md gains the boot layout as it now stands, the re-measured sizes, and eight traps that each cost something to find: - a PXE ROM retransmits its read request, so a per-peer cap is a fairness bound rather than a hostility threshold; - a TFTP transfer ends on a short block, and "short" includes empty; - `;` separates iPXE commands only as a standalone token; - `net0` is the first NIC rather than the booting one, and iPXE percent-encodes nothing on plain expansion; - a UEFI HTTP Boot client discards an offer that does not echo `HTTPClient`, and a Windows DHCP policy cannot condition on option 93 at all; - `auto-installer-mode.toml` is not a legal ISO9660 identifier; - iPXE's BIOS targets need an x86 compiler and its ARM64 ones a cross prefix. Also deduplicates a `dhcp-boot` line the generator emitted twice, because 0x0007 and 0x0009 share a tag. dnsmasq ignores the second; an operator reads the file. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012TSRpBJgtVZg1T9nPQWbbL
…he archive No installer image is in this repository or in a release, and now the server can go and get one: `media add <url> --sha256 …` fetches straight into the media directory. There is still no TLS in the binary — forty crates and a megabyte on ARMv7 for a job every host already has a tool for — so this shells out to curl or wget and says plainly when it finds neither, on the same precedent as `check` calling the Proxmox validator. Three properties, each a way it would otherwise go wrong: - **A download lands on a `.part` name and is renamed only once the digest matches.** The catalogue probes whatever it finds, so a partial download would become an entry — a truncated ISO probes as unknown, and a machine would try to boot it. An interrupted fetch leaves the part file and resumes. - **A URL requires `--sha256` unless `--unverified` is passed.** This decides what every machine on the network installs; an image pulled off a mirror with nothing checking it is the one place here that would be a shrug, so the unsafe path is a deliberate flag rather than the default. A local file keeps the digest optional — the operator already had it. - **A fetch never overwrites an existing image.** Machines may be booting it. The consequence worth naming, and now said in the guide, in CLAUDE.md and in the plan: **the media directory is the archive.** Nothing modifies an image after it lands — preparing one produces a sidecar plus an injection applied on the wire — so the bytes on disk stay exactly what the vendor published and their digest stays checkable against the vendor's own SHA256SUMS. `media list` grew a SOURCE column so which entries are the archive and which derive from it is visible rather than inferred. Also adds a `.dockerignore`. The boot rig's build context was **6 GB** — `target/` alone was most of it — and every run spent two minutes transferring artefacts the image rebuilds from scratch anyway. A rig nobody waits for is a rig nobody runs. 536 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012TSRpBJgtVZg1T9nPQWbbL
Two failures from actually running the rig, and both were invisible until it ran: - `iproute2` was missing from the client image, so `ip` was not there to bridge eth0 and QEMU would have booted with no network at all. Five `ip: command not found` lines scrolled past and the script carried on, because every `ip` call tolerated its own failure and the guard at the bottom of the function never noticed. It now checks for `ip` up front and proves `tap0` exists rather than trusting that nothing printed an error. - `docker compose run` reuses whatever image is already there, so the client image was never rebuilt and the fix above silently did not apply. `run.sh` now builds it explicitly, and the client leaves the `manual` profile so `build` can see it. Everything before QEMU already worked on the first real run: the DHCP configuration generated from the server's own snippet, the stack up on an isolated network, the branded loaders built inside the container, and `boot check` agreeing from inside the server that the set is complete. Also handles the one download failure that repeating will not fix: curl exits 33 when a mirror ignores the Range header, so resuming onto a partial file fails identically forever. The message now says to delete it rather than "run this again". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012TSRpBJgtVZg1T9nPQWbbL
The rig's own rule, broken by the rig: the network is `internal: true`, so nothing on it can reach an apt repository. dnsmasq was installed at run time, apt failed into `/dev/null`, and the container exited 127 with `exec: dnsmasq: not found` — a minute before a client was booted at it. It now has an image, like the loaders, for the reason the README already gave. The deeper failure is that nothing noticed. **A container that died looks exactly like one still starting**, so `run.sh` now checks every service is running before it boots anything and prints the log of any that is not. Without that the symptom was four minutes of QEMU and two missing markers, which reads as a broken boot chain rather than a broken harness. `media add <url>` also names a missing media directory itself rather than leaving curl to say "Failed to open the file", which points at a path instead of at the setting that produced it. The rig README gains a table of what the first four runs cost, because each is a shape that will recur. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012TSRpBJgtVZg1T9nPQWbbL
All four markers reached on a development machine under TCG: the DHCP handoff answered from `boot dhcp-snippet`'s own output, a loader fetched over TFTP, the unclaimed machine fell through to its local disk, and the claimed machine reached its own answer. That is the whole chain — DHCP, our branded loader, its embedded script, the bootstrap, the answer engine, and either an unattended answer or the menu. And it has been red for the right reasons, which is what makes green mean anything: deleting a loader stops the run at `boot check`, and deleting the claimed machine's answer turns that marker alone red while the machine still falls through to its disk — so the fallthrough covers a deleted answer too. **The rig's shape changed, and the measurement is why.** A QEMU guest bridged into a container has a MAC of its own, and Docker Desktop's virtual switch does not forward frames from a MAC it did not assign: container-to-container TCP works, and a DHCP broadcast from the guest reaches nothing at all — tcpdump on the receiving side captures zero packets while the client's own tap0 and eth0 counters show the frames leaving. So the primary rig is now one container on a private bridge with no uplink. Nothing crosses Docker's network, which is stronger isolation than the `internal: true` the plan asked for. The four-service variant stays as `run-compose.sh` for a Linux host. Five failures had to be fixed before it ran at all, and every one was invisible until it did: `iproute2` missing from an image; a client image never rebuilt, because `docker compose run` reuses whatever exists; dnsmasq installed at run time on a network that by design cannot reach apt; dnsmasq logging to syslog, where a container has none, so the DHCPACK marker could never have matched; and a disk attached with `if=ide` to a `q35` machine, which has no IDE controller — SeaBIOS said "could not read the boot disk" and it read as a broken menu. Also fixes `--help` in three scripts: `sed -n … "$0"` cannot find a relatively-invoked script after a `cd`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012TSRpBJgtVZg1T9nPQWbbL
The tripwire the plan asks for: BIOS only, TCG, one claimed and one unclaimed client, time-boxed, no OS image. The dev rig is what decides; this re-proves the chain on every push without a real machine, and it is sized so it cannot fail for capacity reasons. It also breaks a link deliberately and requires the break to show. A green rig that has never been red proves nothing, and the cheapest way to keep that true is to prove it on every run rather than to remember to do it by hand. CLAUDE.md gains the five traps the rig's first runs paid for, of which the one worth repeating is not technical: **a container that died looks exactly like one still starting**, and three of the five hid behind it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012TSRpBJgtVZg1T9nPQWbbL
The package now serves installer images from the same NAS that decides the answer. `RESCRIPTUM_MEDIA_DIR` is one uncommented line away, the share gains `media/` and `boot/` folders at start, and port 8001 is registered with the firewall beside the answer port — registering does not open it, and the alternative is an operator who enables media and then cannot find rescriptum in the rule editor. **TFTP is not in the package, and the env file says why rather than offering a setting that breaks.** Port 69 is privileged and DSM 7 does not let an unsigned package run as root, so `RESCRIPTUM_TFTP_ADDR` would produce a package that refuses to start. DSM has its own TFTP server and it is the right one here: point it at the share's `boot` folder, put the loaders there, and the chain continues on port 8001. DSM hands over one file; that is the whole of its part. `RESCRIPTUM_USER`/`_GROUP` are documented the same way — the package already is its own unprivileged user. `lifecycle-test.sh` caught a real defect on its first run, which is what it is for: the first version of this wrote a live `RESCRIPTUM_MEDIA_ADDR` with `RESCRIPTUM_MEDIA_DIR` still commented, and that combination is a startup error — **the package would not have started at all.** The address is now commented too (the default is already 8001), and three new guards pin it. Watched red: reintroducing the defect turns 54 green into 46 green and 8 red. The admin API's example moves off 127.0.0.1:8001, which the media listener now owns and which the server refuses as a collision. The DSM application needed no code: its field list comes from `config --json`, so the thirteen new variables already render. They needed labels and help in both languages — `check-spk.sh` keeps the two files in lockstep, and it passes. Both packages build and pass the structural check; the lifecycle harness runs 54 checks green in a Linux container. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012TSRpBJgtVZg1T9nPQWbbL
40 checks green on the VM, `on-dsm.sh` exit 0: installed, started, answered a machine with its own merged answer, the desktop application linked and its CGI refusing an unauthenticated request, logrotate rotating a live descriptor, an upgrade over a hand-edited env file leaving it untouched, and an uninstall leaving the share alone. The four new assertions are the on-machine evidence for boot media: the `media` and `boot` folders are created by the start script and writable by the package user. Confirmed by hand first — `drwxrwxrwx+ rescriptum` on the real volume — and now asserted, so it is tested rather than observed once. `check-spk.sh` also asserts the shipped `.sc` template registers the media port. That guard was seen red without being broken on purpose: it fails the older packages still in `dist/`, which is exactly what it is for. DSM's own TFTP server was verified to exist on the machine rather than assumed from documentation — `/usr/bin/opentftp`, with `rc.sysv/tftp.sh` beside it. That is what the env file and the guide now point operators at, and it is the load-bearing claim in both. CLAUDE.md records the fourth place DSM pressed back: a privileged port, answered by not having one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012TSRpBJgtVZg1T9nPQWbbL
… creates
Two things the DSM settings panel showed, and the second was a trap I had
built.
**The empty fields are by design** — a variable whose default is "off" has
nothing to show — but two of them should not have been empty. The package
creates `media/` and `boot/` in its share and then left the settings blank,
so an operator faced a field with no hint of what to type for a folder that
already existed. Both are now named in the env file.
**Naming the boot folder would have killed the package.** It implied a TFTP
server on port 69, which a DSM package cannot bind — and a failed bind is a
`return ExitCode::FAILURE`, so the whole server dies, not just TFTP. The help
text under that very field invited the operator to fill it in. Measured on
the machine rather than assumed: a non-root uid on DSM 7.2.2 gets
`[Errno 13] Permission denied` on UDP 69. Neither macOS nor a Docker
container reproduces it — Docker grants NET_BIND_SERVICE by default — which
is exactly why it had to be the real thing.
So `RESCRIPTUM_TFTP_ADDR` gains `off`, spelled the way `RESCRIPTUM_LOG=off`
already is. **Off is a value, not an absence**: the loaders stay served over
HTTP at `/boot/…` and stay checked by `boot check`; only the listener goes,
and something else hands the file over. The default has not moved — a plain
Linux host that names a boot directory still gets the TFTP server the plan
calls core.
Verified on DSM 7.2.2, from the machine's own log:
media listening on 0.0.0.0:8001 — serving …/media
tftp is off — /volume1/rescriptum/boot is still served over HTTP at /boot/…
rescriptum 0.2.0 listening on 0.0.0.0:8000
40 checks green on the VM, 55 in the lifecycle harness, and the new guard
watched red: commenting the `off` line back out turns 55 green into 54 and 1.
Also corrects two passages in the netboot guide that had gone stale — the
loaders are built now, and `BOOT_DIR` is no longer simply "the off switch for
TFTP".
540 tests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012TSRpBJgtVZg1T9nPQWbbL
…host has The settings panel showed an empty field for RESCRIPTUM_PUBLIC_HOST while the server derived an address at startup, so the one place an operator looks was showing something other than what the server does. `settings()` now fills the default by deriving it, the way it already does the CPU count — the panel renders a default as the field's value, so the derived address appears without the UI knowing anything new. Derivation gains a second source. The routing table answers on a host with a default route; an isolated provisioning segment has none, and there the interface list still answers when the host has exactly one address. With several it does not guess. The startup line now names the alternatives instead of warning generically: one address is stated plainly, and several are all listed, which is what makes "is this the address my machines reach" answerable from the log rather than by going to look at the host. +1,784 bytes on armv7. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012TSRpBJgtVZg1T9nPQWbbL
The on-machine run never touched the panel's backend, which is how a blank RESCRIPTUM_PUBLIC_HOST field reached a DSM install: nothing between the Rust tests and Package Center looked at what `api.cgi?action=config` would return. The check runs `rescriptum-cli config --json` as the package user — the exact command the CGI shells out to — and asserts the address is both present and one the machine's interfaces actually carry. Asking the CGI over HTTP would need a DSM session and would prove the same values through a login. Watched failing on the VM with the derivation removed: 40 passed, 1 failed, reporting the blank. With it restored, 42 pass. The Synology page and the reference table described the old startup warning; both now describe what the panel shows and what the log names beside it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012TSRpBJgtVZg1T9nPQWbbL
The class of bug, not the instance: the panel renders a variable's default as the field's value, so a default that exists only where the server consumes it shows as a blank while the server runs on something it derived. Two entries in KNOWN are special-cased for this and nothing in the type system says a third would need it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012TSRpBJgtVZg1T9nPQWbbL
Port 69 is the only privileged port in the design, so it is the only bind that can fail for something nobody configured. Measured on a DSM 7.2.2 machine: the capability comes from a `setcap` outside the package, an upgrade replaces the binary and silently drops it, and with a fatal bind the whole package then goes to `start_failed` — taking answers and media with it and failing every install in flight to report that a second port could not be opened. Answers are the product; TFTP hands over one file and something else can. So it degrades instead, loudly, in three places at once: a startup warning that names what still works, a non-zero `boot check`, and the settings panel. The failure mode being refused is the silent one, not the degraded one. `boot check` gained a real probe, and writing the test is what found out why it needed one: **binding is not a health check**. A bind that succeeds means nothing is listening — the degraded state, not the healthy one — and a bind that fails cannot tell this server apart from another daemon squatting the port, because both are `AddrInUse`. So it sends an actual read request and reports what a machine would get. Both halves watched red: making the bind fatal again kills the server before it answers, and dropping the failure count makes `boot check` call it fine. The first version of the second assertion passed for the wrong reason — three missing loaders were already failing the command — so the fixture now writes every loader the table names and a control run with TFTP off proves the directory is otherwise clean.
A previous session met a packaging constraint and traded away the product's first principle: it wrote `RESCRIPTUM_TFTP_ADDR=off` into the package and pointed operators at Synology's own TFTP server. rescriptum *is* the TFTP server — an appliance that needs somebody else's is not an appliance — and the constraint turns out not to be one. Measured on a DSM 7.2.2 machine, all four routes to port 69. `run-as: root` in conf/privilege is refused with synopkg error 319, `invalid package privilege content`, both in `defaults` and as a per-action ctrl-script, even though Synology's own packages use exactly that shape. A `security.capability` xattr baked into package.tgz installs — the pax inner format is accepted — but Package Center strips it during extraction. `setcap cap_net_bind_service=+ep` on the installed binary works, and the package then binds udp/69 as its own unprivileged user alongside 8000 and 8001. `net.ipv4.ip_unprivileged_port_start` does not exist on that kernel. So the env file no longer sets the variable at all: the default is 0.0.0.0:69, which is what the generated DHCP snippet and every loader we ship already expect. What it does instead is say what the one root command is, and that an upgrade replaces the binary and drops the capability with it — hence the Task Scheduler boot-up task. `off` stays available as a deployment workaround for an operator who wants it, which is all it ever should have been. 69/udp joins the firewall entry, on `dst.ports` with a protocol suffix like the tcp entries already use rather than an invented `dst.udp.ports` key. And the settings panel grew a `tftp:` line, because a failed bind no longer stops the server: without it the only trace would be a startup warning that scrolled past hours ago. lifecycle-test.sh 55 → 58, and the three new checks were each watched red: reintroducing the `off` line, deleting the panel's report, and making it claim to be serving with nothing bound.
The seven files that carried `RESCRIPTUM_TFTP_ADDR=off` as though it were
the design, corrected. `off` keeps its place as a deployment workaround
for an operator who wants one — it is never how anything here ships.
The Synology guide's "TFTP: use DSM's, not ours" becomes "TFTP needs one
root command": the `setcap` line, the Task Scheduler boot-up task that
survives an upgrade, and what `boot check` prints while the capability is
missing. The netboot guide and the configuration reference move a failed
TFTP bind out of the fatal table and into the warnings one, with the
reason rather than the rule.
And the four DSM routes to port 69 are recorded as traps with their error
codes, in both languages — `run-as: root` refused with synopkg 319 in both
shapes, the xattr stripped by Package Center, `setcap` working,
`ip_unprivileged_port_start` absent. The claim they replace had sat in
CLAUDE.md unmeasured; it was true by luck. Two more traps beside them: a
file capability does not survive an upgrade, and binding is not a health
check — a bind that succeeds means nothing is listening.
Both new anchors verified against the built HTML, since `notabene lint`
checks routes and not anchors. A truncated sentence in the French traps
page ("`check-spk.sh` vérifie les") is finished while passing.
The largest remaining gap in Phase 2, and the blocking one: a deployment that installed the package got a TFTP server with nothing to hand out. `boot check` said so on a fresh install — three MISSING loaders — and every machine the generated DHCP snippet sent there would ask for a file, get nothing, and stop. "One binary boots an arbitrary machine" was true only for whoever built iPXE themselves. `release.yml` gains a `loaders` job: build from the pinned commit, ask `boot check` whether the directory satisfies the table the server hands out from, and attach `rescriptum-boot-assets-<version>.tar.gz`. It is its own download and belongs to no binary archive or `.spk` — iPXE is GPLv2, separate files served alongside is mere aggregation, and `packaging/ipxe/` is the written offer that travels with it. Run end to end in a bookworm container before being written down: all eight loaders, `ipxe.iso` and `ipxe.usb`, `boot check` green, 3.1 MB packed. Two things that run found: - **The ISO target does not need an ISO writer, it needs `isolinux.bin`.** With xorriso installed it still failed with `util/genfsimg: could not find isolinux.bin`, and the note said "needs xorriso or mkisofs" — which sends you after the wrong package. Debian's is `isolinux`; adding it makes the bootable ISO build, so IPMI virtual media comes for free. - **`boot check` now probes the TFTP port**, so the CI and release steps that ask it about a *directory* pin `RESCRIPTUM_TFTP_ADDR=off`. On a runner where port 69 is neither bound nor bindable it would otherwise report a real problem and fail the wrong job over it. The rig is what proves a loader actually gets handed over. The bundle carries a README saying where it goes, because the loaders are inert until `RESCRIPTUM_BOOT_DIR` names them. Both guides now point at the download first and keep building it yourself as the alternative.
`boot check`'s TFTP probe had a test for the port being dead and none for it working — so it could have reported every port as a problem and still looked correct, because only the failing case is one anybody notices. Two outcomes pinned: `Served`, against a real server over real UDP, ending in `boot check` exiting zero and saying "handed over"; and `Refused`, a server that is there without the file asked for, which `boot check` itself never produces because it only ever asks for a loader on disk. Telling that apart from silence is what separates a misconfigured root from nothing running at all. Watched red by making a DATA reply read as silence.
`on-dsm.sh` printed the firewall line and never asserted anything about it, and nothing anywhere covered the route to port 69 — which is the one thing the whole `off` reversal depends on. 42 → 47 checks, run on the DSM 7.2.2 VM: - **`dst.ports="8000/tcp 8001/tcp 69/udp"` survives into `/usr/local/etc/services.d/rescriptum.sc` verbatim.** The protocol suffix was inferred from the form the tcp entries already use rather than from documentation, so it needed measuring; it holds. - **Without the capability the package still answers.** That is the non-fatal decision validated on the machine instead of at a desk, and the log carries its `cannot bind TFTP` line rather than going quiet. - **`setcap cap_net_bind_service=+ep` plus a restart binds `udp/69`** as the unprivileged package process, and answers are unaffected by gaining it — `netstat` shows `0.0.0.0:69 … rescriptum`. The stale comment above it, saying the boot folder is what DSM's own TFTP server gets pointed at, goes with them.
The testing page had drifted badly: 333 tests against a real 545, a per-suite table missing `tests/media.rs` and `tests/tftp.rs` entirely, and stale figures in half its rows. Counted per file rather than remembered. Three sections added for what the table now names — why a TFTP transfer can only be tested as a conversation, why the media suite ends every abuse case by proving answers still work, and that the boot chain lives in a rig `cargo test` does not run. The harness table gains the route to port 69, which is what `on-dsm.sh` now owns, and the go-red paragraph gains today's numbers with the three most recent checks and how each was watched failing. Both new in-page anchors verified against the built HTML — `notabene lint` checks routes, not anchors.
"DSM 7 does not let an unsigned package run as root" was measured but not
explained. Reading `libsynopkg.so.1`'s strings on the 7.2.2 machine gives
the rule verbatim: a package failing `verifyPackageSignature` may not have
a `ctrl-script` or `executable` section, must have `defaults.run-as` =
`package`, may not join the admin group, and — `non-synology package
should not use privilege migration`.
Which explains what looked like a contradiction: FileStation,
StorageManager, QuickConnect and SecureSignIn all carry `"ctrl-script":
[{"action":"start","run-as":"root"}]` in their own conf/privilege, the
exact shape refused to us with error 319. The shape is legal; the
signature is what makes it legal for them.
The line worth knowing is `tool capabilities should not exist`. DSM's
privilege format has a native `capabilities` field —
`SYNOPackageTool::Privilege::ChangeCapabilities` is in the same library —
so a signed package declares `cap_net_bind_service` in conf/privilege and
never needs `setcap` at all. The mechanism we want exists and is closed to
us, which settles that the manual step is the price of not being signed
rather than something better packaging could remove.
Marked explicitly as not measured: whether a third-party publisher's
signature would pass. The string says *non-synology*, not *untrusted*.
The library string said "non-synology package" and I recorded that a third-party publisher's signature was an open question. Synology's developer guide closes it: "If you are developing a package with root privilege, you are not able to install that package unless it is signed by synology." SynoCommunity hit the same wall (spksrc#4170, #4215). Two things worth having beside it. The `capabilities` field is *documented*, not just a symbol in a binary — `"capabilities": "cap_chown,cap_net_raw"` on a tool entry since 7.0-40656 — so a signed package would declare `cap_net_bind_service` and the manual step would vanish entirely. And there is exactly one documented bypass, a development token: generate debug.dat from Support Center, send it to Synology, drop the signed token at /var/packages/syno_dev_token. It is valid only on the NAS that produced the debug.dat, so shipping that way would mean every user doing a round trip with Synology before installing. One local `setcap` is strictly better for them. So the manual step is settled rather than provisional, and this records why, with the sources.
The procedure in packaging/dsm/vm/README.md predated both the loaders being inside the package and the setcap step, so following it would have produced a package that fails its own structural check and then a TFTP server that never binds. Written by running it: build the loaders in a container, cross-compile armv7, wrap, check, install through Package Center, the one root command, and how to tell from the NAS whether a loader is actually handed over — `boot check`'s `handed over` line, which is a real TFTP read answered with real data rather than a port that merely opened. Plus the Task Scheduler task, because a file capability does not survive an upgrade, and the warning that `on-dsm.sh` uninstalls at the end so it goes before a real setup rather than after. `setcap` targets `readlink -f /var/packages/rescriptum/target` rather than a literal /volume1 path — `target` is a symlink into @appstore on whichever volume the package landed on, and readlink -f was checked on the machine. The build guide gains the loaders prerequisite it now has, and loses a stale row: the armv7 package has come from the glibc target since Synology's 3.10 kernels broke musl's time64 fallback, and that table still said musleabihf.
Found on the DS416j, by installing the real package: `boot check` answered "boot assets are off" on a NAS where the boot folder existed, the loaders were in it and 69/udp was registered with the firewall. The env file was the one an older version wrote — four settings, no RESCRIPTUM_BOOT_DIR. The cause is a rule that is right on its own terms: the live env file is written only when absent, so an upgrade never replaces somebody's port and tokens with defaults. But taken alone it makes every new feature invisible to every installation that predates it, and since `etc/` survives an uninstall, removing and reinstalling does not fix it either. The `.env.example` is rewritten every time and is supposed to be the discovery path; nothing makes anybody read it. So `postinst` now appends keys the live file has **never heard of** and touches nothing that is present. **A commented-out key counts as present** — that is the whole safety property, and it gives the operator a way to say no: deleting a line means "never heard of it" and gets it back, commenting it out means no and is respected. It also restates mode 600 after writing, because the file holds an admin token and a file arriving from an older version may never have been 0600. Six checks in lifecycle-test.sh (62 → 68), and the harness earned its keep twice over: it caught the mode being left at 644, and it made me rewrite a "the original content survives" assertion that was a tautology comparing a string with itself. Two defects watched red — removing the top-up reproduces the DS416j bug exactly, and treating a commented key as absent overrides the operator's "no".
The four routes to port 69 were measured on a 7.2.2 VM, and that left one thing genuinely open: the VM is x86_64 with /volume1 on btrfs mounted `nodev` but not `nosuid`, and a `nosuid` mount makes the kernel ignore file capabilities outright — which would have closed the last open route on the one machine this project exists for. It holds. On the DS416j (ARMv7, armada38x) the capability survives, the package binds udp/69 as its unprivileged user, and `boot check` answers `0.0.0.0:69 handed over ipxe-arm64.efi` — a real read request answered with real data. Which also puts the armv7 glibc binary on the machine for the first time since this branch rebuilt it. What is still not proven there: the panel's `tftp:` row needs a browser, and no real machine has PXE-booted from this NAS yet. The rig proves the chain in QEMU, which is not the same claim.
…ndexes
Asked for: a catalogue of ISOs to pick from instead of hunting a URL and a
digest by hand. The obvious shape — a table of URLs with digests baked in
— would have been wrong the day it shipped. Proxmox prunes old ISOs from
its CDN, Debian and Ubuntu publish point releases every few weeks, and
this project *requires* a digest for a URL because that decision is what
every machine ends up installing. A baked-in table would need re-cutting
on somebody else's schedule, forever, and would serve 404s in between.
So nothing about a specific image is stored. Each entry names **the
checksum index the vendor already publishes beside its own images**, and
the names and digests are read from it when somebody asks. The list is
current because it is the vendor's, and the digest rule is satisfied by
the vendor's own file — which is what the documentation already tells
people to do by hand.
rescriptum media sources # the catalogues
rescriptum media sources proxmox-ve # what one offers, right now
rescriptum media add --from proxmox-ve proxmox-ve_9.2-1.iso
Said plainly in the module and not glossed: taking the digest from the
same host as the image is **not** a signature check. Over HTTPS it
authenticates the vendor's domain and catches a truncated download, a
corrupt mirror and a file that changed underneath — most of what actually
goes wrong — and nothing more. `--sha256` with a digest obtained out of
band stays the stronger path.
Five sources, and **every index URL was fetched before being written
down**, plus one image URL derived from each: a table of plausible 404s
would be worse than no table. Two index formats, because that is all that
exists in the wild — coreutils `<digest> <name>` (Proxmox, Debian,
Ubuntu, with Ubuntu's `*` binary marker) and BSD tag `SHA256 (n) = d`
(AlmaLinux, Rocky, inside a PGP-clearsigned document whose wrapper is
skipped rather than refused).
Sorting is natural rather than lexicographic, and that is not cosmetic:
the first row is the one that gets clicked, and plain string order puts
9.10 behind 9.9 — offering a rack an older installer than it asked for.
Verified against the live indexes, and end to end: the digest resolved for
proxmox-ve_9.2-1.iso is 4e88fe416df9b527…, character for character what
Proxmox publishes, and the fetch starts against it.
+31,520 bytes on armv7 (2,709,840 → 2,741,360), recorded rather than
quietly spent — the `boot` budget was already over.
Asked for: do this from the application rather than over SSH, and offer the usual ISOs to click rather than hunting a URL and a digest. A fourth tab — what is held, a catalogue to pick from, and a URL field for what the catalogue does not offer. The manual path is deliberately in front of people rather than documented as a command-line escape hatch: a digest obtained out of band is stronger evidence than one read from the same host as the image, so it is the better of the two and should look it. **The panel grows no rule of its own.** It starts a download by calling `media add`, which is where the digest rules live and are tested, and it follows one by watching the `.part` file that command already writes — `media add` renames it only once the digest checks out, so the partial file's size *is* the progress and its disappearance *is* the completion. Nothing about progress had to be invented for the browser, and nothing here can disagree with what the CLI actually did. A CGI cannot hold a request open for 1.5 GB, so the fetch is backgrounded. Three details, each a trap this package has already paid for once: `</dev/null`, because a background child inheriting the CGI's stdin holds the request open forever — which is how `su` hung this script; `setsid`, or the web server reaps the download a second in; and every value reaching the CLI as one argument, so a URL with a semicolon in it is data. Ten checks in lifecycle-test.sh (68 → 78), all guards: a download refusing to be a GET, refusing a POST without X-Rescriptum, a URL with no digest, a digest that is not one, a scheme nothing can fetch, and an image name that is a path. Removing the digest guard was watched red. And two on the machine (50 → 52), because the catalogue is the one part of this package that talks to the internet and whether that works is a property of the NAS — its resolver, its uplink, its curl. The DSM VM reads Proxmox's index and offers proxmox-ve_9.2-1.iso.
The `prepare` action was wired into api.cgi and nothing in the panel called it — so the tab could fetch an image and then leave the one step that makes it an unattended install available only over SSH. Caught by being asked whether it was there. It sits directly under the listing it acts on, because it is the step nobody guesses at. Proxmox only, and **the CLI is what refuses the rest**, with its own sentence shown verbatim: every other family takes its answer's URL on the kernel command line, where `media ipxe` already puts it, so injecting a file they never read would be a no-op that looks like a step. Deciding that in the panel too would be a second implementation to keep honest. Two checks (78 → 80): a well-formed id for an image that is not there has to reach the CLI and report its refusal, and must not come back claiming exit 0. The traversal guard was already covered.
From the maintainer, installing a real machine: after an install the node reboots straight back into PXE, and "no answer for this machine" ought to mean "get out of the way" rather than "here is a menu". They are right, and it is the better polarity. `RESCRIPTUM_BOOT_UNCLAIMED=local` makes the bootstrap fall through to `exit 0` instead of the menu — control back to the firmware, next boot device, works on BIOS and UEFI alike (`sanboot --drive 0x80` is BIOS-only). **The two settings are opposite readings of what an answer file is for.** With the menu — still the default, and the project's thesis — a file claiming a machine says *leave this one alone*, because without one it lands somewhere a human could click. With `local` a file says *install this one* and its absence is the safe state, which is what a fleet already in production needs and the reading that scales: the machines you want to reinstall are always fewer than the ones you do not. The payoff is that netboot stays first in the BIOS order forever, and reinstalling becomes "add a file, reboot" — no console, no hands on the hardware. Removing the file is what stops it happening twice. Watched red by pinning the switch to false. `every_variable_is_described_ exactly_once` also earned its keep immediately: it caught the new key being in `KNOWN` but not in `envfile::KNOWN_KEYS`, which is the exact split it exists to catch — described but never read. Also corrects what I told the maintainer an hour ago. I warned about a reinstall loop with only a .toml in place; there is none. The menu's first entry is the local disk and its timeout falls through to it, which the rig already asserts. The loop needs an .ipxe answer that boots the installer, and this setting is what makes that safe.
They carry root credentials. A scratch directory beside the repo is where they belong, and it must never be a directory git can be talked into tracking.
The loop, closed properly. A machine claimed by an `.ipxe` answer installs, reboots, is claimed again, and installs again — wiping its disk each time. Doing the disarm by hand works and is what shipped first, but it is a race: the machine reboots the moment the installer finishes. Verified against Proxmox's own documentation before a line was written, because the block in our examples was written by this project and could have been fiction. It is not: `[post-installation-webhook]` fires after a successful install and **before the reboot**, POSTs JSON, and that body carries the network interfaces — MACs included. Which means it is the same shape as the request that asked for the answer, and `Facts` reads it with no new parsing. The `auth-token` goes in the body as a top-level `token`, not as a bearer — unlike the answer token. That last detail decides the routing: the route runs **before** the answer token's guard, because otherwise setting an answer token would 401 every webhook. And the path is reserved **only when the token is configured**, so a deployment that never uses this keeps "POST on any path is an answer request" whole — which is what lets a URL be baked into an ISO. Narrow by construction, because this is the one path where something arriving over the network changes the answer set: - machine documents only, never a group — one machine finishing must not disarm its neighbours, so the lookup never consults groups rather than filtering them out afterwards; - format `ipxe` only — the `.toml` is the record of how the machine was built and the installer is what reads it; - moved under an `installed-` prefix, never deleted, so nothing it does is irreversible. Arriving twice is a success: a webhook may be retried, and a machine installed from the menu was never claimed. A disarm that *fails* logs `still armed`, because otherwise the consequence is silent. Both guards watched red: moving the route after the bearer guard makes every webhook 401, and dropping the format filter takes the machine's .toml with it. The group test asserts the group *does* claim the machine before asserting it survives — without that it would pass for a fixture that was never loaded. 561 tests.
Asked, correctly: this was built against Proxmox's webhook — what about
the others. Two things were tangled and needed separating.
**The claim is not Proxmox-specific.** It is an `.ipxe` document, which is
about the loader rather than the operating system, so every family is
claimed the same way and needs the same disarm.
**The report back is where they differ.** Proxmox has a webhook; nobody
else does. Debian has `late_command`, Ubuntu `late-commands`, RHEL and its
rebuilds `%post`, SUSE a chroot script — all of them can run one `curl`,
and none of them will compose Proxmox's JSON body. The endpoint made that
harder than it had to be, so it now also takes the identity from the query
string and the credential from an ordinary bearer header:
curl -fsS -X POST -H "Authorization: Bearer nas:s3cr3t" \
"http://server:8000/installed?mac=$(cat /sys/class/net/*/address|head -1)"
No body at all. Same secret either way, same constant-time comparison —
the body form exists because it is what Proxmox sends and Proxmox cannot
be told to send a header.
Watched red by reverting to a body-only identity, which leaves the
kickstart path disarming nothing.
The guide names where that line goes per family, and says plainly that the
example's `head -1` is right on a one-NIC machine and wrong on a four-NIC
one — the wrong MAC disarms the wrong machine.
562 tests.
…nt one From the maintainer, setting it up by hand: the token should be generated at install if it is not set. Right — the webhook only works when the string in a machine's answer and the one the server checks are identical, and two blank fields that must agree is a thing people get wrong once and then debug as "the machine reinstalls itself". Generated here, the server half is already correct and the answer document only has to copy it. `od -An -tx1 -N16 /dev/urandom` rather than `openssl rand` or base64: all three exist on the 7.2.2 machine, but od is POSIX and the most likely to be on the oldest thing this package may install on. 128 bits of hex, which also survives being pasted into a TOML string without quoting questions. Two guards worth naming. **An empty token would be an endpoint anybody can call**, so if the generator produces nothing the setting is written commented out and the feature stays off — nothing beats something there. And **a token already in the file is never replaced**: the top-up only adds keys the file has never heard of, so an upgrade cannot silently orphan every answer document carrying the old one. Four checks in lifecycle-test.sh (80 → 84): generated, long enough, hex, and an existing secret left alone. Watched red by stubbing the generator to echo nothing — which fails on emptiness and on length, exactly where it should. The settings panel gains the label and the help in both languages, so it is not a bare key next to a value somebody is afraid to touch.
A machine on the maintainer's network fetched a loader and did nothing.
The log said:
tftp: ipxe-x86_64.efi 1164800 bytes blksize=1468
which reads as success and was not: **that line was written before the
first byte went out.** Every failure path after it returned silently, so a
transfer that stalled at block one and a transfer that completed produced
identical logs — and the one diagnostic a boot server has said the
opposite of the truth.
Reported at the end now, with the outcome: `sent <file> N bytes`, or
`FAILED after N of M bytes` at status 500 so `RESCRIPTUM_LOG=problems`
keeps it, naming the knob that fixes the commonest cause.
That knob is new, and the reason it exists is arithmetic: **1468 fills a
1500-byte path exactly** — 1468 payload, 4 TFTP, 8 UDP, 20 IP. It is what
iPXE asks for and what leaves nothing over. One VLAN tag makes the frame
1504, and a PXE ROM meeting that generally stops with no message at all.
`RESCRIPTUM_TFTP_BLKSIZE` caps what we agree to; 1400 leaves room for a
tag and most tunnels, 512 always works.
The default stays at 1468 rather than being lowered on a hunch: dropping
it for everybody costs every deployment throughput to fix a minority's
network, and the failure it causes is now loud enough to find. Whether it
should move is for a measurement, which this change is what makes
possible.
Two tests, both watched red: an abandoned transfer that leaves no trace
when the line goes back to the top, and a cap that a client's larger
request walks straight past.
564 tests.
`boot check`'s probe wants one block and no more, and it walked away without acknowledging — which, now that an abandoned transfer is reported, made the server retry for four seconds and log a FAILED line. So running the health check wrote a scary alarm into the one file an operator reads to find a real one. Seen immediately, on a real NAS, in the middle of debugging something else. The probe says goodbye with an ERROR packet, which is how TFTP says stop, and the server now tells a deliberate cancel from a client that vanished: `stopped … cancelled by the client` at 200, against `FAILED …` at 500. The distinction is the point — `RESCRIPTUM_LOG=problems` should keep the one that means a machine did not boot, and nothing else. Watched red by removing the goodbye.
Reported from a real NAS: TFTP stopped working, and there was nothing in the log at all. Both halves were mine. **The cause was the cap I had just suggested.** `RESCRIPTUM_TFTP_BLKSIZE=512` makes the server answer a client asking for 1468 with an OACK naming 512. RFC 2348 allows that and says the client should accept it; a PXE ROM often just stops instead. So a machine that had been booting fine stopped, and the advice that broke it was mine. **The reason it was invisible was also mine.** Moving the transfer's only log line to the end — so that a stall could not read as a success — meant a request that never got going logged nothing whatsoever, which is exactly the case somebody is trying to diagnose. The machine said it was downloading, the server said nothing, and both were telling the truth. Two lines now, not one. One when a request arrives, one for what it got. `RESCRIPTUM_LOG=problems` keeps only the second, which is the right split — but `all` is the default, and at `all` a silent request is a bug. And the option handshake no longer returns without a word: it says which block size the client wanted, which one it was offered, and names the setting that caused the disagreement. Both watched red.
The message printed "the client asked for blksize=1468 and would not take 1468" and pointed at RESCRIPTUM_TFTP_BLKSIZE — a setting that, when both numbers are equal, provably did not participate. It sent the maintainer and me after the wrong thing for an hour while the real cause went unexamined. When the server granted exactly what was asked and the client still walked away, the block size is ruled out and the message says what this actually looks like instead: the reply comes from a fresh port, which is how TFTP works, so a firewall or a NAT between the two lets the request in, lets the answer out, and eats the acknowledgement. A message that suggests a cause it has already ruled out is worse than one that suggests none.
…allow them Asked, after a machine that would not boot: can that port not be controlled. It can, and not being able to was the bug. A TFTP transfer leaves port 69 immediately — the server answers from a fresh port and the client acknowledges to *that*. So a firewall told to allow 69 lets the request in, lets the answer out, and drops the acknowledgement; the transfer dies at the handshake and the client looks like it lost interest. It is the hardest failure in this protocol to read, and every serious TFTP server therefore lets the range be pinned. Ours did not. `RESCRIPTUM_TFTP_PORT_RANGE=first-last` does it. Unset keeps today's behaviour, which is right on a host with no firewall in the way. **The DSM package pins 30000-30063 and registers it with the firewall**, because on a NAS the unpinned behaviour is not a default, it is a trap. The range was chosen against a real DSM rather than picked: below the kernel's own ephemeral range (32768–60999, so nothing in it can be handed to something else), clear of every UDP port a stock install uses (68, 123, 137, 138, 161, 323, 1900, 3702, 5353, 9997–9999), claimed by no Synology `.sc`, and all 64 bind-tested. Sixty-four is MAX_TRANSFERS, so the range cannot be what runs out first. Two checks in lifecycle-test.sh (83 → 85): the firewall entry carries the range, and the server is pinned to the same one — a mismatch between those two would be exactly the silent failure this fixes.
While chasing a machine that will not boot: the data socket was `connect`ed to the address the request came from, which makes the kernel accept datagrams only from that exact address *and port*. A client that acknowledges from a different source port therefore has its packets dropped before any of this code runs — and the transfer dies looking precisely like a firewall eating the acknowledgements, with nothing logged, because there is nothing to log. RFC 1350 says a client keeps its TID for the transfer, and most do. The ones that do not are UEFI ROMs, which is exactly the population this serves. A blind spot that cannot be told apart from a network fault is worse than a rule that bends, so the socket now accepts from the same address whatever port it comes from, and logs when the port moves. Not claimed as the cause of the failure being chased — the measurement to settle that is still a packet capture. It is a hole either way, and one that would have made the real answer unreadable. Watched red by reinstating the port filter. Four other tests broke on the way and said so immediately: the refusal paths still used `send`, which needs a connected socket.
Found on the wire, from a packet capture on the NAS, after I had wrongly blamed Secure Boot, the block size and the firewall in turn. The option handler echoed `windowsize` back in the OACK — the server saying "yes, four blocks per acknowledgement" — while the transfer loop sent one block and waited for its ACK. A client told four waits for four. So both sides waited, and only the 700 ms retransmit broke the deadlock: every block cost a resend and 700 ms, which makes a 1.1 MB loader take **nine minutes**, and firmware gives up long before that. The capture showed it exactly — block, 700 ms, identical block, then the ACK. The comment above the constant warned that mishandling the window turns one lost packet into a stall. The code then did it. RFC 2347 says an option left out of the OACK is to be treated as never requested, so declining costs a client one acknowledgement per block — under a millisecond on a LAN — and nothing else. Implementing RFC 7440 properly is worth doing and is deliberately not this change: correctness first, and a window is an optimisation. Two tests, and the second had to be rebuilt to earn its keep. It first used the ordinary test client, which acknowledges every block whatever was negotiated — so the deadlock could not occur and it passed with the bug reintroduced. It now honours the window it was granted, like the ROM it stands in for: 0.5 s healthy, 63 s with the bug back.
A Lenovo ROM asks for `tsize 0 blksize 1468 windowsize 4`, refuses the reply, and retries fifteen seconds later without `tsize` — which succeeds. Its first attempt therefore logs a failure that resolves itself, and which option it objected to was visible only in a packet capture. That is where it was actually found. The line names them now. A client that refuses one option refuses the whole reply, so seeing `tsize=1164800 blksize=1468` beside a second attempt that worked is the diagnosis, without tcpdump. Deliberately not "fixing" it by declining tsize: our answer is what RFC 2349 prescribes — the file's real size for a read request — and three wrong guesses today were enough. It costs fifteen seconds and recovers on its own; a change here would be a fourth guess, and this makes the next person's evidence better instead.
…ramfs Found on the machine, in the line that ruled out three days of wrong theories: `Freeing initrd memory: 1720768K`. The kernel unpacked 1.7 GB without a single complaint — so zstd, gzip and every other compression guess was beside the point. It unpacked an initramfs and found no `/init`. In iPXE, `initrd <uri> <name>` gives the image a cpio header and lands it as a *file* in the initramfs; `initrd <uri>` is appended raw and therefore *is* the initramfs. We named Proxmox's real initrd `initrd.img`, so the kernel got an initramfs holding `/initrd.img` and `/proxmox.iso`, nothing to execute, and fell through to mounting a root filesystem — `VFS: Unable to mount root fs on unknown-block(0,0)`. The comment that stood there justified the name as making it "match the `initrd=` above". That was my misreading: `initrd=` is a bootloader directive pxelinux consumes, not a name the kernel resolves, and upstream's own example passes the initrd with no name at all. **The ISO keeps its name**, for the opposite reason — the installer opens it by that name, so it has to be a file. The asymmetry between the two lines is the thing to preserve, and the test now says so. That test previously asserted the bug, under the name "matches what the assistant itself emits". It matched what I had believed the assistant emits.
Found on the machine, at the last link in the chain, after everything
before it had been made to work:
ERROR: Failed to parse '/cdrom/auto-installer-mode.toml'
unknown field `partition-label`, expected one of `mode`,
`partition_label`, `http`
Installation aborted
We wrote `partition-label` and `cert-fingerprint`. `AutoInstSettings` is
`deny_unknown_fields`, so one wrong key is a *rejected document*, not a
warning: the automated install stops and asks a human who is not coming.
The doc comment above the writer said precisely that — "one key this does
not know about is a rejected file … Only the five keys upstream defines
are ever written" — while the code beneath it wrote two that upstream does
not define. And the test that was meant to pin the names enumerated the
hyphenated ones, so it guarded the bug.
Both fixed, and the names now come from the installer's own refusal rather
than from my reading of anything. `cert_fingerprint` is pinned too: it is
the path nobody exercises, because it only appears when somebody pins a
certificate, and it would have failed the same way at the same place.
…e way The goal this project is written against, reached on hardware: a Lenovo vPro machine powered on and installed itself with Proxmox VE 9.2, unattended, from a DS416j. DHCP handoff, TFTP, branded iPXE, the answer that claimed it, kernel and initrd and a 1.6 GB image over HTTP, the injected mode file, and the machine's own answer.toml. Recorded here is the last one, which is not ours and cost the evening anyway: **Intel AMT with a static address on a NIC it shares with the host.** The installer's dhclient gives up after about eleven seconds, and with the Management Engine holding the interface statically no offer arrives — so the install aborts on `Network is unreachable` while `dhclient -v eno1` from its own shell succeeds instantly afterwards. The network is fine; the timing is not. Setting AMT to DHCP fixes it, and nothing here can widen that window. The guide gains a section for the class: when everything is right and the machine still will not install, the installer's root shell is the fastest diagnosis there is. Worth stating plainly in the plan: seven defects stood between "every harness green" and a machine that installs, and **not one of them was reachable by the rig**, which boots BIOS under TCG.
Numbers counted rather than remembered: 571 tests (545 → 571 over the day's fixes), and the package harnesses at 28 / 85 / 52 where the page still said 26 / 58 / 47. The per-suite table gains `src/installed.rs` and the two suites that grew most — `tests/tftp.rs` 21 → 30, which is where most of today's defects were caught, and `tests/integration.rs` 45 → 48. The machine harness's row says what it now owns: the only route to port 69, and whether the NAS can reach a vendor's image index — the one part of this package that talks to the internet. And the plan records the half of the milestone I had not checked before calling it unproven: the machine **disarmed itself**. The installer called `POST /installed` before rebooting, the server renamed its `.ipxe` claim out of the way, and it came back up on its own disk. Unattended, first time, with nobody watching for it.
A machine's answers were files sharing a stem: `98fa9b50d810.toml` beside
`98fa9b50d810.preseed`. The stem was the identity, the extension the format,
and nothing held the two together — a machine's documents were only adjacent
by sorting. They are a directory now:
answers/98-fa-9b-50-d8-10/proxmox.toml
/debian.preseed
/boot.ipxe
The directory name is the identity; **the extension is the format and the stem
is nothing at all**. `proxmox.toml` and `answer.toml` are one document to this
server, so the name is free for whoever opens the folder. `canonical_stem`
picks a readable one for a document nobody has named, and a write overwrites an
existing document *where it stands* — an operator's name survives.
Two documents of one format in one directory is a **reported problem**, never a
silent choice: there is no tiebreak anyone could have predicted. Sorted order
decides which answers, so it does not depend on readdir, and the loser is named.
Groups and the fallback take the same shape, so there is one rule rather than
three — `groups/rack-a/proxmox.toml`, `default/proxmox.toml`. Both names are
reserved as machine ids in **both** stores: a database that accepted `groups`
would export into a directory that cannot hold it, and `export` has to stay a
way out.
A servable document left flat is reported with its destination and **not
served**. Half-reading the old layout would mean a machine whose answer moved
silently between two files, which is the failure this server exists to prevent.
`rescriptum migrate` shows what it would move and `--apply` moves it; one taken
destination aborts the whole run rather than leaving a half-migrated directory.
Disarming stays a sibling directory (`installed-<id>/`) rather than a prefixed
file inside the machine's own. The machine's directory keeps meaning "this
machine's configuration", no new exclusion rule is needed — the directory name
identifies nothing — and `installed.rs` did not change.
Measured cost, on an M1 Pro at 2,000 machines: a full reload goes from 28.6 ms
to 63.5 ms, a `readdir` per identity on top of the file already opened. It is
syscalls, not allocation — removing the allocations moved nothing. Amortised
over a second of requests by the listing cache, and end-to-end throughput did
not move measurably. The mtime also sees less: a document added *inside* a
machine's directory is one level below what is watched, so the backstop catches
it rather than the version token. Tests pin both halves.
BREAKING CHANGE: answer documents must live in a directory named after their
identity. Documents left at the top of the answers directory are reported and
no longer served; run `rescriptum migrate --apply` to move them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KQnAk5r4fLKcWuAwUY6Pa6
Two failures the local toolchain could not see, both older than this branch. **Clippy 1.98 gained five lints** this code trips, and the pinned local toolchain was 1.93 — so `cargo clippy` was green here and red in CI, which is the worst arrangement. All five are mechanical: an `.into_iter()` an array does not need, a `sort_by` that is a `sort_by_key` over `Reverse`, two `loop` + `let … else break` that are `while let`, and a zero check that is `checked_div`. None changes behaviour; the descending version sort keeps its `Reverse` so `9.10` still outranks `9.9`. **`--no-default-features` had stopped compiling**, and it is a CI gate. The `RESCRIPTUM_TFTP_BLKSIZE` accessor added with the block-size cap reaches for `boot::tftp`'s constants, which are not there when the feature is off. Its only caller is the TFTP server, behind that same feature, so the accessor moves behind it too. All three combinations build again: neither feature, each alone, and both. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KQnAk5r4fLKcWuAwUY6Pa6
macOS lets an unprivileged process bind UDP 69; Linux does not. `boot check` treats an obtainable-but-silent TFTP port as a note and an unbindable one as a problem — the right rule, and precisely what made these tests take a different branch on each platform. They passed on the development machine and failed on the first CI run, on a verdict that had nothing to do with what they assert. Both tests that set `RESCRIPTUM_BOOT_DIR` now set `RESCRIPTUM_TFTP_ADDR=off`, so each measures only its subject: the loader table for one, the media port warning for the other. The unbindable port keeps its coverage in `tests/tftp.rs`, on a high port, where it is the subject rather than the noise. Both traps recorded — this one, and the more general one behind it: a branch that accumulates 57 commits before its first push has never met the CI, and finds out about the toolchain gap and the platform gap at the same time. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KQnAk5r4fLKcWuAwUY6Pa6
wamxx
added a commit
that referenced
this pull request
Aug 30, 2026
Since v0.2.0: the whole boot chain and a directory per identity (#2), a TOML configuration file (#3), and four defects that were live in shipped code — a rollback blind over the file store, a write that could widen the permissions of a document holding a root password hash, a log target that stopped every CLI command for anyone but the service user, and a group `.ipxe` that arms machines nothing can disarm. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KQnAk5r4fLKcWuAwUY6Pa6
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two bodies of work that arrived in order and cannot be separated now — the second is built on the first and edits its tests.
1. Boot media: one binary, the whole chain
Everything between "a machine is powered on" and "an installer is running and asking for its answer". Previously that gap was somebody else's TFTP server and somebody else's ISO extraction.
src/boot/iso.rs) — a file in an image is one contiguous extent, so serving a kernel is a seek, never an extraction. Rock Ridge and Joliet included.src/boot/tftp.rs) speaking RFC 1350 plus options, tested over real UDP.packaging/ipxe/) — no binaries in git; the directory is the GPLv2 written offer, and the loaders ship in the release and the.spk.media prepareadds one file to an ISO as a plan — ~200 bytes on disk, applied on the wire — so the bytes stay what the vendor published and stay checkable against their ownSHA256SUMS.POST /installedcloses the loop: the machine says it finished, and its claim is dropped before it reboots. The window to rename a file by hand was however long the firmware took to come back.bootfolder arrives filled.This ran on real hardware. On 2026-08-28 a Lenovo vPro machine was powered on and installed itself with Proxmox VE 9.2, unattended, from a DS416j — DHCP handoff, TFTP, branded iPXE, the menu's answer, kernel and initrd and a 1.6 GB image over HTTP, the injected mode file, and its own
answer.toml. Then it disarmed itself and came back up on its own disk instead of reinstalling.Seven defects stood between "every harness green" and that, and none of them was visible to the rig. They are recorded in
docs/development/traps.md— a TFTPwindowsizeacknowledged but not implemented (nine minutes per loader), a transfer logged before it happened rather than after, aconnected data socket the kernel filtered on port, an initrd given a name so it became a file instead of the initramfs.2. A directory per identity
A machine's answers were files sharing a stem:
98fa9b50d810.tomlbeside98fa9b50d810.preseed. Nothing held them together — they were adjacent by sorting. They are a directory now:The directory name is the identity; the extension is the format and the stem is nothing at all.
proxmox.tomlandanswer.tomlare one document to this server. A write overwrites an existing document where it stands, so an operator's own naming survives.Two documents of one format in one directory is a reported problem, never a silent choice — there is no tiebreak anyone could have predicted. Groups and the fallback take the same shape, so there is one rule rather than three, and
groups/defaultare reserved as machine ids in both stores: a database that acceptedgroupswould export into a directory that cannot hold it.Disarming stays a sibling directory (
installed-<id>/). The machine's directory keeps meaning "this machine's configuration", no new exclusion rule is needed, andinstalled.rsdid not change.Breaking, with a way across
A servable document left flat is reported with its destination and no longer served. Half-reading the old layout would mean a machine whose answer moved silently between two files.
It shows by default and moves only when told to; one taken destination aborts the whole run rather than leaving a half-migrated directory.
What it costs, measured
At 2,000 machines on an M1 Pro, a full store reload goes from 28.6 ms to 63.5 ms — a
readdirper identity on top of the file already opened. It is syscalls, not allocation: removing the allocations moved nothing. The listing cache amortises it over a second's worth of requests, and end-to-end throughput did not move measurably.The mtime also sees less than it did. A document added inside a machine's directory is one level below what
version()watches, soRELOAD_BACKSTOPcatches it rather than the version token — the same rule that already covered a file edited in place, now the normal case. Tests pin both halves.Testing
582 tests, all green. Each of the nine new ones was watched failing before being trusted — the project has been caught by a test that passed for the wrong reason.
tests/common/mod.rsis new: fixtures go throughStoreWrite, so they land where an admin-API write would and cannot drift from the layout. One copy, not one per suite.tests/stores.rsand therefore runs against both stores.packaging/boot-rig/boots a claimed and an unclaimed machine in QEMU and asserts four markers.cargo testdoes not run it.Documentation
Both languages, in the same commit as the change. The layout, the migration path, the reserved names, the measured cost, and the traps.
Not done
The DSM package has not been re-run on the machine.
remote-check.sh,lifecycle-test.shand the rig's fixtures were adapted to the new layout, butpackaging/dsm/CLAUDE.mdrequires running the DS416j for anything under that directory. That is outstanding.cargo build --no-default-featuresis broken, and was before this branch —config.rsreaches forcrate::boot::tftp::MAX_BLOCKwithout a#[cfg]. It is a CI gate (ci.yml:55), so that job will fail here for a reason this PR did not introduce and does not fix.🤖 Generated with Claude Code
https://claude.ai/code/session_01KQnAk5r4fLKcWuAwUY6Pa6