diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3489148 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +# What a Docker build context must not carry. +# +# Without this the boot rig's build context was **6 GB** — `target/` alone is most of +# it — and every `docker compose up` spent two minutes transferring build artefacts the +# image then rebuilds from scratch anyway. A rig nobody waits for is a rig nobody runs. +target/ +_site/ +node_modules/ +.git/ +plans/ +packaging/dsm/vm/storage/ +packaging/boot-rig/results/ +packaging/boot-rig/.work/ +packaging/ipxe/.work/ +packaging/ipxe/out/ +**/*.iso +**/*.spk diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 317a07b..62dfbd5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,9 +49,102 @@ jobs: run: cargo test --all-features # The smallest build has to keep working, or the NAS target rots unnoticed. - - name: Build without SQLite + # Both features off, and then each on its own: the combination that ships is not + # the only one that has to compile. + - name: Build without SQLite or boot media run: cargo build --release --no-default-features + - name: Build with boot media but no SQLite + run: cargo build --release --no-default-features --features boot + + loaders: + name: Branded iPXE loaders + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # A C toolchain and the ARM64 cross binutils. iPXE's EFI targets need the + # architecture's own `ld` and `objcopy`; the BIOS ones build with the host's. + - name: Toolchain + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + build-essential liblzma-dev mtools xorriso isolinux gcc-aarch64-linux-gnu + aarch64-linux-gnu-ld --version | head -1 + + - name: Build the loaders + run: packaging/ipxe/build.sh --out "$GITHUB_WORKSPACE/loaders" + + # **The chain of custody.** What the rig boots and what a release publishes have to + # be the same bytes, never a rebuild of them — a loader rebuilt after the run that + # proved it is a loader nobody has booted. + - name: What was built + run: | + ls -la "$GITHUB_WORKSPACE/loaders" + cat "$GITHUB_WORKSPACE/loaders/SHA256SUMS" + + # Ask the server whether the directory satisfies the table it serves from. A + # snippet naming a loader that is not here fails silently at the ROM, and this is + # the only thing that catches it. + # `RESCRIPTUM_TFTP_ADDR=off` because the question here is whether the *directory* + # satisfies the table, not whether a listener is up: `boot check` also probes the + # TFTP port, and on a runner where port 69 is neither bound nor bindable that is a + # problem it would rightly report and wrongly fail this job over. The rig is what + # proves a loader actually gets handed over. + - name: Does the server agree the set is complete? + run: | + cargo build --release + RESCRIPTUM_BOOT_DIR="$GITHUB_WORKSPACE/loaders" \ + RESCRIPTUM_TFTP_ADDR=off \ + RESCRIPTUM_PUBLIC_HOST=192.0.2.10 \ + ./target/release/rescriptum boot check + + - uses: actions/upload-artifact@v4 + with: + name: ipxe-loaders + path: ${{ github.workspace }}/loaders + if-no-files-found: error + + rig: + name: The boot chain, end to end + runs-on: ubuntu-latest + # **The tripwire, not the contract.** The dev rig is what decides; this re-proves the + # BIOS chain on every push without a single real machine. GitHub's runners are + # somebody else's machines with somebody else's limits, so it is sized so it cannot + # fail for capacity reasons — BIOS only, TCG, one claimed and one unclaimed client, + # no OS image, a few minutes. If it ever needs hardware virtualisation to pass, it + # has grown too big. + # + # Runners are x86_64, so this is native rather than the emulation a Mac does — and + # there is still no /dev/kvm, which is exactly the constraint the rig is built for. + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + + - name: Build the rig + run: docker build -f packaging/boot-rig/Dockerfile.rig -t rescriptum-rig:one . + + # Four markers: the DHCP handoff answered from our own generated snippet, a loader + # fetched over TFTP, an unclaimed machine on its own disk, and a claimed machine at + # its own answer. + - name: Boot a claimed and an unclaimed machine + run: | + docker run --rm --cap-add NET_ADMIN --device /dev/net/tun \ + -e UNCLAIMED_SECONDS=180 -e CLAIMED_SECONDS=180 \ + rescriptum-rig:one bios + + # **Watched failing, in CI as well as by hand.** A green rig that has never been + # red proves nothing, and the cheapest way to keep that true is to break one link + # on every run and require the break to show. + - name: And it must go red when a loader is missing + run: | + if docker run --rm --cap-add NET_ADMIN --device /dev/net/tun \ + --entrypoint sh rescriptum-rig:one \ + -c 'rm /srv/boot/ipxe-undionly.kpxe && exec /rig/rig-in-one.sh bios'; then + echo "the rig passed with a loader missing — it is not testing what it claims" >&2 + exit 1 + fi + docs: name: Documentation runs-on: ubuntu-latest @@ -112,6 +205,9 @@ jobs: cross: name: Cross-compile for the NAS, and package it runs-on: ubuntu-latest + # The packages carry the loaders now, and they come from the job that built them — + # so this waits rather than rebuilding iPXE a second time. + needs: loaders # This is where a C dependency breaks first: SQLite is compiled from source, and # armv7-musl is the least forgiving target we ship. Catching it here beats catching # it while cutting a release. The DSM packages are assembled on the same job for the @@ -175,9 +271,18 @@ jobs: # Packaging breaks on the PR that breaks it, rather than at tag time. This is the # cheap half of "does this package work"; the other half is installing it, which # only a DSM machine can answer. + # The loaders the package ships, from the job that already built and verified them + # — never a rebuild here. What `check-spk.sh` and the lifecycle harness look at has + # to be the same bytes the `loaders` job proved satisfy the table. + - uses: actions/download-artifact@v4 + with: + name: ipxe-loaders + path: packaging/ipxe/out + - name: Assemble the DSM packages run: | set -euo pipefail + ls packaging/ipxe/out packaging/dsm/make-spk.sh armv7 packaging/dsm/make-spk.sh x86_64 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4a3ad23..ce53fa6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -130,9 +130,75 @@ jobs: path: dist/*.tar.gz* retention-days: 7 + loaders: + name: Branded iPXE loaders + needs: verify + runs-on: ubuntu-latest + # **Without this the release is incomplete, and quietly so.** A deployment that + # installs the package gets a TFTP server with nothing to hand out: `boot check` + # reports three MISSING loaders and every machine the generated DHCP snippet sends + # here asks for a file, gets nothing, and stops. Until this job existed, "one binary + # boots an arbitrary machine" was true only for whoever built iPXE themselves. + # + # They are a separate download rather than part of any binary archive or `.spk` + # because they are iPXE and iPXE is GPLv2: separate files served alongside is mere + # aggregation, and packaging/ipxe/ is the written offer that goes with them. + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag || github.ref }} + + # iPXE's EFI targets need the architecture's own `ld` and `objcopy`; the BIOS ones + # build with the host's, and they are 32-bit x86 — which is why this job is amd64 + # and not arm. + - name: Toolchain + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + build-essential liblzma-dev mtools xorriso isolinux gcc-aarch64-linux-gnu + aarch64-linux-gnu-ld --version | head -1 + + - name: Build them + run: | + set -euo pipefail + VERSION="${{ needs.verify.outputs.version }}" + NAME="rescriptum-boot-assets-$VERSION" + packaging/ipxe/build.sh --out "$GITHUB_WORKSPACE/dist/$NAME" + cat "$GITHUB_WORKSPACE/dist/$NAME/SHA256SUMS" + + # The same question CI asks of the same script: does the server agree the directory + # satisfies the table it serves from. A snippet naming a loader that is not here + # fails silently at the ROM, and this is the only thing that catches it before a + # release goes out. TFTP off because the question is the file set, not a listener. + - name: Does the server agree the set is complete? + run: | + set -euo pipefail + VERSION="${{ needs.verify.outputs.version }}" + cargo build --release + RESCRIPTUM_BOOT_DIR="$GITHUB_WORKSPACE/dist/rescriptum-boot-assets-$VERSION" \ + RESCRIPTUM_TFTP_ADDR=off \ + RESCRIPTUM_PUBLIC_HOST=192.0.2.10 \ + ./target/release/rescriptum boot check + + - name: Pack + run: | + set -euo pipefail + VERSION="${{ needs.verify.outputs.version }}" + NAME="rescriptum-boot-assets-$VERSION" + tar -C dist -czf "dist/${NAME}.tar.gz" "$NAME" + ( cd dist && shasum -a 256 "${NAME}.tar.gz" > "${NAME}.tar.gz.sha256" ) + ls -l dist + + - uses: actions/upload-artifact@v4 + with: + name: boot-assets + path: dist/*.tar.gz* + if-no-files-found: error + retention-days: 7 + package-dsm: name: Synology packages - needs: [verify, build] + needs: [verify, build, loaders] runs-on: ubuntu-latest # Nothing is compiled here: the binaries are already built and statically linked, and # an .spk is a release format — the same artifact, wrapped for one platform's package @@ -156,6 +222,14 @@ jobs: VERSION="${{ needs.verify.outputs.version }}" SPK_BUILD="${{ inputs.spk_build || '1' }}" mkdir -p bins + # **The loaders go inside the package.** A TFTP server with nothing to hand out + # boots nothing, and telling a NAS owner to find a second download is how a + # working appliance becomes a support thread. They are iPXE, GPLv2, separate + # files never linked into our binary — mere aggregation, and the NOTICE beside + # them is the written offer that travels with them. + tar -xzf "artifacts/boot-assets/rescriptum-boot-assets-$VERSION.tar.gz" -C . + export RESCRIPTUM_LOADERS="$PWD/rescriptum-boot-assets-$VERSION" + ls "$RESCRIPTUM_LOADERS" # One .spk per *build*; the arch line inside each covers the platforms that # build serves. aarch64 (arch="armv8") joins this list once the binary has been # run on one of its platforms — make-spk.sh already knows the mapping. @@ -184,7 +258,7 @@ jobs: publish: name: Publish the release - needs: [verify, build, package-dsm] + needs: [verify, build, loaders, package-dsm] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/.gitignore b/.gitignore index 6ec4b7c..9ce408f 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,13 @@ node_modules/ # ── Build output / generated ───────────────────────────────── dist/ +# Scratch for a deployment's own files — answer documents carry credentials and must +# never reach a public repository. +tmp/ +# The built loaders. **No binaries in git, ever** — packaging/ipxe/ is the written +# offer, and build.sh reproduces them from the pinned commit. +packaging/ipxe/out/ +packaging/ipxe/.work/ build/ .astro/ .output/ diff --git a/CLAUDE.md b/CLAUDE.md index 6eba935..1ee0dc3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,19 +88,67 @@ These are deliberate design decisions, not oversights. Do not "improve" them wit directly. **Never re-declare a module in `main.rs`**: it compiles a second copy, runs every unit test twice, and lets the two copies drift. - `src/store/` — where documents come from. `mod.rs` defines the thin `Store` / `StoreWrite` - traits, `file.rs` a flat directory of documents, `sqlite.rs` a bundled-SQLite database. + traits, `file.rs` a **directory per identity** (see *Layout on disk*), `sqlite.rs` a + bundled-SQLite database. +- `src/boot/` — **boot media**: where the installer itself comes from, as opposed to what + it is told. `sources.rs` is the odd one out: it is where images can be fetched *from*, + and it stores **nothing about any specific image** — each entry names the checksum index + a vendor already publishes, so the list and the digests are read from them at the moment + somebody asks. A baked-in table of URLs would ship stale and serve 404s. `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 serving a kernel is a *seek*, + never an extraction); `probe.rs` places an image from a table of markers; `catalog.rs` + discovers what is held, cached behind the directory mtime like the answer listing; + `media.rs` is the listener, on its own socket; `stanza.rs` holds what each installer + family needs on the wire; `patch.rs` adds one file to an ISO as a *plan* rather than a + rewrite; `tftp.rs` hands over the loader and nothing else; `loaders.rs` is the option-93 + table **both** TFTP and `boot dhcp-snippet` read, so the two cannot drift; `menu.rs` + writes the bootstrap and the menu; `dhcp.rs` generates six configuration formats; + `privileges.rs` drops after binding; `cpio.rs` and `sha256.rs` are hand-written and + dependency-free. Behind the `boot` cargo feature, default on. `select.rs` knows none of + this exists, and the only seam is that `media ipxe` **prints an ordinary `.ipxe` answer + document** — selection, layering and templating then apply unchanged. +- **No installer image is in this repository or in a release.** An ISO is somebody + else's artefact, gigabytes, on its own schedule. `RESCRIPTUM_MEDIA_DIR` is where a + deployment keeps them and **that directory is the archive**: `media add ` fetches + one into it (through `curl`/`wget` — there is no TLS in the binary), and nothing ever + modifies it afterwards. Preparing an image produces a sidecar plus an injection applied + on the wire, so the bytes on disk stay what the vendor published and stay checkable + against the vendor's own `SHA256SUMS`. A URL **requires** `--sha256` unless + `--unverified` is passed: this decides what every machine installs, and the unsafe path + has to be a deliberate act. +- `packaging/ipxe/` — the branded loaders: `branding.h`, the embedded script, a + SHA-pinned upstream commit and `build.sh`. **No binaries in git, ever**; this directory + is the GPLv2 written offer. The built loaders **do** ship — inside the `.spk` and as + `rescriptum-boot-assets-.tar.gz` — because a TFTP server with nothing to hand + out boots nothing. That is aggregation, not linking, and the `NOTICE` naming the pinned + commit travels with them everywhere they go. `packaging/boot-rig/` — the boot rig, three services on an + `internal: true` network, so a harness that runs DHCP cannot answer on the host's LAN. - `src/facts.rs` — what a request says about the machine: query parameters, a flattened JSON body, and the raw haystack. - `src/format/` — one interface per document format. `xml.rs` holds the XML tree and its merge rules. - `src/merge.rs` — the TOML merge, used by `format`. -- `src/cli.rs` — the `render`, `check`, `import`, `export` and `config` subcommands. +- `src/cli.rs` — the `render`, `check`, `import`, `export`, `migrate` and `config` + subcommands. `migrate` **shows by default and moves only on `--apply`**, and a single + taken destination aborts the whole run rather than leaving a half-migrated directory. `config` is dispatched **before** `Config::from_env` and `validate`, unlike every other one: a file that will not parse and a token one character short are the states people run it to get *out* of, so it loads the file itself and reports rather than dying. - `src/admin.rs` — the write API: its own listener, the constant-time token, the failure guard, and the rollback that keeps a write from breaking the answer set. - `src/capture.rs` — recording request bodies (`RESCRIPTUM_CAPTURE_DIR`). +- `src/installed.rs` — a machine reporting it finished, and its install claim being + dropped. **The one path where something arriving over the network changes the answer + set**, so it is narrow by construction: machine documents only (never a group — one + machine finishing must not disarm a rack), format `ipxe` only (the `.toml` is the record + of how it was built), and moved into an `installed-/` **sibling directory** rather + than deleted — a name that identifies nothing, so no new exclusion rule is needed. The + token is Proxmox's, and it arrives **in the JSON body**, not as a bearer — so the route + runs before the answer token's guard, which would otherwise reject every webhook. It + also takes a bearer header and the identity from the query, because **Proxmox is the + only family with a webhook**: every other one reports from a `%post`, a + `late_command` or a chroot script, where one `curl` is writable and composing + Proxmox's JSON is not. - `src/config.rs` — environment configuration. `Config::from_lookup` takes a lookup closure so tests never touch the process environment. - `src/envfile.rs` — the optional file of defaults `RESCRIPTUM_ENV_FILE` names, and the @@ -120,6 +168,11 @@ These are deliberate design decisions, not oversights. Do not "improve" them wit it could not report something. - `src/main.rs` — runtime setup, accept loop, connection serving, routing, logging, and the blocking `resolve()` half of a request. +- `tests/common/mod.rs` — the one thing every suite shares: `seed()` writes a fixture named + the way a test thinks of it (`98fa9b50d810.toml`, `groups/rack-a.toml`, `default.toml`) + **through `StoreWrite`**, so it lands exactly where an admin-API write would and cannot + drift from the layout. A name the store would refuse is written literally, because those + fixtures exist to prove a stray file answers nothing. One copy, not one per suite. - `tests/integration.rs` — starts the real binary on an ephemeral port (it prints the address it actually bound, so there is no port race) and talks HTTP to it. It keeps the server's stderr, which is what makes a startup warning assertable. @@ -183,8 +236,9 @@ and must not move because someone renamed a folder. An earlier design made the d name *be* the URL segment and was discarded for exactly that reason. The consequence that makes the model click: **a machine's answer is specific to the OS it -is for**, so `98fa9b50d810.toml` is not "that machine" but "that machine as Proxmox". -`98fa9b50d810.preseed` is the same hardware as Debian, and both exist at once. The store's +is for**, so `98fa9b50d810/proxmox.toml` is not "that machine" but "that machine as +Proxmox". `98fa9b50d810/debian.preseed`, beside it, is the same hardware as Debian, and both +exist at once. The store's key is therefore **(id, format)**, not id — which is what the SQLite schema is built around. Two traps in the alias table: @@ -253,20 +307,20 @@ format** — a YAML machine file over a TOML group is refused, not half-served. ## Grouping and merging -A datacenter has a file per machine, and machines in a rack share almost everything. Answer -files therefore compose: +A datacenter has a directory per machine, and machines in a rack share almost everything. +Answer documents therefore compose: ```text answers/ groups/ - base.toml shared by everything - rack-a.toml extends = "base"; members = [ ...MACs... ] - 98-fa-9b-50-d8-10.toml one machine's overrides (optional) - default.toml only when nothing else matches + base/proxmox.toml shared by everything + rack-a/proxmox.toml extends = "base"; members = [ ...MACs... ] + 98-fa-9b-50-d8-10/proxmox.toml one machine's overrides (optional) + default/proxmox.toml only when nothing else matches ``` - A **group** claims machines by listing them in `members`. Member strings are normalized the - same way filenames are, so separator style does not matter. + same way directory names are, so separator style does not matter. - A group may `extends` another group, giving a chain. Cycles and missing parents are detected at load, reported once, and the broken group is dropped rather than half-applied. - A **machine file** layers on top of whichever group claimed it. `extends` in a machine file @@ -339,8 +393,21 @@ Both are write-capable (`StoreWrite`), which is what the admin API will use: `import ` and `export ` move between the two. The round trip is byte-identical, which is worth keeping true — it is what makes the database safe to adopt and safe to leave. -The `sqlite` cargo feature is on by default and can be turned off: 2,103,456 bytes with it, -944,928 without, on armv7. +Two cargo features, both on by default and both removable: `sqlite` and `boot` (the media +catalogue, the ISO reader and the media listener). Measured on armv7-gnueabihf, floor 2.17 +— **re-measure rather than trusting an older figure here: the numbers moved by ~375 KB +when the target changed from musl to glibc, and a stale baseline once turned a 71% budget +spend into an apparent 293% overrun.** + +| Build | Bytes | +|---|---| +| `sqlite` + `boot` (default) | 2,741,360 | +| `sqlite` only | 2,482,000 | +| neither | 1,316,648 | + +**`boot` costs 259,360 bytes, against a ≤170 KB budget the plan set before any of it was +written** — the image-source catalogue added 31,520 of that. That is recorded in `plans/boot-media.md` with a per-phase breakdown rather +than quietly exceeded; the figure needs re-deciding against the measurement. ## The admin API @@ -457,19 +524,173 @@ could not check. Note it needs `Resolution::format_name` (the extension), not model: they are that machine's answers for two operating systems. - **Assert on parsed values, not on formatting.** Replacing a table with a scalar leaves the key's original decor, so the output can read `value= 3` — valid TOML, different text. - -## Core algorithm (the part worth understanding up front) - -Answer files live in a configurable directory, named after a MAC address -(`98-fa-9b-50-d8-10.toml`, `aabbccddeeff.toml`, plus an optional `default.toml`). +- **`HeaderName::from_static` panics on a name that is not lower-case.** It compiles. + At runtime it kills the connection before anything is written, so the symptom is an + *empty response*, not an error. Put the header in the response builder, which takes any + casing. +- **A guard against two listeners sharing a port must exempt `:0`.** Port zero asks the + kernel for any free port, so two of them never collide — and it is what every + integration test uses. +- **A self dev-dependency re-enables default features unless told not to.** Without + `default-features = false`, `rescriptum = { path = "." , features = [...] }` turns + `sqlite` and `boot` back on for every test build, so `--no-default-features` tests the + full binary and reports coverage that does not exist. +- **`;` in an iPXE script separates commands only as a whole whitespace-delimited token** + (`split_command` in iPXE's `core/exec.c`). So `ds=nocloud-net;s=http://…` is one + argument and must **not** be escaped, while `foo ; bar` is two commands. +- **A PXE ROM retransmits its read request** when an answer is slow — a sleeping NAS disk + is enough. A per-peer transfer cap is therefore a fairness bound, not a hostility + threshold; counting malformed packets against it locks a machine out of the server it + is retrying to reach. +- **Logging an intention is not logging an outcome.** The TFTP transfer line was written + before the first byte went out, so a stalled transfer and a completed one looked + identical — a machine on a real network fetched a loader, nothing happened, and the log + said success. It is reported at the end now, `sent` or `FAILED after N of M`, with 500 + so `RESCRIPTUM_LOG=problems` keeps it. +- **Intel AMT with a static address starves the host's DHCP on a shared NIC.** The + Proxmox installer's `dhclient` gives up after about eleven seconds; with the Management + Engine holding the interface statically while the host asks for a lease, no offer + arrives and the install aborts on `Network is unreachable` — while `dhclient -v eno1` + from the installer's own shell succeeds instantly afterwards. Setting AMT to DHCP fixes + it. Nothing here can widen that window, so it is documented rather than worked around. +- **`auto-installer-mode.toml`'s keys are snake case, and one hyphen rejects the file.** + `AutoInstSettings` is `deny_unknown_fields`, so `partition-label` is not a warning — the + installer refuses the whole document and stops, asking a human who is not coming. Its own + refusal enumerates them: `mode`, `partition_label`, `http`; and inside `[http]`, `url`, + `cert_fingerprint`, `token`. The doc comment above the writer said exactly this while the + code wrote two hyphenated keys, and the test enumerated the hyphenated ones too. +- **In iPXE, naming an initrd turns it into a *file* rather than the initramfs.** + `initrd ` gets a cpio header and lands as `/name`; `initrd ` is + appended raw and *is* the initramfs. Naming Proxmox's real initrd produced an initramfs + holding `/initrd.img` and `/proxmox.iso` and no `/init` — the kernel unpacked 1.7 GB + without a complaint, found nothing to run, and panicked with `VFS: Unable to mount root + fs on unknown-block(0,0)`. **The ISO does take a name**, because the installer opens it + by that name, so the two lines differ on purpose. `initrd=` on the command line is a + pxelinux directive, not something the kernel resolves against a filename. +- **`Freeing initrd memory: NNNN K` means the unpacking worked.** It is the line that + rules out every compression theory, and three guesses were spent before reading it. +- **Never acknowledge a TFTP option that is not implemented.** `windowsize` (RFC 7440) was + echoed back in the OACK while the transfer loop sent one block and waited for its ACK. A + client told `windowsize 4` waits for four blocks before acknowledging anything, so both + sides waited and only the 700 ms retransmit broke the deadlock — every block costing a + resend and 700 ms, which makes a 1.1 MB loader take **nine minutes** and the firmware + give up first. RFC 2347 says an option left out of the OACK is treated as never + requested, so declining costs one ACK per block and nothing else. Found on the wire, in a + capture on the NAS, after firewalls and block sizes had both been wrongly blamed. +- **A test client that does not behave like the client it stands in for proves nothing.** + The first version of the test for the above acknowledged every block whatever was + negotiated, so the deadlock could not happen and it passed with the bug reintroduced. It + honours the granted window now: 0.5 s healthy, 63 s with the bug back. +- **A `connect`ed data socket makes the kernel drop what you most need to see.** It + filters on the peer's address *and port*, so a client that acknowledges from a different + source port has its packets discarded before any code runs — and the transfer dies + looking exactly like a firewall eating them, with nothing to log. RFC 1350 says a client + keeps its TID; the ones that do not are UEFI ROMs, which is the population being served. + The socket accepts from the same *address* now and says when the port moves. +- **A TFTP transfer leaves port 69 immediately, and that is what a firewall does not + expect.** The server answers from a fresh port and the client acknowledges to *that*, so + a rule allowing only 69 lets the request in, lets the answer out, and drops the + acknowledgement — the machine then looks like it lost interest, which is as hard to read + as this protocol gets. `RESCRIPTUM_TFTP_PORT_RANGE` pins it so it can be opened. The DSM + package pins **30000-30063** and registers it: chosen on the machine, below the kernel's + ephemeral range (32768–60999, so nothing there can be handed away), clear of every UDP + port a stock DSM uses, claimed by no Synology `.sc`, and all 64 verified bindable. +- **`blksize=1468` fills a 1500-byte path exactly** — 1468 payload, 4 TFTP, 8 UDP, 20 IP — + which is what iPXE asks for and what leaves no room at all. One VLAN tag makes the frame + 1504, and a PXE ROM meeting that usually stops without a message. `RESCRIPTUM_TFTP_BLKSIZE` + caps it; 1400 covers a tag and most tunnels, 512 always works. +- **A TFTP transfer ends on a *short* block, and "short" includes empty.** A file whose + length divides exactly by the block size must end with an empty data packet, or the + client waits forever for a final block that never comes. +- **`;` separates iPXE commands only as a whole whitespace-delimited token** + (`split_command` in iPXE's `core/exec.c`), so `ds=nocloud-net;s=…` is one argument and + must not be escaped. And **`${version}` is iPXE's own version**, not ours. +- **`net0` is the first NIC, not the booting one** — `${netX/mac}` names the device that + actually booted. **iPXE percent-encodes nothing on plain expansion**, so an SMBIOS + string in a URL needs `${…:uristring}`. +- **A UEFI HTTP Boot client discards a DHCP offer that does not echo `HTTPClient`** in + option 60. **A Windows DHCP policy cannot condition on option 93 at all** — the + architecture reaches it only inside the option-60 string. +- **`auto-installer-mode.toml` is not a legal ISO9660 identifier.** Without a Rock Ridge + `NM` entry the file is in the image and invisible to the installer, which looks like + this server being broken. +- **iPXE's BIOS targets need an x86 compiler and its ARM64 ones need + `CROSS_COMPILE=aarch64-linux-gnu-`.** Both failures read like a broken Makefile. +- **A container that died looks exactly like one still starting.** Three of the boot + rig's first five failures hid behind that; anything that waits on a service has to + check it is still running and print its log when it is not. +- **A service on an `internal` Docker network cannot reach apt.** Install at image-build + time or it fails silently and the container exits 127 later. +- **dnsmasq logs to syslog unless told otherwise** (`--log-facility=-`), and a container + has no syslog — so a marker grepping its output could never have matched. +- **A `q35` QEMU machine has no IDE controller**, so `-drive if=ide` is simply not there + and SeaBIOS says "could not read the boot disk". Use `pc` for a BIOS guest. +- **`sed -n … "$0"` cannot find a relatively-invoked script after a `cd`.** Resolve the + path first, or `--help` breaks for everyone who does not type an absolute path. +- **The DSM-specific traps are in `packaging/dsm/CLAUDE.md`** — the four routes to port 69 + and why `setcap` is settled, the signature gate `libsynopkg.so.1` spells out, `setcap` + measured on the DS416j's own volume, the capability an upgrade drops, and the panel's + runtime-computed defaults. They load with that directory; `docs/development/traps.md` + has them at length. +- **Binding is not a health check.** A bind that *succeeds* on the TFTP port means nothing + is listening — the degraded state, not the healthy one — and one that fails cannot tell + this server from another daemon squatting the port, since both are `AddrInUse`. So + `boot check` sends a real read request (`boot::tftp::probe`) and reports what a machine + would get. The first version guessed, and a test with a squatter said so at once. +- **macOS lets an unprivileged process bind UDP port 69; Linux does not.** So a test that + reaches the *default* TFTP address takes a different branch on each platform — `boot + check` calls an obtainable-but-silent port a note and an unbindable one a problem, which + is the right rule and exactly what makes the test platform-dependent. It passed locally + and failed in CI for a reason that had nothing to do with the change. Any test that sets + `RESCRIPTUM_BOOT_DIR` must also set `RESCRIPTUM_TFTP_ADDR=off` unless the probe *is* the + subject; `tests/tftp.rs` covers the unbindable port on a high one. +- **A branch developed entirely offline has never met the CI.** This one accumulated 57 + commits before its first push, and the first run failed on two things no local run could + see: a clippy five versions newer than the pinned local toolchain, and a Linux-only port + permission. Push early enough to find out, or expect to. +- **The size figures in this file go stale.** They moved ~375 KB when armv7 changed from + musl to glibc. Re-measure before concluding anything from them; a stale baseline once + turned a 71% budget spend into an apparent 293% overrun. +- **A directory's mtime does not see one level down.** With a directory per identity, adding + or editing a document *inside* a machine's directory moves nothing the listing cache + watches, so only `RELOAD_BACKSTOP` catches it. Only the identity itself appearing or + leaving is immediate. A test for "a removed document stops being served" that expects it + immediately is testing the old layout and will hang on the new one. +- **An AppleDouble is worse in a directory than it was flat.** `._proxmox.toml` is now a + *second* `.toml` in a directory that may hold only one, and `.` sorts before every letter + — so a rule that took the first would serve a binary body to every request. `visible_name` + is what stops it, and a test asserts the litter is not even *reported* as a conflict. +- **`git checkout ` to undo a deliberately-broken test also undoes the work.** Copy + the file aside before breaking it to watch a test fail; a `git checkout` here silently + reverted a whole feature and its test, and only a `grep` afterwards caught it. + +## Layout on disk, and the core algorithm + +**One directory per identity.** A machine is a directory in `RESCRIPTUM_ANSWERS_DIR` named +after it, holding one document per format; `groups/` and `default/` are the same shape under +names the layout reserves (`valid_machine_id` refuses both as machine ids, in *both* stores, +so `export` from SQLite can always be represented). + +Inside a directory, **the extension is the format and the stem is nothing at all** — +`proxmox.toml` and `answer.toml` are one document. `format::canonical_stem` picks a readable +name for a document nobody has named; an existing one is overwritten *where it stands*, so +an operator's name survives a write. Two documents of one format in one directory is a +**reported problem**, not a resolved one: there is no tiebreak anyone could predict. Sorted +order decides which answers so the choice does not depend on readdir, and the loser is named. + +A servable document left flat at the top — the layout before this one — is **reported and +not served**, its destination spelled out, and `rescriptum migrate [--apply]` moves them. +`store::file::pending_moves` is that knowledge exposed once, so the command and the reader +cannot disagree. Half-reading an old layout would mean a machine whose answer moved silently +between two files. Selection per request: 1. Normalize the request body: lowercase, strip every non-alphanumeric character. -2. For each `.toml` in the directory (excluding `default.toml`), normalize `` the +2. For each identity directory (excluding `groups/` and `default/`), normalize its name the same way and test whether it appears as a substring of the normalized body. -3. First match wins → return that file. -4. No match → `default.toml` if present, else `404`. +3. First match wins → return that identity's document for the format asked for. +4. No match → `default/` if it holds that format, else `404`. The normalization is what makes this robust: it's indifferent to MAC separator style (`98-fa-9b…` / `98:fa:9b…` / `98fa9b…`) and to the JSON structure, which changes between Proxmox @@ -480,19 +701,30 @@ file is added or removed. The spec asks for a re-read on every request; done lit `readdir` plus a sort plus a normalization pass per request, and with one answer file per machine — the datacenter case — throughput collapses. Measured, 3000 requests at 100 concurrent: -| Files in the directory | Literal re-read | mtime-cached | +| Machines in the directory | Literal re-read | mtime-cached | |---|---|---| | 10 | 11,954 req/s | 12,922 req/s | | 200 | 3,198 req/s | 12,890 req/s | | 2,000 | 311 req/s | 12,520 req/s | | 10,000 | — | 6,924 req/s | -One `stat` replaces the whole walk, and a new file is still picked up with no restart — which is -the guarantee the spec actually wanted. `RELOAD_BACKSTOP` (1 s) forces a re-read even when mtime -looks unchanged, covering filesystems with coarse mtime granularity. Normalized stems are -computed once per directory read, not once per request. - -The remaining cost at 10,000 files is the linear scan of precomputed needles — pure CPU, no +One `stat` replaces the whole walk, and a new machine is still picked up with no restart — +which is the guarantee the spec actually wanted. `RELOAD_BACKSTOP` (1 s) forces a re-read even +when mtime looks unchanged, covering filesystems with coarse mtime granularity. Normalized +identities are computed once per directory read, not once per request. + +**The mtime now sees less, and the read costs more.** A directory's mtime moves when an entry +is added or removed *in it*, so an identity appearing or leaving is immediate while a document +added or edited **inside** one is only caught by the backstop — the same rule that already +covered a file edited in place, now the normal case. And a full reload is a `readdir` per +identity on top of the file it already opened: measured at 2,000 machines on an M1 Pro, +**28.6 ms flat → 63.5 ms with a directory each (2.2×)**, unchanged by removing the +allocations, because it is syscalls. Amortised over a second of requests it did not move +end-to-end throughput measurably; the req/s table above was measured against the flat layout +and has *not* been re-measured with a comparable tool. It is still the reason to reach for a +group before a directory per machine. + +The remaining cost at 10,000 machines is the linear scan of precomputed needles — pure CPU, no syscalls. Bucketing needles by length and sliding a window over the body would remove it, but a 10,000-machine rollout already completes in under two seconds, so it has not been worth the complexity. Measure before adding it. @@ -505,7 +737,7 @@ complexity. Measure before adding it. `Connection: close`. - `404` when no file applies; `500` on read errors. - Reject an implausible `Content-Length` (cap at 1 MB) rather than allocating for it. -- Only read direct entries of the answers directory. Never build a filesystem path from +- Only read the answers directory and one level below it. Never build a filesystem path from request data — that is the path-traversal guard. Logging goes to stdout/stderr, one line per request (timestamp, source IP, body size, chosen @@ -527,6 +759,16 @@ Environment variables only — plus an optional file to read some of them from: | `RESCRIPTUM_TIMEOUT_SECS` | `10` | Header-read timeout **and** whole-connection deadline | | `RESCRIPTUM_LOG` | `all` | `all` \| `problems` (drops the requests that worked) \| `off` | | `RESCRIPTUM_LOG_FILE` | unset | A file to append to, or `stdout`/`stderr`. Unopenable is fatal | +| `RESCRIPTUM_MEDIA_DIR` | unset | Installer images. **Unset is the whole off switch for boot media** | +| `RESCRIPTUM_MEDIA_ADDR` | `0.0.0.0:8001` | The media listener, when there is a media directory | +| `RESCRIPTUM_MEDIA_TIMEOUT_SECS` | `600` | Whole-transfer deadline — deliberately not the answer listener's 10 | +| `RESCRIPTUM_MEDIA_MAX_CONNECTIONS` | `16` | Concurrent transfers; low on purpose | +| `RESCRIPTUM_PUBLIC_HOST` | the routing table's answer, else a sole interface | The host generated URLs name. **A host, never a URL**. Warns and names the alternatives when the host has several | +| `RESCRIPTUM_BOOT_ALLOW` | unset | Client CIDRs allowed to fetch boot media | +| `RESCRIPTUM_BOOT_UNCLAIMED` | `menu` | Or `local`. **Inverts what an answer file means**: with `local`, present is *install this one* and absent is the safe state | +| `RESCRIPTUM_INSTALLED_TOKEN` | unset | Proxmox's webhook token. Set it and `POST /installed` drops a machine's `.ipxe` claim when it reports success. **Unset, the endpoint does not exist** | +| `RESCRIPTUM_BOOT_DIR` | unset | Loaders and menus. **Unset means no TFTP at all** | +| `RESCRIPTUM_TFTP_ADDR` | `0.0.0.0:69` when `RESCRIPTUM_BOOT_DIR` is set | Or `off`, a deployment workaround, never a packaged default. A failed bind here warns rather than killing the server | A zero or unparseable numeric value falls back to the default rather than starting a server that accepts and never answers. @@ -534,8 +776,11 @@ that accepts and never answers. **What is fatal and what is a warning is deliberate.** Fatal: the listener cannot bind, the store cannot be opened (SQLite catches an unwritable directory, a corrupt file and a too-new schema at open, not at the first request), a named env or log file cannot be read, -and any unsafe admin combination. A warning: the answers directory is absent, is not a -directory, or cannot be listed, and any problem in the answer set — all three can be fixed +and any unsafe admin combination — **except the TFTP one**, which is the single exception +and is measured rather than argued: port 69 is the only privileged port in the design, so +it is the only bind that can fail for something nobody configured, and dying there takes +answers and media with it. A warning: a failed TFTP bind, the answers directory being +absent, not a directory, or unlistable, and any problem in the answer set — all fixable while the server runs, and it re-reads as they change. The directory check asks the filesystem whether it can list rather than reading permission bits, so it accounts for owner, group, ACLs and the mount; that is the failure a packaged, non-root run meets first. @@ -563,14 +808,15 @@ loud), `export` accepted so one file can also be sourced, a duplicate key is an Local development (once the crate exists): ```bash -cargo build cargo run -- check # validate an answers directory +cargo run -- migrate # show what a flat answers directory would become +cargo run -- migrate --apply # move those documents into a directory each cargo run -- config # show the configuration and where each value comes from cargo run -- render # print one machine's composed answer -cargo test # all tests -cargo test # single test by name substring -cargo test -- --nocapture # show stdout from tests -cargo fmt && cargo clippy +cargo run -- media list # the installer images held +cargo run -- media add FILE # register one: verify, probe, record its digest +cargo run -- media check # re-verify every recorded digest +cargo run -- media ipxe ID # the .ipxe answer that boots one image ``` Documentation (see *Documentation* below): @@ -617,172 +863,44 @@ Verify the first cross-build actually produces a static binary (`file` should sa linked*) and that it runs on the NAS. If ARMv7 misbehaves, confirm the real architecture with `uname -m` on the NAS before pushing further. -## Release profile - -`Cargo.toml` optimizes for size, minus the spec's `panic = "abort"` (see Hard constraints): - -```toml -[profile.release] -opt-level = "z" -lto = true -codegen-units = 1 -strip = true -``` - ## The DSM package `packaging/dsm/` wraps an already-built binary as a DSM 7 `.spk`. It is a **release format**, exactly like the `.tar.gz` archives — no DSM-specific build, no feature flag, -nothing in `src/`. The three places DSM pressed back are answered in packaging: log rotation -by a `copytruncate` stanza, a CLI that cannot find its configuration by a three-line wrapper -(`rescriptum-cli`, which names `RESCRIPTUM_ENV_FILE`), and no settings panel by the desktop -application below. If this ever seems to need a `#[cfg]`, the design has gone wrong. - -```bash -./build.sh --spk x86_64-unknown-linux-musl # build, then wrap -packaging/dsm/make-spk.sh armv7 # wrap an existing build -packaging/dsm/check-spk.sh # structural check ⎫ both run -packaging/dsm/lifecycle-test.sh # drive the scripts ⎭ by ci.yml -packaging/dsm/vm/on-dsm.sh admin@nas # what only DSM can answer -``` - -**The package is tested in three places, and none of it is Rust** — `cargo test` does not -touch it. `check-spk.sh` asserts the archive's shape; `lifecycle-test.sh` unpacks an `.spk` -into a fake `/var/packages` tree and drives the real scripts through install (with a wizard -and without), start, `/health`, the exit codes, an upgrade over a hand-edited env file and -a canary — with `etc/` surviving and with it wiped — and an uninstall; both run on every -push. `vm/on-dsm.sh` runs the rest on a DSM 7 VM and then on the DS416j: `data-share`'s -ACL, `port-config`, the generated unit, `logrotate -f` against a live descriptor, and -whether Package Center accepts the archive at all. **Nothing ships on VM evidence alone**, -and `lifecycle-test.sh` was watched failing — breaking four guards turns 33 green into 25 -green and 8 red. - -### The desktop application - -`packaging/dsm/payload/ui/` is a **real DSM application** — `SYNO.SDS.AppWindow`, -`syno_formpanel`, `syno_textfield`, `syno_combobox`, `syno_button` — not a page of ours in a -frame. `dsmuidir="ui"` makes DSM symlink it into -`/usr/syno/synoman/webman/3rdparty/rescriptum`, and `dsmappname` names the class `ui/config` -declares. It manages the server's configuration, shows its status and tails its log. - -**ExtJS, not Vue, and the machine decided that.** DSM 7.2 ships a Vue framework and -Synology's current guide documents only that one — the first version of this was written -against it. The DS416j is capped at **DSM 7.1.1**, where `Vue` is undefined. ExtJS is on both -(7.1.1 and 7.2.2, measured), so one application covers every DSM this package supports; -`os_min_ver` is **7.1**, and 7.0 is not claimed because nothing has run there. The API is -documented in the ExtJS reference Synology generated for DSM, mirrored at - as `docs/synoextjsdocs.tar.gz`. - -The design rule holds: nothing in `src/` knows any of this exists. What the server gained is -a *generic* `config` subcommand, and the application's backend — `ui/api.cgi` — is a hundred -lines of shell that authenticate and then shell out to `rescriptum-cli config`. The env-file -semantics stay in Rust where they are tested rather than being written a second time in `sh`. - -**Four things were measured on the machine and every one of them is load-bearing. None is in -the developer guide** (they are in `docs/development/traps.md` at length): - -- **A CGI there runs as the owner of the script**, which for a package tree is the package - user. Not `http`, not root. That is what lets it read the `0600` env file it owns, and why - it cannot start or stop anything — restarting goes through DSM's own - `SYNO.Core.Package.Control`, from the application, with the administrator's session. -- **DSM does not authenticate that path.** An unauthenticated request gets `200`. So - `authenticate.cgi` plus an `administrators` check *is* the door, and a write additionally - needs a header a cross-origin page cannot make a browser send. Losing any of them would be - silent, which is why `check-spk.sh` greps for them **with the comments stripped** — the - first version of that check passed because the word appeared in a comment. -- **No `su`, ever.** It hangs a CGI outright without ``**, so the package root is the - fixed `/var/packages/`, never `dirname "$SYNOPKG_PKGDEST"`. `RESCRIPTUM_PKG_ROOT` is - the seam that lets `lifecycle-test.sh` drive the scripts against a writable tree. -- **`etc/` and `var/` survive an uninstall** (they are symlinks into `@appconf`/`@appdata`), - so the env file and its tokens outlive the package — said plainly in the Synology page. -- **`$SYNOPKG_TEMP_UPGRADE_FOLDER` outlives its upgrade**, so restoring from it requires - `SYNOPKG_PKG_STATUS = UPGRADE` or a fresh install resurrects a removed configuration. -- **The firewall directory is `/usr/local/etc/services.d/`** (plural; the guide is wrong), - and `port-config` acquires *after* `postinst` — the wizard's port does reach it. Both - `port-config` and `usr-local-linker` acquire when the package is **enabled**, not at - `postinst`. -- **The generated unit has no `Restart=`**: DSM does not restart the process if it dies. +nothing in `src/` knows it exists, and if it ever seems to need a `#[cfg]`, the design has +gone wrong. It carries a real DSM desktop application for the settings panel, and it ships +the branded loaders, so the share's `boot` folder arrives filled. **Changing anything under `packaging/dsm/` means running the machine**, not just the local -harness — the procedure is in `packaging/dsm/vm/README.md` (*Changing the package? This is -the procedure*), and `AGENTS.md` points at it. A DSM 7.2.2 VM already exists in Docker on -the maintainer's machine with a `clean` snapshot; `bootstrap.sh` sets one up from scratch, -`on-dsm.sh` drives it, and the run is destructive on purpose. It asks the server for a real -answer — a machine file merged over the group that claims it — rather than settling for -`/health`. - -The harnesses catch a broken archive and broken scripts; only Package Center catches a -broken package. **A tag must not be the first time an `.spk` meets a DSM machine** — the -rig is `packaging/dsm/vm/`: `docker-compose.yml` runs Synology's own Virtual DSM (DSM 7.2, -close to the DS416j's 7.2.1). KVM makes it fast, not possible — without `/dev/kvm` the image -falls back to emulation on its own, about ten times slower, which is what -`docker-compose.emulated.yml` is for. What does stop a host is **14 GiB free**, hardcoded in -the image and not derived from `DISK_SIZE`. `run-vm.sh` is the loader-image fallback. +harness. `packaging/dsm/CLAUDE.md` holds the whole contract — the four places DSM pressed +back, the privilege routes measured on a DS416j and the DS416j run itself, the desktop +application, the lifecycle rules and the DSM traps — and it loads whenever you touch that +directory. The procedure is in `packaging/dsm/vm/README.md` (*Changing the package? This +is the procedure*), which `AGENTS.md` also points at. ## Testing expectations -333 tests, plus the package's own harnesses (see *The DSM package*, and note that +582 tests, plus the package's own harnesses (see *The DSM package*, and note that `cargo test` does not run those). `docs/development/testing.md` has the per-suite table; the rules that decide where a test goes: +- **The whole boot chain belongs in `packaging/boot-rig/`**, which is not Rust and which + `cargo test` does not run. `run.sh` boots a claimed and an unclaimed machine in QEMU + and asserts four markers; CI runs the same thing plus one deliberate break. **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** — measured, which is why + the primary rig is one container on a private bridge rather than four on a Docker + network. +- **TFTP belongs in `tests/tftp.rs`**, speaking the protocol over real UDP. A transfer is + a conversation, and every bug worth catching lives in the turn-taking: the first run + found two, both of the "works by hand, never after a reboot" kind. +- **Boot media belongs in `tests/media.rs`**, against the real binary with both listeners + up. Every abuse case there ends by proving the server still answers, and one case proves + the property the separate socket exists for: **answers keep succeeding while four image + transfers are in flight**. There is deliberately **no binary ISO fixture in this + repository** — `boot::iso::build` writes images in memory, behind the `test-support` + feature so it never reaches a release binary. + - **A behaviour belongs in `tests/stores.rs`**, which runs it against both stores and requires the identical outcome. One that covers a single backend proves half of what it claims, and the half it skips is where a divergence hides. @@ -806,45 +924,15 @@ the rules that decide where a test goes: `docs/` is the documentation site, rendered by **notabene** (`@z29k/notabene`, the sibling project) and published to GitHub Pages at . Two spaces, -two audiences, and they do not interleave: +two audiences: `docs/guide/` is **using** rescriptum, `docs/development/` is **working on** +it. Nothing in `src/` knows the site exists. -- `docs/guide/` — **using** rescriptum: install, quick start, installer media, writing - answers (`answers/`), running it (`operations/`), exhaustive tables (`reference/`). -- `docs/development/` — **working on** rescriptum: constraints, architecture, request - lifecycle, internals per module, testing, building, releasing, traps. - -This file and `docs/development/` overlap deliberately: this one is condensed for agents, +This file and `docs/development/` overlap deliberately — this one is condensed for agents, that one is written for a human reading in order. **A change to a constraint belongs in -both.** A user-visible change should land with its documentation in the same PR. - -- `notabene.config.mjs` — the site configuration. `review: "approve"` means an agent - *proposes* doc edits and a human validates each against its real git diff at `/review`. - `i18n: { locales: ["en","fr"], strategy: "suffix" }` is what makes the FR siblings work. - **`tagline` is a plain string, not a per-locale map** — notabene does not accept a map - there, and one stringifies to `[object Object]` in the topbar and `llms.txt`. -- `assets/rescriptum-logo.jpg` — the logo (a sealed rescript on a floppy disk), used as the - topbar logo, the favicon, the social card, and the README header. -- `docs/.notabene/` — the comment and journal store, plain JSON, **committed**. The agent - protocol is `docs/.notabene/protocol.md`, pointed at from `AGENTS.md`. -- `package.json` exists **only** for this. Nothing in `node_modules` is executed by the - published site or reaches the Rust binary. `npm audit` reports unfixable transitive - advisories in Astro/esbuild/sharp; they are development-only. -- `.github/workflows/docs.yml` publishes from `main` (so docs normally ship with a - release; `workflow_dispatch` publishes a fix that should not wait). `ci.yml` has a - `docs` job that builds and runs `notabene lint` on every push. - -Writing conventions: relative `.md` links between pages (they become routes *and* stay -clickable on GitHub), absolute GitHub URLs for repository files outside `docs/`, frontmatter -`title` / `description` / `sidebar.order`, Mermaid in ` ```mermaid ` fences. - -From a French page, a link still uses the **base** name (`./selection.md`, never -`./selection.fr.md`) — notabene resolves the locale — but the **anchor must be the French -heading's slug**. `notabene lint` checks routes, **not anchors**: verify those against the -built HTML. - -**Verify prose against the binary rather than against this file.** Every command output in -`docs/` was captured from a real run; several passages in the older README had drifted from -what the code actually prints. +both**, and a user-visible change should land with its documentation in the same PR. + +`docs/CLAUDE.md` holds the rest — the site configuration, the FR mirroring rules, the +writing conventions and the anchor trap — and loads whenever you touch `docs/`. ## Language @@ -874,7 +962,10 @@ SemVer tags. Keep PRs focused. What does **not** carry over from notabene: it is an npm package and publishes prereleases to an `@dev` dist-tag. This project ships a **compiled binary**, so the release artifact is a GitHub Release with cross-compiled binaries attached, built by a CI matrix, plus a `.spk` -per Linux ABI from the `package-dsm` job. A `spk_build` dispatch input ships a +per Linux ABI from the `package-dsm` job and **`rescriptum-boot-assets-.tar.gz` +from the `loaders` job** — the branded iPXE loaders, their own download because they are +GPLv2 and `packaging/ipxe/` is the written offer. Without it a release ships a TFTP server +with nothing to hand out. A `spk_build` dispatch input ships a packaging-only fix as `0.1.0-2` without a new tag. Submission to SynoCommunity may follow later; a package source that Package Center could poll deliberately will not — there are no update notifications, and the documentation says so. @@ -910,12 +1001,9 @@ documentation quality as requirements rather than polish. Being public is not a still ahead: it is the condition every change now lands under. A force-push is immediate and irreversible, and so is a published release. -Deliverables per the spec: `src/main.rs` (split into modules if size warrants), `Cargo.toml`, -a commented `examples/example.toml` covering `global` / `network` / `disk-setup`, `build.sh`, -`deploy.sh`, a Rust `.gitignore`, and a license. The spec's "README covering purpose, -cross-compilation, DSM deployment, ISO preparation and troubleshooting" is now `docs/` -instead — the README had grown to 28 KB and was three documents wearing one coat. It is a -landing page linking into the site. +The spec's "README covering purpose, cross-compilation, DSM deployment, ISO preparation +and troubleshooting" is now `docs/` instead — the README had grown to 28 KB and was three +documents wearing one coat. It is a landing page linking into the site. Out of scope but documented rather than implemented: TLS (plain HTTP is fine on a trusted LAN — document the workaround for installer versions demanding a cert fingerprint) and diff --git a/Cargo.lock b/Cargo.lock index b064163..f17a00f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -315,7 +315,9 @@ dependencies = [ "http-body-util", "hyper", "hyper-util", + "libc", "quick-xml", + "rescriptum", "rusqlite", "serde_json", "serde_yaml_ng", diff --git a/Cargo.toml b/Cargo.toml index eb37f75..d49471b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,10 @@ categories = ["network-programming", "command-line-utilities"] http-body-util = "0.1.5" hyper = { version = "1.11.0", features = ["server", "http1"] } hyper-util = { version = "0.1.20", features = ["tokio", "server"] } +# Already in the lock file by way of tokio, so promoting it from transitive to direct +# costs zero new crates. It is here for exactly one thing: dropping privileges after +# binding TFTP's privileged port, which needs setgid/setuid/initgroups. +libc = "0.2.189" quick-xml = "0.41.0" rusqlite = { version = "0.40.2", features = ["bundled"], optional = true } serde_json = "1.0.151" @@ -27,10 +31,22 @@ tokio = { version = "1.53.1", features = ["rt-multi-thread", "net", "time", "io- toml_edit = "0.25.13" [features] -default = ["sqlite"] +default = ["sqlite", "boot"] # SQLite backs the admin API. Turn it off for the smallest possible binary when the # answers directory is all you need: `cargo build --no-default-features`. sqlite = ["dep:rusqlite"] +# Boot media: the catalogue, the ISO reader and the media listener. On by default, +# because an appliance that cannot serve the installer is half a product — but off in +# one flag, so `--no-default-features` still produces the smallest possible answer +# server. One feature and not two: media and TFTP share the catalogue and the address +# logic, and splitting them would buy kilobytes at the cost of a seam. +boot = [] +# Compiles `boot::iso::build`, which writes ISO9660 images in memory for tests. There is +# deliberately **no binary ISO fixture in this repository**: a checked-in image is a blob +# nobody can review and nobody can vary. The builder is the alternative, and it has no +# business in a release binary — the resolver keeps it out of one, because nothing but +# the dev-dependency below asks for it. +test-support = [] [profile.release] opt-level = "z" @@ -40,6 +56,14 @@ strip = true [dev-dependencies] rusqlite = { version = "0.40.2", features = ["bundled"] } +# The crate itself, so an integration test can build an ISO to serve. This is what turns +# `test-support` on for a test build and leaves it off for every other one. +# +# `default-features = false` is load-bearing: without it this line re-enables `sqlite` +# and `boot` for every test build, so `cargo test --no-default-features` would quietly +# test the full binary and report coverage that does not exist. Features are a union, so +# an ordinary `cargo test` still gets the defaults from the normal build. +rescriptum = { path = ".", default-features = false, features = ["test-support"] } # `panic = "abort"` is deliberately ABSENT. With one thread per connection, unwinding # means a panic kills only the connection that caused it; aborting would take down the diff --git a/README.fr.md b/README.fr.md index c8349a2..adb5db4 100644 --- a/README.fr.md +++ b/README.fr.md @@ -57,8 +57,8 @@ $ RESCRIPTUM_ANSWERS_DIR=/srv/answers rescriptum ## Trente secondes ```console -$ mkdir -p answers/groups -$ cat > answers/groups/rack-a.toml <<'TOML' +$ mkdir -p answers/groups/rack-a +$ cat > answers/groups/rack-a/proxmox.toml <<'TOML' members = ["98:fa:9b:50:d8:10", "98:fa:9b:50:d8:11"] [global] @@ -78,8 +78,9 @@ timezone = "Europe/Paris" … ``` -Voilà une baie **en tant que Proxmox**. Le même répertoire contient `groups/rack-a.ks` pour -les nœuds RHEL et `groups/rack-a.preseed` pour les Debian — même idée, autre extension. Un +Voilà une baie **en tant que Proxmox**. Le même répertoire contient `groups/rack-a/rhel.ks` +pour les nœuds RHEL et `groups/rack-a/debian.preseed` pour les Debian — même répertoire, autre +extension. Un document est indexé par *(machine, format)*, donc une machine peut être plusieurs systèmes d'exploitation à la fois et c'est l'URL qui tranche. @@ -110,7 +111,8 @@ Chacun de ces points est un lien vers la une machine pour ce qu'elle *est*. Déterministe : nommer bat matcher, plus de critères bat moins, les égalités se départagent sur le nom trié. - **[Un document par système d'exploitation](https://z29k.github.io/rescriptum/fr/guide/answers/formats)** — - `98fa9b50d810.toml` est cette machine *en tant que Proxmox*, `98fa9b50d810.preseed` le même + `98fa9b50d810/proxmox.toml` est cette machine *en tant que Proxmox*, + `98fa9b50d810/debian.preseed` le même matériel *en tant que Debian*. Les deux existent en même temps ; l'URL choisit. - **[Des réponses qui se composent](https://z29k.github.io/rescriptum/fr/guide/answers/grouping)** — chaînes de groupes via `extends`, documents machine par-dessus. Les maps fusionnent, les diff --git a/README.md b/README.md index d0875ae..f74943a 100644 --- a/README.md +++ b/README.md @@ -54,8 +54,8 @@ $ RESCRIPTUM_ANSWERS_DIR=/srv/answers rescriptum ## Thirty seconds ```console -$ mkdir -p answers/groups -$ cat > answers/groups/rack-a.toml <<'TOML' +$ mkdir -p answers/groups/rack-a +$ cat > answers/groups/rack-a/proxmox.toml <<'TOML' members = ["98:fa:9b:50:d8:10", "98:fa:9b:50:d8:11"] [global] @@ -75,8 +75,9 @@ timezone = "Europe/Paris" … ``` -That is one rack **as Proxmox**. The same directory holds `groups/rack-a.ks` for the RHEL -nodes and `groups/rack-a.preseed` for the Debian ones — same idea, different extension. A +That is one rack **as Proxmox**. The same directory holds `groups/rack-a/rhel.ks` for the +RHEL nodes and `groups/rack-a/debian.preseed` for the Debian ones — same directory, different +extension. A document is keyed by *(machine, format)*, so one machine can be several operating systems at once and the URL picks between them. @@ -102,12 +103,12 @@ Each of these is a link into the [documentation](https://z29k.github.io/rescript go deep only where you are curious. - **[Picks the right answer](https://z29k.github.io/rescriptum/guide/answers/selection)** — - by filename, by a group's member list, or by a `[match]` block claiming a machine for + by directory name, by a group's member list, or by a `[match]` block claiming a machine for what it *is*. Deterministic: naming beats matching, more criteria beats fewer, ties break on sorted name. - **[One document per operating system](https://z29k.github.io/rescriptum/guide/answers/formats)** — - `98fa9b50d810.toml` is that machine *as Proxmox*, `98fa9b50d810.preseed` the same - hardware *as Debian*. Both exist at once; the URL chooses. + `98fa9b50d810/proxmox.toml` is that machine *as Proxmox*, `98fa9b50d810/debian.preseed` + the same hardware *as Debian*. Both exist at once; the URL chooses. - **[Answers that compose](https://z29k.github.io/rescriptum/guide/answers/grouping)** — group chains via `extends`, machine documents on top. Maps merge, arrays replace, the machine always wins. diff --git a/docs/CLAUDE.md b/docs/CLAUDE.md new file mode 100644 index 0000000..d97aac8 --- /dev/null +++ b/docs/CLAUDE.md @@ -0,0 +1,49 @@ +# CLAUDE.md — docs + +Guidance for working on the documentation site. It loads only when Claude touches files under +`docs/`. The root `CLAUDE.md` keeps what applies everywhere: the site exists, this file and +`docs/development/` overlap deliberately, and **a change to a constraint belongs in both**. + +## Documentation + +`docs/` is the documentation site, rendered by **notabene** (`@z29k/notabene`, the sibling +project) and published to GitHub Pages at . Two spaces, +two audiences, and they do not interleave: + +- `docs/guide/` — **using** rescriptum: install, quick start, installer media, writing + answers (`answers/`), running it (`operations/`), exhaustive tables (`reference/`). +- `docs/development/` — **working on** rescriptum: constraints, architecture, request + lifecycle, internals per module, testing, building, releasing, traps. + +This file and `docs/development/` overlap deliberately: this one is condensed for agents, +that one is written for a human reading in order. **A change to a constraint belongs in +both.** A user-visible change should land with its documentation in the same PR. + +- `notabene.config.mjs` — the site configuration. `review: "approve"` means an agent + *proposes* doc edits and a human validates each against its real git diff at `/review`. + `i18n: { locales: ["en","fr"], strategy: "suffix" }` is what makes the FR siblings work. + **`tagline` is a plain string, not a per-locale map** — notabene does not accept a map + there, and one stringifies to `[object Object]` in the topbar and `llms.txt`. +- `assets/rescriptum-logo.jpg` — the logo (a sealed rescript on a floppy disk), used as the + topbar logo, the favicon, the social card, and the README header. +- `docs/.notabene/` — the comment and journal store, plain JSON, **committed**. The agent + protocol is `docs/.notabene/protocol.md`, pointed at from `AGENTS.md`. +- `package.json` exists **only** for this. Nothing in `node_modules` is executed by the + published site or reaches the Rust binary. `npm audit` reports unfixable transitive + advisories in Astro/esbuild/sharp; they are development-only. +- `.github/workflows/docs.yml` publishes from `main` (so docs normally ship with a + release; `workflow_dispatch` publishes a fix that should not wait). `ci.yml` has a + `docs` job that builds and runs `notabene lint` on every push. + +Writing conventions: relative `.md` links between pages (they become routes *and* stay +clickable on GitHub), absolute GitHub URLs for repository files outside `docs/`, frontmatter +`title` / `description` / `sidebar.order`, Mermaid in ` ```mermaid ` fences. + +From a French page, a link still uses the **base** name (`./selection.md`, never +`./selection.fr.md`) — notabene resolves the locale — but the **anchor must be the French +heading's slug**. `notabene lint` checks routes, **not anchors**: verify those against the +built HTML. + +**Verify prose against the binary rather than against this file.** Every command output in +`docs/` was captured from a real run; several passages in the older README had drifted from +what the code actually prints. diff --git a/docs/development/building.fr.md b/docs/development/building.fr.md index c5f50e3..63198ae 100644 --- a/docs/development/building.fr.md +++ b/docs/development/building.fr.md @@ -141,6 +141,24 @@ packaging/dsm/make-spk.sh armv7 # emballer un build qui existe déj packaging/dsm/check-spk.sh # contrôle structurel sur dist/*.spk ``` +**Le paquet embarque les chargeurs : construisez-les d'abord, sinon il ne passe pas son +propre contrôle.** `make-spk.sh` les prend dans `packaging/ipxe/out` (remplaçable par +`RESCRIPTUM_LOADERS`), et `check-spk.sh` refuse un paquet qui n'en a pas — un serveur TFTP +sans rien à distribuer ne démarre personne. Construire iPXE demande une chaîne C Linux, ce +qui sur un Mac veut dire un conteneur : + +```bash +docker run --rm --platform linux/amd64 -v "$PWD:/w" -w /w debian:bookworm-slim sh -c ' + apt-get update -qq && + apt-get install -y --no-install-recommends build-essential liblzma-dev mtools \ + xorriso isolinux gcc-aarch64-linux-gnu git ca-certificates perl && + packaging/ipxe/build.sh --out /w/packaging/ipxe/out' +``` + +Une fois, pas par paquet : les chargeurs sont les mêmes octets dans le `.spk` de chaque +ABI, puisqu'ils tournent sur les machines *démarrées*, pas sur le NAS. `packaging/ipxe/out` +est gitignoré — **jamais de binaires dans git**. + | ABI | `arch` dans `INFO` | Depuis | |---|---|---| | `x86_64` | `x86_64` — le nom de *famille*, donc toutes les plateformes Intel | `x86_64-unknown-linux-musl` | diff --git a/docs/development/building.md b/docs/development/building.md index 43143af..2c865e5 100644 --- a/docs/development/building.md +++ b/docs/development/building.md @@ -138,10 +138,28 @@ packaging/dsm/make-spk.sh armv7 # wrap a build that already exists packaging/dsm/check-spk.sh # structural check over dist/*.spk ``` +**The package carries the loaders, so build them first or it will not pass its own +check.** `make-spk.sh` takes them from `packaging/ipxe/out` (override with +`RESCRIPTUM_LOADERS`), and `check-spk.sh` fails a package that has none — a TFTP server +with nothing to hand out boots nothing. Building iPXE needs a Linux C toolchain, which on +a Mac means a container: + +```bash +docker run --rm --platform linux/amd64 -v "$PWD:/w" -w /w debian:bookworm-slim sh -c ' + apt-get update -qq && + apt-get install -y --no-install-recommends build-essential liblzma-dev mtools \ + xorriso isolinux gcc-aarch64-linux-gnu git ca-certificates perl && + packaging/ipxe/build.sh --out /w/packaging/ipxe/out' +``` + +Once, not per package: the loaders are the same bytes in every ABI's `.spk`, because they +run on the machines being *booted*, not on the NAS. `packaging/ipxe/out` is gitignored — +**no binaries in git, ever**. + | ABI | `arch` in `INFO` | From | |---|---|---| | `x86_64` | `x86_64` — the *family* name, so it covers every Intel platform | `x86_64-unknown-linux-musl` | -| `armv7` | `armada38x` — the family shorthand does not reach the Marvell platforms | `armv7-unknown-linux-musleabihf` | +| `armv7` | `armada38x` — the family shorthand does not reach the Marvell platforms | `armv7-unknown-linux-gnueabihf` | | `aarch64` | `armv8` | `aarch64-unknown-linux-musl`, once the binary has been run on one | The rule for widening that: **claim an ABI once the binary has run on the oldest-kernel @@ -185,6 +203,6 @@ under a temporary name, restarts, and confirms `/health`. See | Environment | Default | |---|---| -| `TARGET` | `armv7-unknown-linux-musleabihf` | +| `TARGET` | `armv7-unknown-linux-gnueabihf` | | `ANSWERS` | `/answers` | | `PORT` | `8000` | diff --git a/docs/development/releasing.fr.md b/docs/development/releasing.fr.md index cffd7d0..ef75118 100644 --- a/docs/development/releasing.fr.md +++ b/docs/development/releasing.fr.md @@ -70,10 +70,19 @@ git push origin main --follow-tags 3. Empaquette chacune en `rescriptum--.tar.gz`, avec `README.md` et `LICENSE` à côté du binaire, plus une **somme SHA-256** — qui fait tourner cela en root devrait pouvoir vérifier ce qu'il a téléchargé. -4. Emballe les builds musl Linux en [paquets Synology](./building.md#le-paquet-synology), +4. **Construit les chargeurs iPXE marqués** depuis le commit épinglé et les attache en + `rescriptum-boot-assets-.tar.gz`, après avoir demandé à `boot check` si le + répertoire satisfait la table de chargeurs depuis laquelle le serveur distribue. Sans + cela la release est incomplète, et silencieusement : un déploiement obtient un serveur + TFTP sans rien à distribuer, et chaque machine que l'extrait DHCP généré envoie là + demande un fichier, n'obtient rien, et s'arrête. **C'est un téléchargement à part, + jamais dans une archive binaire ni dans un `.spk`** — c'est iPXE, en GPLv2, et des + fichiers séparés servis à côté relèvent de la simple agrégation, avec `packaging/ipxe/` + pour offre écrite. +5. Emballe les builds musl Linux en [paquets Synology](./building.md#le-paquet-synology), `rescriptum---.spk`, et contrôle structurellement chacun avant qu'il puisse être publié. -5. Crée la GitHub Release avec `gh` et `--generate-notes`, ou verse dedans si elle existe +6. Crée la GitHub Release avec `gh` et `--generate-notes`, ou verse dedans si elle existe déjà. Il est relançable à la main via `workflow_dispatch` avec un tag, pour quand un job échoue après diff --git a/docs/development/releasing.md b/docs/development/releasing.md index 890e8cb..40dc347 100644 --- a/docs/development/releasing.md +++ b/docs/development/releasing.md @@ -70,10 +70,18 @@ git push origin main --follow-tags 3. Packages each as `rescriptum--.tar.gz`, with `README.md` and `LICENSE` alongside the binary, plus a **SHA-256 sum** — whoever runs this as root should be able to check what they downloaded. -4. Wraps the Linux musl builds as [Synology packages](./building.md#the-synology-package), +4. **Builds the branded iPXE loaders** from the pinned commit and attaches them as + `rescriptum-boot-assets-.tar.gz`, after asking `boot check` whether the + directory satisfies the loader table the server hands out from. Without this the + release is incomplete and quietly so: a deployment gets a TFTP server with nothing to + hand out, and every machine the generated DHCP snippet sends there asks for a file, + gets nothing, and stops. **They are their own download, never part of a binary archive + or an `.spk`** — they are iPXE, GPLv2, and separate files served alongside is mere + aggregation, with `packaging/ipxe/` as the written offer. +5. Wraps the Linux musl builds as [Synology packages](./building.md#the-synology-package), `rescriptum---.spk`, and checks each structurally before it can be published. -5. Cuts the GitHub Release with `gh` and `--generate-notes`, or uploads into it if it +6. Cuts the GitHub Release with `gh` and `--generate-notes`, or uploads into it if it already exists. It is re-runnable by hand through `workflow_dispatch` with a tag, for when a job fails diff --git a/docs/development/selection.fr.md b/docs/development/selection.fr.md index e7add95..69a04c5 100644 --- a/docs/development/selection.fr.md +++ b/docs/development/selection.fr.md @@ -147,13 +147,22 @@ par machine, le débit s'effondre : | 2 000 | 311 req/s | 12 520 req/s | | 10 000 | — | 6 924 req/s | -Un `stat` remplace tout le parcours, et un nouveau document est quand même pris en compte sans -redémarrage — ce qui est la garantie que la spécification voulait réellement. Les radicaux -normalisés sont calculés une fois par lecture du store, pas une fois par requête. - -> **Le filet n'est pas redondant.** Éditer le *contenu* d'un fichier de groupe ne bouge aucun -> mtime de répertoire, et un changement fait par un autre processus ne bouge aucun atomique en -> mémoire. Un test d'intégration couvre exactement cela. +Un `stat` remplace tout le parcours, et une nouvelle machine est quand même prise en compte +sans redémarrage — ce qui est la garantie que la spécification voulait réellement. Les +identités normalisées sont calculées une fois par lecture du store, pas une fois par requête. + +> **Le filet n'est pas redondant, et il travaille plus qu'avant.** Éditer le *contenu* d'un +> document ne bouge aucun mtime de répertoire ; en ajouter un *à l'intérieur* du répertoire +> d'une machine non plus, puisque c'est un niveau sous le mtime surveillé ; et un changement +> fait par un autre processus ne bouge aucun atomique en mémoire. Avec un répertoire par +> identité, le filet est donc ce qui rattrape tout sauf l'apparition ou la disparition d'une +> identité. Des tests couvrent chaque cas. +> +> Les chiffres ci-dessus ont été mesurés contre l'agencement plat. La lecture elle-même est +> désormais un `readdir` par identité en plus du fichier qu'elle ouvrait déjà — de 28 ms à +> 63 ms à 2 000 machines — que le cache amortit sur une seconde de requêtes, et qui n'a pas +> déplacé le débit de façon mesurable. Cela reste la raison pour laquelle un groupe vaut +> mieux qu'un répertoire par machine. Le coût restant à 10 000 documents est un balayage linéaire d'aiguilles précalculées — du CPU pur, aucun appel système. Regrouper les aiguilles par longueur et faire glisser une fenêtre diff --git a/docs/development/selection.md b/docs/development/selection.md index 940ab65..89436e5 100644 --- a/docs/development/selection.md +++ b/docs/development/selection.md @@ -143,13 +143,21 @@ machine, throughput collapses: | 2,000 | 311 req/s | 12,520 req/s | | 10,000 | — | 6,924 req/s | -One `stat` replaces the whole walk, and a new document is still picked up with no restart -— which is the guarantee the specification actually wanted. Normalized stems are computed +One `stat` replaces the whole walk, and a new machine is still picked up with no restart — +which is the guarantee the specification actually wanted. Normalized identities are computed once per store read, not once per request. -> **The backstop is not redundant.** Editing a group file's *contents* moves no directory -> mtime, and a change made by another process moves no in-process atomic. An integration -> test covers exactly this. +> **The backstop is not redundant, and it does more work than it used to.** Editing a +> document's *contents* moves no directory mtime; neither does adding one *inside* a +> machine's own directory, since that is one level below the mtime being watched; and a +> change made by another process moves no in-process atomic. So with a directory per +> identity, the backstop is what picks up everything except an identity appearing or +> leaving. Tests cover each case. +> +> The figures above were measured against the flat layout. The read itself is now a +> `readdir` per identity on top of the file it already opened — 28 ms to 63 ms at 2,000 +> machines — which the cache amortises over a second's worth of requests, and which did not +> move throughput measurably. It is still the reason a group beats a directory per machine. The remaining cost at 10,000 documents is a linear scan of precomputed needles — pure CPU, no syscalls. Bucketing needles by length and sliding a window over the body would remove diff --git a/docs/development/stores.fr.md b/docs/development/stores.fr.md index dc36007..222f7a6 100644 --- a/docs/development/stores.fr.md +++ b/docs/development/stores.fr.md @@ -61,7 +61,25 @@ est exactement là où se cache une divergence. ## Le store fichiers -Un répertoire plat, plus `groups/`. `version()` est le mtime du répertoire : +**Un répertoire par identité.** Une machine est un répertoire nommé d'après elle, qui +contient un document par format ; `groups/` porte la même forme pour les groupes, et +`default/` les réponses de repli. Les deux noms sont réservés, donc une machine ne peut pas +les revendiquer — `valid_machine_id` les refuse dans les *deux* stores, parce qu'une base qui +en accepterait un exporterait vers un répertoire incapable de le contenir. + +Dans un répertoire, **l'extension est le format et le radical n'est rien du tout**. C'est +cette règle qui fait de deux documents d'un même format dans un même répertoire un problème +*signalé* plutôt que tranché : il n'existe aucun départage qu'un administrateur aurait pu +prévoir. L'ordre trié décide lequel des deux répond, pour que le choix ne dépende au moins pas +de readdir — et le perdant est nommé dans `problems()`. + +Un document servable laissé à la racine du répertoire de réponses — l'agencement d'avant — +est **signalé et non servi**, avec sa destination explicitée. Lire à moitié un ancien +agencement signifierait une machine dont la réponse se déplace silencieusement entre deux +fichiers. `pending_moves()` expose la même connaissance pour `migrate`, pour que la commande +et le lecteur ne puissent pas diverger sur l'endroit où va un document. + +`version()` est le mtime du répertoire : ```rust fs::metadata(&self.dir).ok() @@ -73,9 +91,19 @@ fs::metadata(&self.dir).ok() Un `stat` remplace tout un parcours de répertoire — voir [le cache du listing](./selection.md#le-cache-du-listing). -> Le mtime du répertoire bouge quand un fichier est **ajouté ou supprimé**, pas quand un -> fichier est **édité**. Le filet de rechargement d'une seconde est ce qui couvre l'édition, -> et un test d'intégration couvre exactement cela. +> Le mtime du répertoire bouge quand une entrée y est **ajoutée ou supprimée**, pas quand +> l'une est **éditée**, ni quand quelque chose change un niveau plus bas. Le répertoire +> entier d'une machine qui apparaît ou disparaît est donc vu immédiatement, tandis qu'un +> document ajouté ou modifié *à l'intérieur* de l'un d'eux attend le filet de rechargement +> d'une seconde — celui qui couvrait déjà un fichier édité sur place. Un test unitaire +> épingle chacune des deux moitiés. + +> **Ce que l'agencement coûte à la lecture.** Un rechargement complet est désormais un +> `readdir` par identité en plus du fichier qu'il ouvrait déjà. Mesuré à 2 000 machines sur +> un M1 Pro : **28 ms à plat, 63 ms avec un répertoire chacune**. C'est amorti sur une +> seconde de requêtes, et le débit de bout en bout n'a pas bougé de façon mesurable — mais +> c'est un vrai facteur 2,2 sur la seule opération dont le filet garantit qu'elle tournera +> chaque seconde, et c'est la raison de préférer un groupe à un répertoire par machine. **Les écritures passent par un fichier temporaire plus un `rename`**, atomique dans un répertoire sur POSIX, pour qu'un lecteur ne rencontre jamais une réponse à moitié écrite. Le @@ -130,13 +158,15 @@ $ rescriptum export # store configuré → répertoire Les deux passent par `Snapshot`, donc ils partagent toutes les règles. **L'aller-retour est identique octet pour octet** — importez un répertoire, réexportez-le, `diff -r` ne signale -rien. C'est ce qui rend la base sûre à adopter *et* sûre à quitter, et cela vaut la peine de -rester vrai. +rien, chemins compris. Un test compare les deux côtés au même chemin pour exactement cette +raison : `export` écrivant un document là où `import` n'irait pas le chercher est ce qui +rendrait la base dangereuse à quitter. -## Les identifiants deviennent des noms de fichiers +## Les identifiants deviennent des noms de répertoires ```rust -pub fn valid_id(id: &str) -> bool // lettres, chiffres, - _ . : et aucun séparateur de chemin +pub fn valid_id(id: &str) -> bool // lettres, chiffres, - _ . : et aucun séparateur +pub fn valid_machine_id(id: &str) -> bool // …et ni `groups` ni `default` ``` Imposé à la frontière de l'API d'administration **et** dans les deux stores. Le store est la diff --git a/docs/development/stores.md b/docs/development/stores.md index 0e29167..e09e26f 100644 --- a/docs/development/stores.md +++ b/docs/development/stores.md @@ -59,7 +59,25 @@ backend proves half of what it claims. ## The file store -A flat directory, plus `groups/`. `version()` is the directory's mtime: +**One directory per identity.** A machine is a directory named after it, holding one +document per format; `groups/` holds the same shape for groups, and `default/` the +fallbacks. Both names are reserved, so a machine cannot claim them — `valid_machine_id` +refuses them in *both* stores, because a database that accepted one would export into a +directory that cannot hold it. + +Inside a directory, **the extension is the format and the stem is nothing at all**. That is +the rule that makes two documents of one format in one directory a *reported problem* rather +than a resolved one: there is no tiebreak an operator could have predicted. Sorted order +decides which of the two answers, so the choice at least does not depend on readdir — and +the loser is named in `problems()`. + +A servable document left at the top of the answers directory — the layout that came before — +is **reported and not served**, with its destination spelled out. Half-reading an old layout +would mean a machine whose answer moved silently between two files. `pending_moves()` is the +same knowledge exposed for `migrate`, so the command and the reader cannot disagree about +where a document belongs. + +`version()` is the directory's mtime: ```rust fs::metadata(&self.dir).ok() @@ -71,9 +89,18 @@ fs::metadata(&self.dir).ok() One `stat` replaces a whole directory walk — see [the listing cache](./selection.md#the-listing-cache). -> The directory's mtime moves when a file is **added or removed**, not when one is -> **edited**. The 1-second reload backstop is what covers editing, and an integration -> test covers exactly that. +> The directory's mtime moves when an entry is **added or removed** *in it*, not when one +> is **edited**, and not when something changes one level down. So a machine's whole +> directory appearing or leaving is seen at once, while a document added or edited *inside* +> one waits for the 1-second reload backstop — which is what already covered a file edited +> in place. A unit test pins each half. + +> **What the layout costs on a read.** A full reload is now a `readdir` per identity on top +> of the file it already opened. Measured at 2,000 machines on an M1 Pro: **28 ms flat, +> 63 ms with a directory each**. It is amortised over a second's worth of requests, and +> end-to-end throughput did not move measurably — but it is a real 2.2× on the one operation +> the backstop guarantees will run every second, and it is the reason to reach for a group +> before a directory per machine. **Writes go through a temporary file plus `rename`**, which is atomic within a directory on POSIX, so a reader never meets a half-written answer. The temporary name carries the @@ -125,13 +152,16 @@ $ rescriptum export # the configured store → a directory ``` Both go through `Snapshot`, so they share every rule. **The round trip is byte-identical** -— import a directory, export it again, `diff -r` reports nothing. That is what makes the +— import a directory, export it again, `diff -r` reports nothing, paths included. A test +compares both sides at the same path for exactly that reason: `export` writing a document +somewhere `import` would not look for it is what would make the database unsafe to leave. That is what makes the database safe to adopt *and* safe to leave, and it is worth keeping true. -## Identifiers become filenames +## Identifiers become directory names ```rust -pub fn valid_id(id: &str) -> bool // letters, digits, - _ . : and no path separators +pub fn valid_id(id: &str) -> bool // letters, digits, - _ . : and no separators +pub fn valid_machine_id(id: &str) -> bool // …and not `groups` or `default` ``` Enforced at the admin API boundary **and** in both stores. The store is the layer that diff --git a/docs/development/testing.fr.md b/docs/development/testing.fr.md index 4e6b0a7..756614e 100644 --- a/docs/development/testing.fr.md +++ b/docs/development/testing.fr.md @@ -8,7 +8,14 @@ sidebar: # Tests -333 tests. `cargo test` les fait tous tourner en quelques secondes. +582 tests. `cargo test` les fait tous tourner en une vingtaine de secondes — dont +l'essentiel dans `tests/tftp.rs`, qui attend de vrais délais UDP parce que c'est +précisément ce qu'il teste. + +**`cargo test` ne lance pas les bancs qui comptent le plus** : le banc de démarrage, les +trois du paquet DSM, et la construction des chargeurs. Voir [Le paquet aussi est +testé](#le-paquet-aussi-est-testé-à-trois-endroits) et [le banc de +démarrage](#la-chaîne-de-démarrage-a-sa-place-dans-le-banc). ```bash cargo test # tout @@ -21,21 +28,40 @@ cargo test --all-features # ce que lance la CI | Suite | Cas | Pour | |---|---|---| -| `tests/stores.rs` | 39 | **chaque comportement, contre les deux stores** | -| `tests/integration.rs` | 45 | le vrai binaire sur une vraie socket | -| `src/select.rs` | 27 | normalisation, scoring, superposition, remplissage de templates | -| `src/format/mod.rs` | 27 | parsing, fusion, clés de contrôle, alias d'endpoint | -| `src/facts.rs` | 22 | parsing de query, aplatissement JSON, globbing | +| `tests/integration.rs` | 48 | le vrai binaire sur une vraie socket | +| `tests/cli.rs` | 50 | `render`, `check`, `import`, `export`, `config` et le fichier d'environnement — contre le vrai binaire | +| `tests/media.rs` | 45 | les médias de démarrage contre le vrai binaire, les deux listeners debout | +| `src/config.rs` | 42 | l'environnement, ce qui refuse de démarrer, et qui l'emporte du fichier ou de l'environnement | +| `tests/stores.rs` | 45 | **chaque comportement, contre les deux stores** | +| `tests/tftp.rs` | 30 | le TFTP sur de l'UDP réel : les tours de parole, et ce qu'une liaison ratée ne doit pas coûter | +| `src/select.rs` | 28 | normalisation, scoring, superposition, remplissage de templates | +| `src/format/mod.rs` | 28 | parsing, fusion, clés de contrôle, alias d'endpoint | | `tests/admin.rs` | 26 | l'API d'administration de bout en bout, formats compris | -| `tests/cli.rs` | 39 | `render`, `check`, `import`, `export`, `config` et le fichier d'environnement — contre le vrai binaire | -| `src/log.rs` | 4 | lecture des niveaux, et l'arithmétique d'horodatage | +| `src/envfile.rs` | 23 | le parseur et l'écrivain du fichier d'environnement, et ce que chacun refuse | +| `src/facts.rs` | 22 | parsing de query, aplatissement JSON, globbing | | `src/format/xml.rs` | 18 | l'arbre XML — appariement, entités, fidélité | -| `src/config.rs` | 24 | l'environnement, ce qui refuse de démarrer, et qui l'emporte du fichier ou de l'environnement | | `src/merge.rs` | 11 | la fusion profonde TOML | | `tests/guards.rs` | 7 | le jeton de réponse, et le verrouillage qui délibérément n'existe pas | -| `src/envfile.rs` | 23 | le parseur et l'écrivain du fichier d'environnement, et ce que chacun refuse | +| `src/installed.rs` | 6 | une machine qui signale son installation, et ce qu'il ne faut jamais désarmer | +| `src/log.rs` | 4 | lecture des niveaux, et l'arithmétique d'horodatage | +| `src/boot/*.rs` | 128 | le lecteur ISO, le repérage, le catalogue, les sources d'images, les plans de patch, le menu, la table des chargeurs, les extraits DHCP, cpio et SHA-256 | | `src/admin.rs`, `src/capture.rs`, `src/store/mod.rs` | 21 | comportement unitaire | +## `tests/common/mod.rs` — les fixtures que toutes les suites partagent + +Les réponses sont stockées à raison d'**un répertoire par identité**, donc une fixture ne +peut plus être un simple nom de fichier. `seed()` prend le nom dans lequel un test pense — +`98fa9b50d810.toml`, `groups/rack-a.toml`, `default.toml` — et l'écrit **via `StoreWrite`**, +si bien qu'elle atterrit exactement là où une écriture de l'API d'administration la mettrait +et ne peut pas diverger de l'agencement. Un nom que le store refuserait (une extension que +personne ne sert) est écrit littéralement, parce que ces fixtures existent justement pour +prouver qu'un fichier égaré ne répond à rien. + +Une seule copie, pas une par suite — le même raisonnement qui fait de `loaders.rs` une table +unique lue par TFTP *et* par l'extrait DHCP. Quatre copies d'une correspondance sont quatre +occasions qu'une fixture atterrisse là où le serveur ne regarde pas, et un test qui ne sème +rien passe pour la mauvaise raison. + ## `tests/stores.rs` — la suite de conformité Chaque cas de comportement tourne **deux fois**, une par store, et affirme le résultat @@ -82,6 +108,49 @@ Explicitement couverts : > contre un binaire périmé a un jour « reproduit » un bug déjà corrigé. Reconstruisez avant de > triturer le binaire à la main. +## `tests/tftp.rs` — un transfert est une conversation + +Rien ici ne se prouve depuis l'intérieur d'une fonction. Les blocs, les acquittements, la +retransmission, le paquet vide qui termine un transfert — chaque bug qui vaut d'être +attrapé vit dans les tours de parole, et la première exécution en a trouvé deux, du genre +« marche à la main, jamais après un redémarrage ». **Un fichier dont la longueur est un +multiple exact de la taille de bloc doit se terminer par un paquet de données vide** ; +sans lui le client attend éternellement un dernier bloc qui ne vient jamais. + +Cette suite porte aussi le seul écouteur de ce serveur dont l'échec n'est *pas* fatal. Un +port TFTP qu'on ne peut pas lier ne doit pas emporter les réponses et les médias avec lui +— mesuré sur DSM, où la capacité est accordée hors du paquet et où une mise à jour la perd +— donc le test squatte le port, puis vérifie trois choses d'un coup : le serveur est monté, +il a averti en disant ce qui marche encore, et `boot check` sort toujours en non-zéro. + +Cette dernière assertion est d'abord passée pour la mauvaise raison : trois chargeurs +manquants faisaient déjà échouer la commande. Le montage écrit maintenant tous les +chargeurs que la table nomme, et une exécution témoin avec le TFTP coupé prouve que le +répertoire est propre par ailleurs. + +## `tests/media.rs` — les médias de démarrage contre le vrai binaire + +Les deux listeners debout, et chaque cas d'abus se termine en prouvant que le serveur +répond toujours. Un cas prouve la propriété pour laquelle la socket séparée existe : **les +réponses continuent d'aboutir pendant que quatre transferts d'image sont en cours.** + +Il n'y a délibérément **aucune ISO binaire dans ce dépôt**. `boot::iso::build` écrit des +images en mémoire, derrière la fonctionnalité `test-support`, pour qu'elle n'atteigne +jamais un binaire de release. + +## La chaîne de démarrage a sa place dans le banc + +`packaging/boot-rig/run.sh` n'est pas du Rust et `cargo test` ne le lance pas. Il démarre +une machine revendiquée et une non revendiquée dans QEMU sous TCG, sur un pont privé sans +lien montant, et vérifie quatre marqueurs : la passe DHCP a répondu depuis notre propre +extrait généré, un chargeur a été récupéré en TFTP, la machine non revendiquée est +retombée sur son disque local, et la machine revendiquée a atteint sa propre réponse. La +CI fait la même chose plus une casse délibérée. + +**Un invité QEMU ponté dans un conteneur a une MAC à lui, et le commutateur virtuel de +Docker Desktop ne transmet pas les trames d'une MAC qu'il n'a pas attribuée** — mesuré, +d'où un banc principal en un seul conteneur plutôt qu'en quatre sur un réseau Docker. + ## Vérifier qu'un test peut échouer Un test qui passe pour la mauvaise raison est pire que pas de test : il annonce une @@ -131,7 +200,7 @@ harnais s'en chargent, et chacun prouve ce que les autres ne peuvent pas. |---|---|---| | [`packaging/dsm/check-spk.sh`](https://github.com/z29k/rescriptum/blob/main/packaging/dsm/check-spk.sh) | l'archive est structurellement ce que DSM attend — tar externe non compressé, les six champs d'`INFO`, une version tout en segments numériques, `os_min_ver` au moins 7.1, icônes 64×64 et 256×256, scripts exécutables sans CRLF, **le `--version` du binaire empaqueté**, et l'application de bureau : un `dsmappname` nommant une classe que son `ui/config` déclare vraiment, un nom de fichier JavaScript qui porte la version, et un backend qui vérifie toujours la session DSM et `administrators` | des secondes, **à chaque push** | | [`packaging/dsm/lifecycle-test.sh`](https://github.com/z29k/rescriptum/blob/main/packaging/dsm/lifecycle-test.sh) | tout ce que les *scripts* du paquet décident, contre un faux arbre `/var/packages` : le fichier d'environnement écrit une fois et une seule, les valeurs de l'assistant **et leur absence**, le service qui survit à son propre script de démarrage et répond à `/health`, les codes de sortie que lit Package Center, une mise à jour qui ne doit pas toucher une configuration éditée à la main, une désinstallation qui ne doit pas toucher aux réponses — **et le backend de l'application de bureau**, piloté avec un authentificateur bouchonné : refuser l'absence de session, refuser un non-administrateur, refuser une écriture sans en-tête d'intention, refuser celle qui empêcherait le serveur de démarrer, et ne jamais livrer un jeton au navigateur | des secondes, **à chaque push** | -| [`packaging/dsm/vm/on-dsm.sh`](https://github.com/z29k/rescriptum/blob/main/packaging/dsm/vm/on-dsm.sh) | la machinerie propre à DSM — le worker `data-share` et son ACL, le worker `port-config`, l'unité systemd générée, logrotate contre un descripteur vivant, si Package Center accepte l'archive — **et qu'une machine qui demande sa configuration en reçoit une** : un POST avec le matériel dans le corps, auquel répond le fichier de cette machine fusionné par-dessus le groupe qui la revendique | des minutes, sur une VM DSM 7 — puis sur le DS416j | +| [`packaging/dsm/vm/on-dsm.sh`](https://github.com/z29k/rescriptum/blob/main/packaging/dsm/vm/on-dsm.sh) | la machinerie propre à DSM — le worker `data-share` et son ACL, le worker `port-config`, l'unité systemd générée, logrotate contre un descripteur vivant, si Package Center accepte l'archive — **et qu'une machine qui demande sa configuration en reçoit une** : un POST avec le matériel dans le corps, auquel répond le fichier de cette machine fusionné par-dessus le groupe qui la revendique. Elle porte aussi **la seule route vers le port 69** et la capacité de ce NAS à atteindre l'index d'un éditeur : que `69/udp` survive dans l'entrée de pare-feu acquise, que le paquet réponde encore sans la capacité, et que `setcap cap_net_bind_service=+ep` puis un redémarrage lient `udp/69` sous le processus non privilégié du paquet | des minutes, sur une VM DSM 7 — puis sur le DS416j | ```bash packaging/dsm/lifecycle-test.sh # le premier .spk de dist/ qui tourne ici @@ -157,7 +226,11 @@ La même règle que partout ailleurs vaut pour eux : **cassez ce qu'ils gardent regardez-les virer au rouge.** Annuler la garde de `postinst` à la mise à jour, faire supprimer le partage par `postuninst`, renvoyer `1` pour un paquet arrêté et refuser `prestart` transforme 33 vérifications vertes en 25 vertes et 8 rouges — c'est ainsi qu'on -sait que le harnais teste quelque chose. +sait que le harnais teste quelque chose. Aujourd'hui c'est **85** vérifications dans +`lifecycle-test.sh`, **28** dans `check-spk.sh` et **52** sur la machine ; les trois dernières +ajoutées ont chacune été vues rouges de la même façon — en remettant +`RESCRIPTUM_TFTP_ADDR=off`, en supprimant le rapport du panneau sur l'état du TFTP, et en +lui faisant prétendre qu'il livre alors que rien n'est lié. ## CI diff --git a/docs/development/testing.md b/docs/development/testing.md index 120610f..8b85351 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -8,7 +8,12 @@ sidebar: # Testing -333 tests. `cargo test` runs all of them in a couple of seconds. +582 tests. `cargo test` runs all of them in about twenty seconds — most of that is +`tests/tftp.rs`, which waits on real UDP timeouts because that is what it is testing. + +**`cargo test` does not run the harnesses that matter most**: the boot rig, the DSM +package's three, and the loader build. See [The package is tested too](#the-package-is-tested-too-in-three-places) +and [the boot rig](#the-boot-chain-belongs-in-the-rig). ```bash cargo test # everything @@ -21,21 +26,39 @@ cargo test --all-features # what CI runs | Suite | Cases | For | |---|---|---| -| `tests/stores.rs` | 39 | **every behaviour, against both stores** | -| `tests/integration.rs` | 45 | the real binary over a real socket | -| `src/select.rs` | 27 | normalization, scoring, layering, template filling | -| `src/format/mod.rs` | 27 | parsing, merging, control keys, endpoint aliases | -| `src/facts.rs` | 22 | query parsing, JSON flattening, globbing | +| `tests/integration.rs` | 48 | the real binary over a real socket | +| `tests/cli.rs` | 50 | `render`, `check`, `import`, `export`, `config` and the env file — against the real binary | +| `tests/media.rs` | 45 | boot media against the real binary, with both listeners up | +| `src/config.rs` | 42 | the environment, what refuses to start, and which of the file and the environment wins | +| `tests/stores.rs` | 45 | **every behaviour, against both stores** | +| `tests/tftp.rs` | 30 | TFTP over real UDP: the turn-taking, and what a failed bind must not cost | +| `src/select.rs` | 28 | normalization, scoring, layering, template filling | +| `src/format/mod.rs` | 28 | parsing, merging, control keys, endpoint aliases | | `tests/admin.rs` | 26 | the admin API end to end, formats included | -| `tests/cli.rs` | 39 | `render`, `check`, `import`, `export`, `config`, and the env file — against the real binary | -| `src/log.rs` | 4 | level parsing, and the timestamp arithmetic | +| `src/envfile.rs` | 23 | the env-file parser and writer, and what each refuses | +| `src/facts.rs` | 22 | query parsing, JSON flattening, globbing | | `src/format/xml.rs` | 18 | the XML tree — pairing, entities, fidelity | -| `src/config.rs` | 24 | the environment, what refuses to start, and which of the file and the environment wins | | `src/merge.rs` | 11 | the TOML deep merge | | `tests/guards.rs` | 7 | the answer token, and the lockout that deliberately is not there | -| `src/envfile.rs` | 23 | the env-file parser and writer, and what each refuses | +| `src/installed.rs` | 6 | a machine reporting it installed, and what must never be disarmed | +| `src/log.rs` | 4 | level parsing, and the timestamp arithmetic | +| `src/boot/*.rs` | 128 | the ISO reader, probing, the catalogue, image sources, patch plans, the menu, the loader table, DHCP snippets, cpio and SHA-256 | | `src/admin.rs`, `src/capture.rs`, `src/store/mod.rs` | 21 | unit-level behaviour | +## `tests/common/mod.rs` — the fixtures every suite shares + +Answers are stored as a **directory per identity**, so a fixture cannot just be a filename +any more. `seed()` takes the name a test thinks in — `98fa9b50d810.toml`, +`groups/rack-a.toml`, `default.toml` — and writes it **through `StoreWrite`**, so it lands +exactly where a write from the admin API would and cannot drift from the layout. A name the +store would refuse (an extension nobody serves) is written literally instead, because those +fixtures exist precisely to prove that a stray file answers nothing. + +One copy, not one per suite — the same reasoning that makes `loaders.rs` a single table +read by both TFTP and the DHCP snippet. Four copies of a mapping are four chances for a +fixture to land somewhere the server does not look, and a test that seeds nothing passes +for the wrong reason. + ## `tests/stores.rs` — the conformance suite Every behavioural case runs **twice**, once per store, and asserts the identical outcome. @@ -81,6 +104,47 @@ Explicitly covered: > stale binary once "reproduced" a bug that had already been fixed. Rebuild before poking > at the binary by hand. +## `tests/tftp.rs` — a transfer is a conversation + +Nothing here can be proved from inside a function. Blocks, acknowledgements, +retransmission, the empty packet that ends a transfer — every bug worth catching lives in +the turn-taking, and the first run found two of the "works by hand, never after a reboot" +kind. **A file whose length is an exact multiple of the block size must end with an empty +data packet**; leave it out and the client waits forever for a final block that never +comes. + +It also owns the one listener failure in this server that is *not* fatal. A TFTP port that +cannot be bound must not take answers and media down with it — measured on DSM, where the +capability is granted outside the package and an upgrade drops it — so the test holds the +port with a squatter, then asserts three things at once: the server came up, it warned and +said what still works, and `boot check` still exits non-zero. + +That last one first passed for the wrong reason: three missing loaders were already +failing the command. The fixture now writes every loader the table names, and a control +run with TFTP off proves the directory is otherwise clean. + +## `tests/media.rs` — boot media against the real binary + +Both listeners up, and every abuse case ends by proving the server still answers. One case +proves the property the separate socket exists for: **answers keep succeeding while four +image transfers are in flight.** + +There is deliberately **no binary ISO fixture in this repository**. `boot::iso::build` +writes images in memory, behind the `test-support` feature so it never reaches a release +binary. + +## The boot chain belongs in the rig + +`packaging/boot-rig/run.sh` is not Rust and `cargo test` does not run it. It boots a +claimed and an unclaimed machine in QEMU under TCG, on a private bridge with no uplink, +and asserts four markers: the DHCP handoff answered from our own generated snippet, a +loader fetched over TFTP, the unclaimed machine fell through to its local disk, and the +claimed machine reached its own answer. CI runs the same thing plus a deliberate break. + +**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** — measured, which is why the +primary rig is one container rather than four on a Docker network. + ## Check that a test can fail A test that passes for the wrong reason is worse than no test: it reports coverage that @@ -124,7 +188,7 @@ do, and each proves something the others cannot. |---|---|---| | [`packaging/dsm/check-spk.sh`](https://github.com/z29k/rescriptum/blob/main/packaging/dsm/check-spk.sh) | the archive is structurally what DSM expects — uncompressed outer tar, six `INFO` fields, an all-numeric version, `os_min_ver` at least 7.1, 64×64 and 256×256 icons, executable scripts with no CRLF, **the packaged binary's own `--version`**, and the desktop application: `dsmappname` naming a class its `ui/config` actually declares, a JavaScript filename that carries the version, and a backend that still checks the DSM session and `administrators` | seconds, **on every push** | | [`packaging/dsm/lifecycle-test.sh`](https://github.com/z29k/rescriptum/blob/main/packaging/dsm/lifecycle-test.sh) | everything the package's *scripts* decide, against a fake `/var/packages` tree: the env file written once and only once, the wizard's values **and their absence**, the service surviving its own start script and answering `/health`, the exit codes Package Center reads, an upgrade that must not touch a hand-edited configuration, an uninstall that must not touch the answers — **and the desktop application's backend**, driven with a stubbed authenticator: refusing no session, refusing a non-administrator, refusing a write with no intent header, refusing one that would stop the server starting, and never handing a token to the browser | seconds, **on every push** | -| [`packaging/dsm/vm/on-dsm.sh`](https://github.com/z29k/rescriptum/blob/main/packaging/dsm/vm/on-dsm.sh) | DSM's own machinery — the `data-share` worker and its ACL, the `port-config` worker, the generated systemd unit, logrotate against a live descriptor, whether Package Center accepts the archive — **and that a machine asking for its configuration gets one**: a POST with hardware in the body, answered by that machine's file merged over the group claiming it | minutes, on a DSM 7 VM — and then on the DS416j | +| [`packaging/dsm/vm/on-dsm.sh`](https://github.com/z29k/rescriptum/blob/main/packaging/dsm/vm/on-dsm.sh) | DSM's own machinery — the `data-share` worker and its ACL, the `port-config` worker, the generated systemd unit, logrotate against a live descriptor, whether Package Center accepts the archive — **and that a machine asking for its configuration gets one**: a POST with hardware in the body, answered by that machine's file merged over the group claiming it. It also owns **the only route to port 69** and whether this NAS can reach a vendor's image index: that `69/udp` survives into the acquired firewall entry, that the package still answers without the capability, and that `setcap cap_net_bind_service=+ep` plus a restart binds `udp/69` as the unprivileged package process | minutes, on a DSM 7 VM — and then on the DS416j | ```bash packaging/dsm/lifecycle-test.sh # the first .spk in dist/ that runs here @@ -149,6 +213,10 @@ The same rule as everywhere else applies to these: **break the thing they guard them go red.** Reverting the `postinst` upgrade guard, making `postuninst` delete the share, returning `1` for a stopped package and refusing `prestart` turns 33 green checks into 25 green and 8 red — which is how we know the harness is testing anything at all. +Today it is **85** checks in `lifecycle-test.sh`, **28** in `check-spk.sh` and **52** on +the machine; the three most recently added were each watched red the same way — by putting +`RESCRIPTUM_TFTP_ADDR=off` back, by deleting the panel's report of the TFTP state, and by +making it claim to be serving with nothing bound. ## CI diff --git a/docs/development/traps.fr.md b/docs/development/traps.fr.md index 0534856..4365376 100644 --- a/docs/development/traps.fr.md +++ b/docs/development/traps.fr.md @@ -47,16 +47,31 @@ fermait aussitôt, si bien que l'installateur à qui il essayait de dire *« ré recevait un reset. Il draine maintenant brièvement d'abord, comme le faisait déjà le `put()` de l'API d'administration. Un test au plafond de connexions l'épingle. +- **macOS autorise un processus non privilégié à lier le port UDP 69 ; Linux non.** Un test + qui atteint l'adresse TFTP *par défaut* prend donc une branche différente sur chaque + plateforme — `boot check` traite un port libre mais silencieux comme une note, et un port + non liable comme un problème, ce qui est la bonne règle et exactement ce qui rend le test + dépendant de la plateforme. Il passait en local et échouait en CI pour une raison sans + rapport avec le changement. Tout test qui définit `RESCRIPTUM_BOOT_DIR` doit aussi définir + `RESCRIPTUM_TFTP_ADDR=off`, sauf si la sonde *est* le sujet ; `tests/tftp.rs` couvre le + port non liable sur un port haut. +- **Une branche développée entièrement hors ligne n'a jamais rencontré la CI.** Celle-ci a + accumulé 57 commits avant son premier push, et le premier run a échoué sur deux choses + qu'aucune exécution locale ne pouvait voir : un clippy cinq versions plus récent que la + toolchain locale épinglée, et une permission de port propre à Linux. Poussez assez tôt + pour le découvrir, ou attendez-vous à le découvrir tard. + ## Sélection et formats **Un Mac qui édite le répertoire de réponses en SMB peut détourner la réponse d'une machine.** macOS écrit un fichier AppleDouble `._` à côté d'un fichier dont le système -n'accepte pas les attributs étendus — `._98-fa-9b-50-d8-10.toml` a une extension *présente* -dans la liste, et la normalisation retire le `._` de tête : il revendique donc la même -identité que le vrai fichier, avec un contenu binaire. La machine qu'on configurait reçoit -une erreur d'analyse au lieu de sa réponse, et `check` fait échouer le *groupe* avec elle. -`.DS_Store` n'est inoffensif que par chance (son extension n'est pas dans la liste). Le store -fichiers ignore désormais toute entrée dont le nom commence par `.` ; trouvé sur un vrai NAS, +n'accepte pas les attributs étendus — `._proxmox.toml` a une extension *présente* dans la +liste. Avec un répertoire par identité, c'est pire que du temps des réponses à plat : c'est +un **second `.toml` dans un répertoire qui n'en accepte qu'un**, et il se trie *avant* le +vrai, si bien qu'une règle prenant le premier servirait un contenu binaire à toutes les +requêtes. La machine qu'on configurait reçoit alors une erreur d'analyse au lieu de sa +réponse. `.DS_Store` n'est inoffensif que par chance (son extension n'est pas dans la liste). +Le store fichiers ignore toute entrée dont le nom commence par `.` ; trouvé sur un vrai NAS, pas en lisant quoi que ce soit. **Normaliser un motif de sélecteur retire `*` et `?`** à moins d'utiliser `normalize_pattern` @@ -213,12 +228,124 @@ l'inode sous un serveur qui continue d'écrire dans un fichier sans nom. **Un `.spk` dont le tar externe est gzippé est rejeté** avec « invalid file format » et rien de plus. Idem pour un qui embarque des membres `._` de macOS. `check-spk.sh` vérifie les +deux. + +**Il existe exactement une route vers le port 69 sur DSM 7, et c'est `setcap`.** Les quatre +ont été essayées sur une machine 7.2.2 le 2026-08-27, parce que l'affirmation « DSM 7 +n'autorise pas un paquet non signé à tourner en root » traînait dans `CLAUDE.md` depuis un +moment **sans mesure derrière** — vraie, mais par chance. + +| Route | Résultat | +|---|---| +| `"defaults": {"run-as": "root"}` dans `conf/privilege` | **refusée** — erreur `synopkg` **319**, `invalid package privilege content`, `stage: install_failed` | +| `"ctrl-script": [{"action":"start","run-as":"root"}]` — la forme qu'utilisent les paquets *de Synology* (FileStation, QuickConnect et StorageManager tous les trois) | **refusée**, même erreur 319 | +| `cap_net_bind_service` embarquée en attribut étendu `security.capability` dans `package.tgz` | s'installe très bien — le format pax interne est accepté — mais **Package Center supprime l'attribut**, et `getcap` revient vide | +| `setcap cap_net_bind_service=+ep` sur le binaire installé, en root, après l'installation | **fonctionne** ; le paquet ouvre alors `udp/69` sous son propre utilisateur non privilégié, à côté de 8000 et 8001 | + +`net.ipv4.ip_unprivileged_port_start` n'existe pas sur ce noyau, donc cette route est +fermée aussi. `/volume1` est en btrfs avec `nodev` mais **pas** `nosuid`, donc les capacités +de fichier y fonctionnent bien, et `/usr/bin/setcap` existe en mode `0700`. + +**Le root sur DSM 7 est conditionné au fait d'être un paquet *Synology*, et +`libsynopkg.so.1` le dit noir sur blanc.** Lire ses chaînes sur une machine 7.2.2 transforme +la mesure ci-dessus en explication. Un paquet qui ne passe pas le contrôle de signature +(`verifyPackageSignature` vit dans la même bibliothèque) se voit refuser tout ceci : + +``` +Failed to pass privilege check, ctrl-script and executable section should not exist +Failed to pass privilege check, defaults should be provided and defaults.run-as should be package +Failed to pass privilege check, join-groupname should not contains admin group +Failed to pass privilege check, tool capabilities should not exist +Failed to pass privilege check, tool user should be package +Failed to pass privilege check, non-synology package should not use privilege migration +``` + +D'où le fait que FileStation, StorageManager, QuickConnect et SecureSignIn portent tous +`"ctrl-script": [{"action": "start", "run-as": "root"}]` dans leur propre `conf/privilege` +et que nous ne le pouvons pas : la forme est légale, c'est la signature qui la rend légale +*pour eux*. + +**La ligne la plus importante est `tool capabilities should not exist`.** Le format de +privilège de DSM a un champ `capabilities` natif — documenté comme +`"capabilities": "cap_chown,cap_net_raw"` sur une entrée `tool` depuis 7.0-40656, et +`SYNOPackageTool::Privilege::ChangeCapabilities` est bien là dans la bibliothèque. Un paquet +signé déclare `cap_net_bind_service` et n'a jamais besoin de `setcap`. **Le mécanisme que +nous voulons existe, est documenté, et nous est fermé.** + +Le guide développeur de Synology énonce la règle sans détour : *« If you are developing a +package with root privilege, you are not able to install that package unless it is signed +by synology. »* C'est donc **leur** signature, pas celle d'un éditeur tiers de confiance — +ce qui tranche ce que la chaîne de la bibliothèque laissait ouvert. SynoCommunity a heurté +le même mur ([spksrc#4170](https://github.com/SynoCommunity/spksrc/issues/4170), +[#4215](https://github.com/SynoCommunity/spksrc/issues/4215)). + +Il existe un contournement documenté, et ce **n'est pas une voie de distribution** : un +*jeton de développement*. On génère `debug.dat` depuis Centre d'assistance → Services +d'assistance, on l'envoie à Synology, on reçoit un jeton signé, on le dépose dans +`/var/packages/syno_dev_token`. Il n'est valable **que sur le NAS qui a produit le +`debug.dat`** : livrer ainsi voudrait dire que chaque utilisateur fasse un aller-retour avec +Synology avant de pouvoir installer. Un `setcap` est une commande locale, et c'est +strictement mieux pour lui. + +Conclusion, tranchée et non provisoire : **le `setcap` manuel est le prix de ne pas être +signé par Synology, et aucun changement d'empaquetage ne l'enlève.** Si le paquet est un +jour signé, l'étape manuelle et la tâche au démarrage sont remplacées par trois lignes dans +`conf/privilege`. + +**`setcap` fonctionne aussi sur un DS416j, et ce n'était pas acquis.** Les quatre routes +vers le port 69 ont été mesurées sur une VM 7.2.2, qui est en x86_64 avec `/volume1` en +btrfs monté `nodev` mais pas `nosuid` — or un volume monté `nosuid` fait ignorer les +capacités de fichier par le noyau, ce qui aurait fermé la dernière route ouverte sur la +seule machine pour laquelle ce projet existe. Mesuré sur le DS416j (ARMv7, `armada38x`) : +la capacité tient, le paquet ouvre `udp/69` sous son utilisateur non privilégié, et +`boot check` rapporte `0.0.0.0:69 handed over ipxe-arm64.efi` — une vraie requête de +lecture à laquelle on a répondu avec de vraies données. + +**La capacité appartient au fichier, donc une mise à jour la perd.** Une nouvelle version +remplace le binaire et la capacité part avec l'ancien — d'où la tâche au démarrage du +Planificateur de tâches documentée par le paquet plutôt qu'une commande unique, et d'où le +fait qu'un bind TFTP raté ne soit pas fatal : quand il l'était, cette mise à jour coupait +aussi le point d'entrée des réponses. + +**Lier n'est pas un contrôle de santé, et cela prouve le contraire de ce qu'on croit.** Un +bind qui *réussit* sur le port TFTP signifie que personne n'écoute — l'état dégradé, pas +l'état sain — et un bind qui échoue ne distingue pas ce serveur d'un autre service qui +squatterait le port, puisque les deux donnent `AddrInUse`. `boot check` envoie donc une +vraie requête de lecture et rapporte ce qu'obtiendrait une machine. Sa première version +annonçait « already in use — that is this server, if it is running » et un test avec un +squatteur sur le port a montré tout de suite que c'était une supposition. + +**Un nouveau réglage n'atteint jamais une installation qui existe déjà**, sauf si quelque +chose l'y met. Le fichier d'environnement vivant n'est écrit que s'il est absent — ce qui +est correct, une mise à jour ne doit jamais remplacer le port et les jetons de quelqu'un +par des valeurs par défaut — mais à lui seul cela rend une nouvelle fonctionnalité +invisible pour toute installation antérieure. Le boot media est arrivé avec les dossiers +créés, les chargeurs déposés et 69/udp enregistré au pare-feu, et `RESCRIPTUM_BOOT_DIR` +jamais posé : `boot check` répondait *« boot assets are off »* sur un DS416j où tout le +reste était en place. Comme `etc/` survit à une désinstallation, même désinstaller et +réinstaller n'y change rien. Le `.env.example` n'aide pas : rien n'oblige personne à le +lire. + +`postinst` ajoute désormais les clés dont le fichier vivant **n'a jamais entendu parler**, +sans toucher à ce qui est présent. **Une clé commentée compte comme présente**, et c'est là +la propriété de sûreté : c'est ainsi qu'un exploitant dit « celle-là je la connais et je +n'en veux pas ». Supprimer une ligne veut dire « jamais entendu parler » et la fait +revenir ; la commenter veut dire non, et c'est respecté. ## L'application de bureau DSM -Sept choses, mesurées sur une machine virtuelle DSM 7.2.2 et sur un DS416j en 7.1.1, et +Huit choses, mesurées sur une machine virtuelle DSM 7.2.2 et sur un DS416j en 7.1.1, et aucune dans le guide du développeur. +**Un défaut calculé à l'exécution doit l'être aussi dans `settings()`.** Le panneau rend le +défaut d'une variable comme valeur du champ ; un défaut qui n'existe que là où le serveur +le consomme s'affiche donc en case vide — pendant que le serveur, lui, tourne sur une +adresse qu'il a déduite et jamais montrée. `RESCRIPTUM_PUBLIC_HOST` est parti comme ça : +l'exploitant n'avait aucun moyen de voir vers quelle adresse ses machines seraient +envoyées, sinon en lisant le journal de démarrage. Deux entrées de `KNOWN` sont dans ce +cas, et toutes deux ont leur branche dans `settings()` : le nombre de threads et l'hôte +public. Une troisième demanderait le même traitement, et rien dans le typage ne le dit. + **Un CGI sous `/webman/3rdparty//` tourne sous le propriétaire du script.** Pas en `http`, et pas en root — sous celui qui possède le fichier. DSM attribue l'arborescence d'un paquet à l'utilisateur du paquet : le backend de l'application tourne donc en `rescriptum` et diff --git a/docs/development/traps.md b/docs/development/traps.md index e7d8df3..240aa13 100644 --- a/docs/development/traps.md +++ b/docs/development/traps.md @@ -44,16 +44,29 @@ closed immediately, so the installer it was trying to tell *"retry"* got a conne reset instead. It now drains briefly first, the way the admin API's `put()` already did. A test at the connection cap pins it. +- **macOS lets an unprivileged process bind UDP port 69; Linux does not.** So a test that + reaches the *default* TFTP address takes a different branch on each platform — `boot + check` calls an obtainable-but-silent port a note and an unbindable one a problem, which + is the right rule and exactly what makes the test platform-dependent. It passed locally + and failed in CI for a reason that had nothing to do with the change. Any test that sets + `RESCRIPTUM_BOOT_DIR` must also set `RESCRIPTUM_TFTP_ADDR=off` unless the probe *is* the + subject; `tests/tftp.rs` covers the unbindable port on a high one. +- **A branch developed entirely offline has never met the CI.** This one accumulated 57 + commits before its first push, and the first run failed on two things no local run could + see: a clippy five versions newer than the pinned local toolchain, and a Linux-only port + permission. Push early enough to find out, or expect to. + ## Selection and formats **A Mac editing the answers directory over SMB can hijack a machine's answer.** macOS writes an AppleDouble `._` beside a file whose extended attributes the filesystem will not -take — `._98-fa-9b-50-d8-10.toml` has an extension that *is* on the allowlist, and -normalization strips the leading `._`, so it claims the same identity as the real file with a -body that is binary. The machine being configured then receives a parse error instead of its -answer, and `check` reports the failure against the *group* as well. `.DS_Store` is harmless -only by luck (its extension is not on the list). The file store now skips every entry whose -name starts with `.`; found on a real NAS, not by reading anything. +take — `._proxmox.toml` has an extension that *is* on the allowlist. With a directory per +identity it is worse than it was when answers were flat: it is a **second `.toml` in a +directory that may hold only one**, and it sorts *before* the real one, so a rule that took +the first would hand every request a binary body. The machine being configured then receives +a parse error instead of its answer. `.DS_Store` is harmless only by luck (its extension is +not on the list). The file store skips every entry whose name starts with `.`; found on a +real NAS, not by reading anything. **Normalizing a selector pattern strips `*` and `?`** unless you use `normalize_pattern` — which turns every glob into a literal, quietly. @@ -197,11 +210,116 @@ carries on writing to a file with no name. **A `.spk` whose outer tar is gzipped is rejected** with "invalid file format" and no further detail. So is one carrying macOS `._` members. `check-spk.sh` asserts both. +**There is exactly one route to port 69 on DSM 7, and it is `setcap`.** All four were +tried on a 7.2.2 machine on 2026-08-27, because the claim "DSM 7 does not let an unsigned +package run as root" had sat in `CLAUDE.md` for a while with no measurement behind it — +true, but by luck. + +| Route | Result | +|---|---| +| `"defaults": {"run-as": "root"}` in `conf/privilege` | **refused** — `synopkg` error **319**, `invalid package privilege content`, `stage: install_failed` | +| `"ctrl-script": [{"action":"start","run-as":"root"}]` — the shape Synology's *own* packages use (FileStation, QuickConnect and StorageManager all do) | **refused**, same error 319 | +| `cap_net_bind_service` embedded as a `security.capability` xattr in `package.tgz` | installs fine — the pax inner format is accepted — but **Package Center strips the xattr**, and `getcap` comes back empty | +| `setcap cap_net_bind_service=+ep` on the installed binary, as root, after install | **works**; 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 that route is +closed too. `/volume1` is btrfs with `nodev` but **not** `nosuid`, so file capabilities do +work there, and `/usr/bin/setcap` exists at mode `0700`. + +**Root on DSM 7 is gated on being a *Synology* package, and `libsynopkg.so.1` says so in +so many words.** Reading its strings on a 7.2.2 machine turns the measurement above into +an explanation. A package that does not pass the signature check (`verifyPackageSignature` +lives in the same library) is refused all of this: + +``` +Failed to pass privilege check, ctrl-script and executable section should not exist +Failed to pass privilege check, defaults should be provided and defaults.run-as should be package +Failed to pass privilege check, join-groupname should not contains admin group +Failed to pass privilege check, tool capabilities should not exist +Failed to pass privilege check, tool user should be package +Failed to pass privilege check, non-synology package should not use privilege migration +``` + +Which is why FileStation, StorageManager, QuickConnect and SecureSignIn all carry +`"ctrl-script": [{"action": "start", "run-as": "root"}]` in their own `conf/privilege` and +we cannot: the shape is legal, the signature is what makes it legal *for them*. + +**The line that matters most is `tool capabilities should not exist`.** DSM's privilege +format has a native `capabilities` field — documented as +`"capabilities": "cap_chown,cap_net_raw"` on a `tool` entry since 7.0-40656, and +`SYNOPackageTool::Privilege::ChangeCapabilities` is right there in the library. A signed +package declares `cap_net_bind_service` and never needs `setcap` at all. **The mechanism we +want exists, is documented, and is closed to us.** + +Synology's developer guide states the rule outright: *"If you are developing a package with +root privilege, you are not able to install that package unless it is signed by synology."* +So it is **their** signature, not any trusted publisher's — which answers what the library +string left open. SynoCommunity hit the same wall +([spksrc#4170](https://github.com/SynoCommunity/spksrc/issues/4170), +[#4215](https://github.com/SynoCommunity/spksrc/issues/4215)). + +There is one documented bypass and it is **not a distribution path**: a *development +token*. Generate `debug.dat` from Support Center → Support Services, send it to Synology, +receive a signed token, drop it at `/var/packages/syno_dev_token`. It is valid **only on +the NAS that generated the `debug.dat`**, so shipping this way would mean every single user +doing a round trip with Synology before they could install. `setcap` is one local command +and strictly better for them. + +Conclusion, and it is settled rather than provisional: **the manual `setcap` is the price +of not being signed by Synology, and no packaging change removes it.** If the package is +ever signed, the manual step and the boot-up task are both replaced by three lines in +`conf/privilege`. + +**`setcap` works on a DS416j too, and that was not a given.** The four routes to port 69 +were measured on a 7.2.2 VM, which is x86_64 with `/volume1` on btrfs mounted `nodev` but +not `nosuid` — and a volume mounted `nosuid` makes the kernel ignore file capabilities +entirely, which would have closed the last open route on the one machine this project +exists for. Measured on the DS416j (ARMv7, `armada38x`): the capability holds, the package +binds `udp/69` as its unprivileged user, and `boot check` reports +`0.0.0.0:69 handed over ipxe-arm64.efi` — a real read request answered with real data. + +**The capability belongs to the file, so an upgrade drops it.** A new version replaces the +binary and the capability goes with the old one — which is why the package documents a +Task Scheduler boot-up task rather than a one-off command, and why a failed TFTP bind is +not fatal: when it was, that upgrade took the answer endpoint down too. + +**Binding is not a health check, and it proves the opposite of what it looks like.** A +bind that *succeeds* on the TFTP port 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`. `boot check` therefore sends a real read +request and reports what a machine would get. The first version of it reported "already in +use — that is this server, if it is running" and a test with a squatter on the port +immediately showed that to be a guess. + +**A new setting never reaches an installation that already exists**, unless something +puts it there. The live env file is written only when absent — correct, because an upgrade +must never replace somebody's port and tokens with defaults — but on its own that makes a +new feature invisible to every install that predates it. Boot media shipped with the +folders created, the loaders seeded and 69/udp registered with the firewall, and +`RESCRIPTUM_BOOT_DIR` never arriving, so `boot check` answered *"boot assets are off"* on a +DS416j that had everything else in place. `etc/` surviving an uninstall means even removing +and reinstalling does not fix it. The `.env.example` was no help, because nothing makes +anybody read it. + +`postinst` now appends keys the live file has **never heard of**, touching nothing that is +present. **A commented-out key counts as present**, and that is the safety property: it is +how an operator says "I know about this one and I do not want it". Deleting a line means +"never heard of it" and gets it back; commenting it out means no, and is respected. + ## The DSM desktop application -Seven things, measured on a DSM 7.2.2 virtual machine and on a DS416j running 7.1.1, and +Eight things, measured on a DSM 7.2.2 virtual machine and on a DS416j running 7.1.1, and none of them in the developer guide. +**A default computed at runtime has to be computed in `settings()` too.** 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 an empty box — while the server runs on an address it derived and +never displayed. `RESCRIPTUM_PUBLIC_HOST` shipped that way; the operator had no way to see +which address their machines would be sent to short of reading the startup log. Two entries +in `KNOWN` are like this, and both are special-cased in `settings()`: the worker count and +the public host. A third would need the same treatment, and nothing in the type system says +so. + **A CGI under `/webman/3rdparty//` runs as the owner of the script.** Not as `http`, and not as root — as whoever owns the file. DSM chowns a package's tree to the package user, so the application's backend runs as `rescriptum` and can read the `0600` env file it diff --git a/docs/guide/answers/formats.fr.md b/docs/guide/answers/formats.fr.md index ea474ba..cbac800 100644 --- a/docs/guide/answers/formats.fr.md +++ b/docs/guide/answers/formats.fr.md @@ -13,7 +13,7 @@ kickstart veut du kickstart et s'étranglerait avec du TOML. C'est le protocole, convention que quelqu'un aurait choisie. Donc : - **l'endpoint déclare le format** — `/rhel/ks` demande du kickstart ; -- **le document le porte comme extension** — `rhel-compute.ks` ; +- **le document le porte comme extension** — `groups/rhel-compute/rhel.ks` ; - **seuls les documents de ce format peuvent répondre.** ## La conséquence qui fait comprendre @@ -23,8 +23,9 @@ destinée.** Donc ceci n'est pas une machine et deux fichiers : ``` answers/ -├── 98fa9b50d810.toml « cette machine, en tant que Proxmox » -└── 98fa9b50d810.preseed « cette machine, en tant que Debian » +└── 98fa9b50d810/ + ├── proxmox.toml « cette machine, en tant que Proxmox » + └── debian.preseed « cette machine, en tant que Debian » ``` C'est un même matériel avec deux réponses, et les deux peuvent exister en même temps. Celle @@ -33,15 +34,20 @@ obtient le TOML, `/debian/preseed` obtient le preseed. Aucune n'est plus « la l'autre. En interne, c'est pourquoi un document est indexé par **(identifiant, format)** plutôt que -par identifiant seul. +par identifiant seul — et pourquoi un répertoire contient un document par format, pas +davantage. ## Le stockage n'est pas l'URL -Tout vit dans un seul répertoire plat, et c'est délibéré. **Répertoires et lignes de base -sont un espace de recherche** — ils doivent rester libres d'être réorganisés. **Une URL est -un contrat public gravé dans une ISO** — elle ne doit pas bouger parce que quelqu'un a -renommé un dossier. Une conception antérieure faisait du nom de répertoire *le* segment -d'URL et a été écartée pour exactement cette raison. +Les documents sont regroupés par *identité*, jamais par format, et c'est délibéré. +**Répertoires et lignes de base sont un espace de recherche** — ils doivent rester libres +d'être réorganisés. **Une URL est un contrat public gravé dans une ISO** — elle ne doit pas +bouger parce que quelqu'un a renommé un dossier. Une conception antérieure faisait du nom de +répertoire *le* segment d'URL et a été écartée pour exactement cette raison. + +La même règle explique pourquoi le nom de fichier dans le répertoire d'une machine ne porte +aucun sens : `proxmox.toml` se lit bien et fait écho à l'endpoint `/proxmox/`, mais seul le +`.toml` est porteur. Renommez-le `answer.toml` et rien ne change. Quel alias sert quelle extension est dans la [référence des formats](../reference/formats.md) ; comment en choisir un pour votre média @@ -172,5 +178,5 @@ aucun sens. Le groupement n'est par ailleurs pas affecté par tout cela : une baie partage un groupe *par format*, et une machine qui existe en deux systèmes d'exploitation rejoint deux d'entre eux. -`default` suit la même règle — `default.toml` répond à une requête qui a demandé du TOML, et -jamais à une qui a demandé du kickstart. +`default` suit la même règle — un `.toml` dans `default/` répond à une requête qui a demandé +du TOML, et jamais à une qui a demandé du kickstart. diff --git a/docs/guide/answers/formats.md b/docs/guide/answers/formats.md index f8e6d5f..29152eb 100644 --- a/docs/guide/answers/formats.md +++ b/docs/guide/answers/formats.md @@ -13,7 +13,7 @@ kickstart and would choke on TOML. That is the protocol, not a convention anyone So: - **the endpoint declares the format** — `/rhel/ks` asks for kickstart; -- **the document carries it as its extension** — `rhel-compute.ks`; +- **the document carries it as its extension** — `groups/rhel-compute/rhel.ks`; - **only documents of that format may answer.** ## The consequence that makes it click @@ -23,8 +23,9 @@ machine and two files: ``` answers/ -├── 98fa9b50d810.toml "that machine, as Proxmox" -└── 98fa9b50d810.preseed "that machine, as Debian" +└── 98fa9b50d810/ + ├── proxmox.toml "that machine, as Proxmox" + └── debian.preseed "that machine, as Debian" ``` It is one piece of hardware with two answers, and both can exist at once. Which one a @@ -32,16 +33,20 @@ request receives depends on the URL it arrived on — `/proxmox/answer` gets the `/debian/preseed` gets the preseed. Neither is more "the" answer than the other. Internally this is why a document is keyed by **(identifier, format)** rather than by -identifier alone. +identifier alone — and why one directory holds one document per format and no more. ## Storage is not the URL -Everything lives in one flat directory, and that is deliberate. **Directories and -database rows are a lookup space** — they must stay free to be reorganised. **A URL is a -public contract baked into an ISO** — it must not move because someone renamed a folder. +Documents are grouped by *identity*, never by format, and that is deliberate. **Directories +and database rows are a lookup space** — they must stay free to be reorganised. **A URL is +a public contract baked into an ISO** — it must not move because someone renamed a folder. An earlier design made the directory name *be* the URL segment and was discarded for exactly that reason. +The same rule is why the filename inside a machine's directory carries no meaning: +`proxmox.toml` reads well and matches the `/proxmox/` endpoint, but only the `.toml` is +load-bearing. Rename it `answer.toml` and nothing changes. + Which alias serves which extension is in the [format reference](../reference/formats.md); how to pick one for your media is in [preparing installer media](../iso.md). @@ -166,5 +171,5 @@ same reason — layering a preseed onto a TOML base is meaningless. Grouping is otherwise untouched by any of this: a rack shares one group *per format*, and a machine that exists as two operating systems joins two of them. -`default` follows the same rule — `default.toml` answers a request that asked for TOML, -and never one that asked for kickstart. +`default` follows the same rule — a `.toml` in `default/` answers a request that asked for +TOML, and never one that asked for kickstart. diff --git a/docs/guide/answers/grouping.fr.md b/docs/guide/answers/grouping.fr.md index c7208cf..90efa42 100644 --- a/docs/guide/answers/grouping.fr.md +++ b/docs/guide/answers/grouping.fr.md @@ -1,6 +1,6 @@ --- title: Groupes et fusion -description: Une baie partage un fichier ; une machine qui diffère ne porte que sa différence. Ce qui fusionne, ce qui remplace, et pourquoi les tableaux remplacent. +description: Une baie partage un document ; une machine qui diffère ne porte que sa différence. Ce qui fusionne, ce qui remplace, et pourquoi les tableaux remplacent. sidebar: label: Groupement order: 3 @@ -14,16 +14,20 @@ machine est la façon dont la configuration d'un parc dérive. Donc les réponse ``` answers/ ├── groups/ -│ ├── base.toml partagé par tout -│ └── rack-a.toml extends = "base" ; members = [ … ] -├── 98-fa-9b-50-d8-10.toml les surcharges d'une machine (optionnel) -└── default.toml seulement quand rien d'autre ne correspond +│ ├── base/ +│ │ └── proxmox.toml partagé par tout +│ └── rack-a/ +│ └── proxmox.toml extends = "base" ; members = [ … ] +├── 98-fa-9b-50-d8-10/ +│ └── proxmox.toml les surcharges d'une machine (optionnel) +└── default/ + └── proxmox.toml seulement quand rien d'autre ne correspond ``` ## La partie partagée ```toml -# answers/groups/rack-a.toml +# answers/groups/rack-a/proxmox.toml members = [ "98:fa:9b:50:d8:10", "98:fa:9b:50:d8:11", @@ -46,7 +50,7 @@ disk-list = ["sda", "sdb"] Une machine qui diffère reçoit un document contenant **seulement la différence** : ```toml -# answers/98-fa-9b-50-d8-10.toml +# answers/98-fa-9b-50-d8-10/proxmox.toml [global] fqdn = "node01.example.com" @@ -95,7 +99,7 @@ Un groupe peut en étendre un autre, ce qui donne une chaîne — ce que toutes partagent dans un fichier, les différences par baie dans un autre : ```toml -# answers/groups/base.toml +# answers/groups/base/proxmox.toml [global] mailto = "ops@example.com" timezone = "Europe/Paris" @@ -103,7 +107,7 @@ root-ssh-keys = ["ssh-ed25519 AAAA…REPLACE ops@example.com"] ``` ```toml -# answers/groups/rack-a.toml +# answers/groups/rack-a/proxmox.toml extends = "base" members = ["98:fa:9b:50:d8:10", "98:fa:9b:50:d8:11"] @@ -117,7 +121,7 @@ Les couches s'appliquent alors `base` → `rack-a` → document machine. pour une machine qui a besoin d'un groupe où elle n'est pas listée : ```toml -# answers/98-fa-9b-50-d8-99.toml +# answers/98-fa-9b-50-d8-99/proxmox.toml extends = "rack-a" # même si rack-a ne liste pas cette MAC [global] @@ -144,7 +148,7 @@ fois dans le log, et le groupe cassé est **écarté plutôt qu'appliqué à moi 2026-08-24T08:43:36Z - warning: group "rack-a": extends unknown group "base" ``` -Un mauvais fichier de groupe n'empêche pas les autres baies de s'installer. Une machine qui +Un groupe cassé n'empêche pas les autres baies de s'installer. Une machine qui *avait besoin* de ce groupe reçoit un `500` bruyant plutôt qu'une réponse à moitié construite — servir une configuration dont la base manque installerait la machine à moitié configurée, et personne ne s'en apercevrait avant qu'elle ne tourne. @@ -167,9 +171,16 @@ store**, puis servi comme une chaîne préparée. Le cas courant en datacenter n requête. Ajouter une surcharge par machine coûte une fusion par requête — ça vaut le coup là où c'est nécessaire, et ça vaut le coup de l'éviter ailleurs. +L'autre moitié du même argument, c'est ce que coûte une *lecture*. Le store entier est relu +au plus une fois par seconde, et avec un répertoire par identité cette lecture ajoute un +`readdir` par machine au fichier qu'elle ouvrait déjà — mesuré à 2 000 machines sur un +M1 Pro : **28 ms avant le changement d'agencement, 63 ms après**. C'est amorti sur une +seconde de requêtes dans les deux cas, et les débits ci-dessus n'ont pas bougé de façon +mesurable ; mais un groupe qui dispense d'un répertoire par machine évite ce coût aussi. + ## Ensuite - [Templating](./templating.md) — `{{ serial }}` supprime la dernière raison d'avoir un - fichier par machine. + répertoire par machine. - [Validation](./validating.md) — une réponse fusionnée est un document que personne n'a écrit ; regardez-le avant qu'une baie ne le fasse. diff --git a/docs/guide/answers/grouping.md b/docs/guide/answers/grouping.md index bac21ac..f2db8e2 100644 --- a/docs/guide/answers/grouping.md +++ b/docs/guide/answers/grouping.md @@ -1,6 +1,6 @@ --- title: Groups and merging -description: A rack shares one file; a machine that differs carries only its difference. What merges, what replaces, and why arrays replace. +description: A rack shares one document; a machine that differs carries only its difference. What merges, what replaces, and why arrays replace. sidebar: label: Grouping order: 3 @@ -14,16 +14,20 @@ once per machine is how a fleet's configuration drifts. So answers compose. ``` answers/ ├── groups/ -│ ├── base.toml shared by everything -│ └── rack-a.toml extends = "base"; members = [ … ] -├── 98-fa-9b-50-d8-10.toml one machine's overrides (optional) -└── default.toml only when nothing else matches +│ ├── base/ +│ │ └── proxmox.toml shared by everything +│ └── rack-a/ +│ └── proxmox.toml extends = "base"; members = [ … ] +├── 98-fa-9b-50-d8-10/ +│ └── proxmox.toml one machine's overrides (optional) +└── default/ + └── proxmox.toml only when nothing else matches ``` ## The shared part ```toml -# answers/groups/rack-a.toml +# answers/groups/rack-a/proxmox.toml members = [ "98:fa:9b:50:d8:10", "98:fa:9b:50:d8:11", @@ -46,7 +50,7 @@ disk-list = ["sda", "sdb"] A machine that differs gets a document with **only the difference** in it: ```toml -# answers/98-fa-9b-50-d8-10.toml +# answers/98-fa-9b-50-d8-10/proxmox.toml [global] fqdn = "node01.example.com" @@ -95,7 +99,7 @@ A group may extend another group, giving a chain — what every rack shares in o per-rack differences in another: ```toml -# answers/groups/base.toml +# answers/groups/base/proxmox.toml [global] mailto = "ops@example.com" timezone = "Europe/Paris" @@ -103,7 +107,7 @@ root-ssh-keys = ["ssh-ed25519 AAAA…REPLACE ops@example.com"] ``` ```toml -# answers/groups/rack-a.toml +# answers/groups/rack-a/proxmox.toml extends = "base" members = ["98:fa:9b:50:d8:10", "98:fa:9b:50:d8:11"] @@ -117,7 +121,7 @@ Layers then apply `base` → `rack-a` → machine document. machine that needs a group it is not listed in: ```toml -# answers/98-fa-9b-50-d8-99.toml +# answers/98-fa-9b-50-d8-99/proxmox.toml extends = "rack-a" # even though rack-a does not list this MAC [global] @@ -144,7 +148,7 @@ once, and the broken group is **dropped rather than half-applied**: 2026-08-24T08:43:36Z - warning: group "rack-a": extends unknown group "base" ``` -One bad group file does not stop the other racks from installing. A machine that +One bad group does not stop the other racks from installing. A machine that *needed* that group gets a loud `500` rather than a half-built answer — serving a configuration whose base is missing would install the machine half-configured, and nobody would find out until it was running. @@ -167,9 +171,16 @@ is read**, and served afterwards as a prepared string. The common datacenter cas nothing per request. Adding a per-machine override buys a merge per request — worth it where it is needed, and worth avoiding where it is not. +The other half of the same argument is what a *read* costs. The whole store is re-read at +most once a second, and with a directory per identity that read is a `readdir` per machine +on top of the file it already opened — measured at 2,000 machines on an M1 Pro, **28 ms +before the layout changed and 63 ms after**. It is amortised over a second's worth of +requests either way, and the throughput figures above did not move measurably; but a group +that needs no per-machine directory avoids that cost too. + ## Next -- [Templating](./templating.md) — `{{ serial }}` removes the remaining reason for a file - per machine. +- [Templating](./templating.md) — `{{ serial }}` removes the remaining reason for a + directory per machine. - [Validating](./validating.md) — a merged answer is a document nobody wrote; look at it before a rack does. diff --git a/docs/guide/answers/index.fr.md b/docs/guide/answers/index.fr.md index 55b8ee9..7c714cb 100644 --- a/docs/guide/answers/index.fr.md +++ b/docs/guide/answers/index.fr.md @@ -17,29 +17,56 @@ couches que vous avez écrites. ## L'agencement -Tout vit dans un seul répertoire plat. Il n'y a pas de dossier par OS, parce que -l'organisation du stockage et l'URL sont délibérément séparées — voir -[formats](./formats.md#le-stockage-nest-pas-lurl). +**Un répertoire par identité.** Une machine est un répertoire nommé d'après elle, qui +contient un document par système d'exploitation : ``` answers/ -├── 98fa9b50d810.toml cette machine, en tant que Proxmox -├── 98fa9b50d810.preseed …et la même machine, en tant que Debian -├── aabbccddeeff.yaml une autre machine, Ubuntu -├── default.toml quand rien d'autre ne correspond +├── 98fa9b50d810/ une machine +│ ├── proxmox.toml en tant que Proxmox +│ └── debian.preseed …et le même matériel en tant que Debian +├── aabbccddeeff/ une autre machine +│ └── ubuntu.yaml en tant qu'Ubuntu +├── default/ quand rien d'autre ne correspond +│ └── proxmox.toml └── groups/ - ├── rack-a.toml partagé par une baie, revendique ses membres - ├── base.preseed - └── rhel-compute.ks revendique des machines pour ce qu'elles sont + ├── rack-a/ partagé par une baie, revendique ses membres + │ ├── proxmox.toml + │ └── debian.preseed + └── rhel-compute/ revendique des machines pour ce qu'elles sont + └── rhel.ks ``` -- **Un document machine** est nommé d'après la machine — une adresse MAC, dans n'importe quel - style de séparateur — et porte la configuration de cette machine, ou seulement la part qui - diffère de son groupe. -- **Un groupe** vit dans `groups/` et est partagé. Il revendique des machines en les listant - dans `members`, ou par un bloc `match` testé contre la requête. -- **`default.`** répond quand rien d'autre ne le fait. Un par format : un défaut TOML ne - doit pas répondre à un client qui a demandé du kickstart. +- **Une machine** est un répertoire nommé d'après elle — une adresse MAC, dans n'importe + quel style de séparateur — qui porte la configuration de cette machine, ou seulement la + part qui diffère de son groupe. +- **Un groupe** est un répertoire sous `groups/` et il est partagé. Il revendique des + machines en les listant dans `members`, ou par un bloc `match` testé contre la requête. +- **`default/`** répond quand rien d'autre ne le fait. Un document par format : un défaut + TOML ne doit pas répondre à un client qui a demandé du kickstart. + +### L'extension décide ; le nom, non + +Dans un répertoire, **l'extension est le format et ce qui précède ne veut rien dire**. +`proxmox.toml` et `answer.toml` sont le même document pour le serveur ; le nom est là pour +qui ouvre le dossier. `rescriptum` en écrit des lisibles — `proxmox.toml`, `ubuntu.yaml`, +`debian.preseed`, `boot.ipxe` — et ne renomme jamais les vôtres. + +La seule règle qui en découle : **un répertoire contient au plus un document par format**. +Deux `.toml` dans un même répertoire est signalé comme un problème plutôt que tranché, +parce que rien ne pourrait choisir entre eux d'une façon que vous auriez prévue. Deux +formats *différents* ne sont pas un doublon — c'est justement l'intérêt du répertoire. + +L'organisation du stockage et l'URL restent délibérément séparées : un dossier peut être +réorganisé, une URL gravée dans une ISO non. Voir +[formats](./formats.md#le-stockage-nest-pas-lurl). + +:::note[Migration depuis un répertoire plat] +Les réponses étaient des fichiers à la racine du répertoire : `98fa9b50d810.toml` à côté de +`98fa9b50d810.preseed`. Ils ne sont **plus servis**, et chacun est signalé par son nom avec +son nouveau chemin. `rescriptum migrate` montre ce qu'il déplacerait ; `rescriptum migrate +--apply` les déplace. +::: ## Les cinq choses à savoir @@ -70,7 +97,7 @@ L'écriture par format est dans [formats](./formats.md#où-vivent-les-clés-de-c Le répertoire [`examples/`](https://github.com/z29k/rescriptum/tree/main/examples) du dépôt contient un exemple commenté de **chaque** format supporté, tous sélectionnés différemment — -par matériel, par liste de membres, par nom de fichier — et ils sont exercés par la suite de +par matériel, par liste de membres, par nom de répertoire — et ils sont exercés par la suite de tests : ```console diff --git a/docs/guide/answers/index.md b/docs/guide/answers/index.md index bb7a627..eb37936 100644 --- a/docs/guide/answers/index.md +++ b/docs/guide/answers/index.md @@ -16,28 +16,55 @@ asking and hand it back, assembled from however many layers you wrote. ## The layout -Everything lives in one flat directory. There is no per-OS folder, because storage layout -and URL are deliberately kept apart — see [formats](./formats.md#storage-is-not-the-url). +**One directory per identity.** A machine is a directory named after it, holding one +document per operating system: ``` answers/ -├── 98fa9b50d810.toml this machine, as Proxmox -├── 98fa9b50d810.preseed …and the same machine, as Debian -├── aabbccddeeff.yaml another machine, Ubuntu -├── default.toml when nothing else matches +├── 98fa9b50d810/ one machine +│ ├── proxmox.toml as Proxmox +│ └── debian.preseed …and the same hardware as Debian +├── aabbccddeeff/ another machine +│ └── ubuntu.yaml as Ubuntu +├── default/ when nothing else matches +│ └── proxmox.toml └── groups/ - ├── rack-a.toml shared by a rack, claims its members - ├── base.preseed - └── rhel-compute.ks claims machines by what they are + ├── rack-a/ shared by a rack, claims its members + │ ├── proxmox.toml + │ └── debian.preseed + └── rhel-compute/ claims machines by what they are + └── rhel.ks ``` -- **A machine document** is named after the machine — a MAC address, in any separator - style — and holds that machine's own configuration, or only the part of it that differs - from its group. -- **A group** lives in `groups/` and is shared. It claims machines by listing them in - `members`, or by a `match` block tested against the request. -- **`default.`** answers when nothing else does. One per format: a TOML default must - not answer a client that asked for kickstart. +- **A machine** is a directory named after it — a MAC address, in any separator style — + holding that machine's own configuration, or only the part of it that differs from its + group. +- **A group** is a directory under `groups/` and is shared. It claims machines by listing + them in `members`, or by a `match` block tested against the request. +- **`default/`** answers when nothing else does. One document per format: a TOML default + must not answer a client that asked for kickstart. + +### The extension decides; the name does not + +Inside a directory, **the extension is the format and the part before it means nothing**. +`proxmox.toml` and `answer.toml` are the same document to the server; the name is there +for whoever opens the folder. `rescriptum` writes readable ones — `proxmox.toml`, +`ubuntu.yaml`, `debian.preseed`, `boot.ipxe` — and never renames yours. + +The one rule that follows: **a directory holds at most one document per format**. Two +`.toml` in one directory is reported as a problem rather than resolved, because nothing +could pick between them that you would have predicted. Two *different* formats are not a +duplicate at all — that is the whole point of the directory. + +Storage layout and URL are still deliberately kept apart: a folder can be reorganised, a +URL baked into an ISO cannot. See [formats](./formats.md#storage-is-not-the-url). + +:::note[Upgrading from a flat directory] +Answers used to be files at the top of the directory: `98fa9b50d810.toml` beside +`98fa9b50d810.preseed`. Those are **no longer served**, and each one is reported by name +with its new path. `rescriptum migrate` shows what it would move; `rescriptum migrate +--apply` moves them. +::: ## The five things to know @@ -68,8 +95,8 @@ per-format spelling is in [formats](./formats.md#where-the-control-keys-live). The repository's [`examples/`](https://github.com/z29k/rescriptum/tree/main/examples) directory carries a commented example of **every** supported format, all selected -differently — by hardware, by member list, by filename — and they are exercised by the -test suite: +differently — by hardware, by member list, by directory name — and they are exercised by +the test suite: ```console $ RESCRIPTUM_ANSWERS_DIR=examples rescriptum check diff --git a/docs/guide/answers/selection.fr.md b/docs/guide/answers/selection.fr.md index c548db0..2e2ab83 100644 --- a/docs/guide/answers/selection.fr.md +++ b/docs/guide/answers/selection.fr.md @@ -13,41 +13,48 @@ ordonnées par la finesse avec laquelle elles visent une machine. ## 1. Par le nom -Nommez un document d'après l'adresse MAC de la machine : +Nommez un **répertoire** d'après l'adresse MAC de la machine, et mettez-y ses documents : ``` answers/ -├── 98-fa-9b-50-d8-10.toml -├── aabbccddeeff.toml -└── default.toml +├── 98-fa-9b-50-d8-10/ +│ └── proxmox.toml +├── aabbccddeeff/ +│ └── proxmox.toml +└── default/ + └── proxmox.toml ``` Quand une requête arrive, le serveur passe en minuscules tout ce qu'elle porte et retire -chaque caractère non alphanumérique, fait de même avec le nom de chaque document, et sert le -premier dont le nom apparaît **à l'intérieur** de la requête. Ainsi -`98-fa-9b-50-d8-10.toml`, `98:fa:9b:50:d8:10.toml` et `98fa9b50d810.toml` correspondent tous -à la même machine — vous n'avez jamais à vous soucier du style de séparateur que Proxmox -utilise cette version-ci, ni de la façon dont il structure son JSON. +chaque caractère non alphanumérique, fait de même avec le nom de chaque répertoire, et sert +le premier dont le nom apparaît **à l'intérieur** de la requête. Ainsi +`98-fa-9b-50-d8-10`, `98:fa:9b:50:d8:10` et `98fa9b50d810` nomment tous la même machine — +vous n'avez jamais à vous soucier du style de séparateur que Proxmox utilise cette +version-ci, ni de la façon dont il structure son JSON. + +L'identité est le nom du **répertoire** ; les noms de fichiers qu'il contient ne choisissent +rien, ils ne portent que le format dans leur extension. Cette normalisation est toute l'astuce, et c'est pourquoi cela survit à un changement de format de corps entre versions de Proxmox : c'est un test de sous-chaîne sur des octets, pas un schéma. -Rien n'empêche de nommer un document d'après un numéro de série, un code d'inventaire ou un -nom d'hôte. N'importe quelle chaîne apparaissant dans ce que la machine envoie fera l'affaire. +Rien n'empêche de nommer un répertoire d'après un numéro de série, un code d'inventaire ou +un nom d'hôte. N'importe quelle chaîne apparaissant dans ce que la machine envoie fera +l'affaire. ## 2. Par liste de membres Un groupe revendique un ensemble de machines en les listant : ```toml -# answers/groups/rack-a.toml +# answers/groups/rack-a/proxmox.toml members = ["98:fa:9b:50:d8:10", "98:fa:9b:50:d8:11", "98:fa:9b:50:d8:12"] ``` -Les chaînes de `members` sont normalisées exactement comme les noms de fichiers, donc le +Les chaînes de `members` sont normalisées exactement comme les noms de répertoires, donc le style de séparateur n'a pas d'importance ici non plus. Une machine listée n'a besoin -d'aucun document propre à moins d'avoir quelque chose à surcharger — voir +d'aucun répertoire propre à moins d'avoir quelque chose à surcharger — voir [groupes](./grouping.md). ## 3. Par ce que la machine est @@ -55,7 +62,7 @@ d'aucun document propre à moins d'avoir quelque chose à surcharger — voir Un bloc `match` revendique une machine par ses propriétés plutôt que par son identité : ```toml -# answers/groups/dell-r620.toml +# answers/groups/dell-r620/proxmox.toml [match] manufacturer = "Dell Inc." product = "PowerEdge R620" @@ -113,7 +120,7 @@ que la botte de foin. Normalisé en alphanumériques minuscules : la botte de foin de sous-chaînes qui fait fonctionner la correspondance par nom. Les valeurs de query et les segments de chemin y sont -aussi ajoutés, donc un document nommé d'après une MAC résout que la MAC soit arrivée dans un +aussi ajoutés, donc un répertoire nommé d'après une MAC résout que la MAC soit arrivée dans un corps POST ou dans une query string. Sans cela, un `GET` — qui n'a aucun corps — ne pourrait jamais correspondre par nom. diff --git a/docs/guide/answers/selection.md b/docs/guide/answers/selection.md index 70fa478..a239679 100644 --- a/docs/guide/answers/selection.md +++ b/docs/guide/answers/selection.md @@ -13,26 +13,31 @@ ordered by how narrowly they target one machine. ## 1. By name -Name a document after the machine's MAC address: +Name a **directory** after the machine's MAC address, and put its documents in it: ``` answers/ -├── 98-fa-9b-50-d8-10.toml -├── aabbccddeeff.toml -└── default.toml +├── 98-fa-9b-50-d8-10/ +│ └── proxmox.toml +├── aabbccddeeff/ +│ └── proxmox.toml +└── default/ + └── proxmox.toml ``` When a request arrives, the server lowercases everything it carries and drops every -non-alphanumeric character, does the same to each document's name, and serves the first -whose name appears **inside** the request. So `98-fa-9b-50-d8-10.toml`, -`98:fa:9b:50:d8:10.toml` and `98fa9b50d810.toml` all match the same machine — you never -have to care which separator style Proxmox happens to use this version, or how it -structures its JSON. +non-alphanumeric character, does the same to each directory's name, and serves the first +whose name appears **inside** the request. So `98-fa-9b-50-d8-10`, `98:fa:9b:50:d8:10` and +`98fa9b50d810` all name the same machine — you never have to care which separator style +Proxmox happens to use this version, or how it structures its JSON. + +The identity is the **directory** name; the filenames inside it choose nothing, they only +carry the format in their extension. That normalization is the whole trick, and it is why this survives Proxmox changing its body format between releases: it is a substring test over the bytes, not a schema. -Nothing prevents naming a document after a serial number, an asset tag or a hostname +Nothing prevents naming a directory after a serial number, an asset tag or a hostname instead. Any string that appears in what the machine sends will do. ## 2. By member list @@ -40,20 +45,20 @@ instead. Any string that appears in what the machine sends will do. A group claims a set of machines by listing them: ```toml -# answers/groups/rack-a.toml +# answers/groups/rack-a/proxmox.toml members = ["98:fa:9b:50:d8:10", "98:fa:9b:50:d8:11", "98:fa:9b:50:d8:12"] ``` -Member strings are normalized exactly like file names, so separator style does not matter -here either. A listed machine needs no document of its own unless it has something to -override — see [grouping](./grouping.md). +Member strings are normalized exactly like directory names, so separator style does not +matter here either. A listed machine needs no directory of its own unless it has something +to override — see [grouping](./grouping.md). ## 3. By what the machine is A `match` block claims a machine by its properties rather than its identity: ```toml -# answers/groups/dell-r620.toml +# answers/groups/dell-r620/proxmox.toml [match] manufacturer = "Dell Inc." product = "PowerEdge R620" @@ -107,7 +112,7 @@ A body that is not JSON is not an error — it simply contributes nothing but th ### The raw body Normalized to lowercase alphanumerics: the substring haystack that makes matching by name -work. Query values and path segments are appended to it too, so a document named after a +work. Query values and path segments are appended to it too, so a directory named after a MAC resolves whether that MAC arrived in a POST body or a query string. Without that, a `GET` — which has no body at all — could never match by name. diff --git a/docs/guide/answers/templating.fr.md b/docs/guide/answers/templating.fr.md index 91eed48..44de8fa 100644 --- a/docs/guide/answers/templating.fr.md +++ b/docs/guide/answers/templating.fr.md @@ -1,6 +1,6 @@ --- title: Templating -description: Des placeholders remplis depuis la requête, pour qu'un fichier de groupe couvre cinq cents machines — et pourquoi une valeur manquante est une erreur plutôt qu'une chaîne vide. +description: Des placeholders remplis depuis la requête, pour qu'un document de groupe couvre cinq cents machines — et pourquoi une valeur manquante est une erreur plutôt qu'une chaîne vide. sidebar: label: Templating order: 4 @@ -9,10 +9,10 @@ sidebar: # Templating Le groupement supprime la duplication entre machines d'accord. Le templating supprime la -dernière raison d'écrire un fichier par machine : les valeurs qui doivent différer. +dernière raison d'écrire un document par machine : les valeurs qui doivent différer. ```toml -# answers/groups/rack-a.toml +# answers/groups/rack-a/proxmox.toml members = ["98:fa:9b:50:d8:10", "98:fa:9b:50:d8:11", "…"] [global] @@ -22,7 +22,7 @@ fqdn = "node-{{ serial }}.example.com" filter.ID_NET_NAME_MAC = "*{{ mac }}" ``` -Cinq cents machines, un fichier. Sans cela, un nom d'hôte par machine signifie un document +Cinq cents machines, un document. Sans cela, un nom d'hôte par machine signifie un document par machine — et cinq cents documents qui diffèrent d'une ligne chacun. Les placeholders fonctionnent dans **tous** les formats : TOML, YAML, JSON, XML, kickstart, @@ -52,7 +52,7 @@ sont identiques. `{{ machine }}` est l'identifiant du *document* machine qui a matché — il n'est donc disponible que lorsque la machine a un document à elle. Une machine revendiquée par la liste -`members` d'un groupe, sans fichier à côté, n'a pas de valeur `machine` et le rendu échoue +`members` d'un groupe, sans répertoire à elle, n'a pas de valeur `machine` et le rendu échoue avec `template needs {{ machine }}, but this request carries no "machine"`. Dans un groupe, utilisez plutôt un fait de la requête : diff --git a/docs/guide/answers/templating.md b/docs/guide/answers/templating.md index 930fce7..885b2ef 100644 --- a/docs/guide/answers/templating.md +++ b/docs/guide/answers/templating.md @@ -1,6 +1,6 @@ --- title: Templating -description: Placeholders filled from the request, so one group file covers five hundred machines — and why a missing value is an error rather than an empty string. +description: Placeholders filled from the request, so one group document covers five hundred machines — and why a missing value is an error rather than an empty string. sidebar: label: Templating order: 4 @@ -9,10 +9,10 @@ sidebar: # Templating Grouping removes the duplication between machines that agree. Templating removes the last -reason to write a file per machine at all: the values that must differ. +reason to write a document per machine at all: the values that must differ. ```toml -# answers/groups/rack-a.toml +# answers/groups/rack-a/proxmox.toml members = ["98:fa:9b:50:d8:10", "98:fa:9b:50:d8:11", "…"] [global] @@ -22,7 +22,7 @@ fqdn = "node-{{ serial }}.example.com" filter.ID_NET_NAME_MAC = "*{{ mac }}" ``` -Five hundred machines, one file. Without this, a per-machine hostname means a document per +Five hundred machines, one document. Without this, a per-machine hostname means a document per machine — and five hundred documents that differ in one line each. Placeholders work in **every** format: TOML, YAML, JSON, XML, kickstart, preseed. @@ -49,7 +49,7 @@ Whitespace inside the braces is optional: `{{serial}}` and `{{ serial }}` are th `{{ machine }}` is the identifier of the machine *document* that matched — so it is only available when the machine has a document of its own. A machine claimed by a group's -`members` list, with no file next to it, has no `machine` value and rendering fails with +`members` list, with no directory of its own, has no `machine` value and rendering fails with `template needs {{ machine }}, but this request carries no "machine"`. In a group, use a request fact instead: diff --git a/docs/guide/answers/validating.fr.md b/docs/guide/answers/validating.fr.md index ca1d3de..3c5dd24 100644 --- a/docs/guide/answers/validating.fr.md +++ b/docs/guide/answers/validating.fr.md @@ -8,7 +8,7 @@ sidebar: # Valider ce qui sera servi -Avant que les réponses ne se composent, un administrateur écrivait un fichier complet et le +Avant que les réponses ne se composent, un administrateur écrivait un document complet et le validait : ```console @@ -16,8 +16,8 @@ $ proxmox-auto-install-assistant validate-answer answer.toml ``` Une fois qu'une réponse est assemblée à partir d'une chaîne de groupes, plus un document -machine, plus un remplissage de template, **le fichier que reçoit l'installateur est un -fichier que personne n'a jamais vu** — et une mauvaise fusion se manifeste par une +machine, plus un remplissage de template, **le document que reçoit l'installateur est un +document que personne n'a jamais vu** — et une mauvaise fusion se manifeste par une installation automatisée ratée à 3 h du matin. Deux sous-commandes existent pour combler ce manque, et tout changement de la fusion doit les garder fonctionnelles. @@ -59,7 +59,7 @@ Le code de sortie est 0 quand quelque chose s'est résolu, non nul quand rien ne ```console $ rescriptum check checking files:examples - 10 group(s), 8 machine file(s) + 10 group(s), 8 machine document(s) group "rhel-compute" selects on serial=7ABC* (verify with: rescriptum render --query "...") group "ubuntu-web" selects on file=user-data product=PowerEdge R6* diff --git a/docs/guide/answers/validating.md b/docs/guide/answers/validating.md index 560511e..17d1c06 100644 --- a/docs/guide/answers/validating.md +++ b/docs/guide/answers/validating.md @@ -8,14 +8,14 @@ sidebar: # Validating what will be served -Before answers composed, an admin wrote a complete file and validated it: +Before answers composed, an admin wrote a complete document and validated it: ```console $ proxmox-auto-install-assistant validate-answer answer.toml ``` Once an answer is assembled from a group chain plus a machine document plus a template -fill, **the file the installer receives is one nobody has ever seen** — and a bad merge +fill, **the document the installer receives is one nobody has ever seen** — and a bad merge surfaces as a failed unattended install at 3am. Two subcommands exist to close that gap, and any change to merging has to keep them working. @@ -57,7 +57,7 @@ have returned 404) or when rendering failed. ```console $ rescriptum check checking files:examples - 10 group(s), 8 machine file(s) + 10 group(s), 8 machine document(s) group "rhel-compute" selects on serial=7ABC* (verify with: rescriptum render --query "...") group "ubuntu-web" selects on file=user-data product=PowerEdge R6* diff --git a/docs/guide/index.fr.md b/docs/guide/index.fr.md index 33ee13f..dd8cf86 100644 --- a/docs/guide/index.fr.md +++ b/docs/guide/index.fr.md @@ -61,12 +61,12 @@ par machine. s'étranglerait avec du TOML. Donc `/rhel/ks` sert des documents `.ks` et rien d'autre, `/proxmox/answer` sert du `.toml`, `/ubuntu/` sert du YAML. La conséquence qui fait comprendre le modèle : la réponse d'une machine est spécifique au système d'exploitation -auquel elle est destinée, donc `98fa9b50d810.toml` n'est pas « cette machine » mais -*« cette machine en tant que Proxmox »* — et `98fa9b50d810.preseed` est le même matériel en -tant que Debian. Les deux existent en même temps. +auquel elle est destinée, donc `98fa9b50d810/proxmox.toml` n'est pas « cette machine » mais +*« cette machine en tant que Proxmox »* — et `98fa9b50d810/debian.preseed`, dans le même +répertoire, est le même matériel en tant que Debian. Les deux existent en même temps. → [Un document par système d'exploitation](./answers/formats.md) -**2. Une machine est revendiquée, pas cherchée.** Nommez un document d'après la MAC et il +**2. Une machine est revendiquée, pas cherchée.** Nommez un répertoire d'après la MAC et il gagne. Ou listez la machine dans les `members` d'un groupe. Ou écrivez un bloc `[match]` et laissez la machine être revendiquée pour ce qu'elle *est* — un Dell R620 dont le numéro de série commence par `7ABC`. La résolution est déterministe : nommer bat matcher, plus de @@ -74,10 +74,10 @@ critères bat moins, les égalités se départagent sur le nom trié. → [Comment une réponse est choisie](./answers/selection.md) **3. Les réponses se composent.** Une baie de machines partage tout sauf ses adresses MAC. -Mettez la partie commune dans un groupe ; une machine qui diffère reçoit un fichier +Mettez la partie commune dans un groupe ; une machine qui diffère reçoit un document contenant **seulement la différence**. Les formats structurés fusionnent vraiment — les maps clé par clé, les tableaux remplacés pour qu'une liste puisse encore être raccourcie. -Ajoutez des placeholders `{{ serial }}` et un seul fichier de groupe couvre cinq cents +Ajoutez des placeholders `{{ serial }}` et un seul document de groupe couvre cinq cents machines. → [Groupes et fusion](./answers/grouping.md) · [Templating](./answers/templating.md) @@ -90,13 +90,15 @@ validateur de l'installateur lui-même quand il est dans le PATH. ## Ce que ce n'est pas -- **Pas un serveur PXE/TFTP/DHCP.** Il répond à une seule question — *quelle configuration - reçoit cette machine ?* — et laisse le netboot à ce que vous faites déjà tourner. +- **Pas un serveur DHCP, sous aucune forme.** Ni répondeur, ni proxy, ni derrière un + drapeau. Les sites qui déploient ceci en ont déjà un, et le faire pointer vers un + serveur de démarrage est un problème résolu depuis trente ans. +- **Pas un système de gestion de configuration.** Il livre un document au moment de + l'installation et n'a ensuite plus rien à voir avec la machine — rien de ce qu'il + installe n'en dépend ensuite. - **Pas un validateur de schéma.** Il prouve que vos documents sont bien formés et fusionnent proprement. Savoir si le résultat est du *Proxmox* valide est le travail de `proxmox-auto-install-assistant`, et `check` l'appellera s'il est installé. -- **Pas un système de gestion de configuration.** Il remet un document au moment de - l'installation et n'a plus rien à voir avec la machine ensuite. ## Deux réalités de déploiement @@ -104,8 +106,8 @@ Les deux sont réelles, et la conception doit satisfaire les deux : - **Un Synology DS416j** — ARMv7, 512 Mo, DSM 7, pas de Docker. La motivation d'origine, et la raison pour laquelle c'est un binaire statique unique sans runtime ni interpréteur. -- **Un hôte de datacenter** encaissant une rafale de provisioning, avec un fichier de - réponse par machine. La raison pour laquelle il est asynchrone, borne sa propre +- **Un hôte de datacenter** encaissant une rafale de provisioning, avec un répertoire de + réponses par machine. La raison pour laquelle il est asynchrone, borne sa propre concurrence, et met en cache le listing du répertoire au lieu de le parcourir à chaque requête. @@ -120,6 +122,7 @@ propre. - [Préparer les médias d'installation](./iso.md) — l'URL à graver dans chaque ISO. - [Écrire des réponses](./answers/index.md) — sélection, formats, groupes, templating. - [L'exploiter](./operations/index.md) — déploiement, sécurité, stockage, dépannage. +- [Médias de démarrage](./operations/media.md) et [démarrage réseau](./operations/netboot.md) — servir l'installeur lui-même, pas seulement sa réponse. Vous travaillez *sur* rescriptum plutôt qu'avec ? L'espace [Développement](../development/index.md) est l'autre moitié de ce site. diff --git a/docs/guide/index.md b/docs/guide/index.md index 3ce8fe2..23dad06 100644 --- a/docs/guide/index.md +++ b/docs/guide/index.md @@ -54,12 +54,12 @@ Either way, the answer has to be chosen — and usually assembled — per machin **1. The endpoint declares the format.** A kickstart client wants kickstart and would choke on TOML. So `/rhel/ks` serves `.ks` documents and nothing else, `/proxmox/answer` serves `.toml`, `/ubuntu/` serves YAML. The consequence that makes the model click: -a machine's answer is specific to the OS it is for, so `98fa9b50d810.toml` is not "that -machine" but *"that machine as Proxmox"* — and `98fa9b50d810.preseed` is the same -hardware as Debian. Both exist at once. +a machine's answer is specific to the OS it is for, so `98fa9b50d810/proxmox.toml` is not +"that machine" but *"that machine as Proxmox"* — and `98fa9b50d810/debian.preseed`, in the +same directory, is the same hardware as Debian. Both exist at once. → [One document per operating system](./answers/formats.md) -**2. A machine is claimed, not looked up.** Name a document after the MAC and it wins. +**2. A machine is claimed, not looked up.** Name a directory after the MAC and it wins. Or list the machine in a group's `members`. Or write a `[match]` block and let the machine be claimed by what it *is* — a Dell R620 with a serial starting `7ABC`. The resolution is deterministic: naming beats matching, more criteria beats fewer, ties break @@ -67,9 +67,9 @@ on sorted name. → [How an answer is picked](./answers/selection.md) **3. Answers compose.** A rack of machines shares everything except its MAC addresses. -Put the shared part in a group; a machine that differs gets a file containing **only the +Put the shared part in a group; a machine that differs gets a document containing **only the difference**. Structured formats really merge — maps key by key, arrays replaced so a -list can still be shortened. Add `{{ serial }}` placeholders and one group file covers +list can still be shortened. Add `{{ serial }}` placeholders and one group document covers five hundred machines. → [Groups and merging](./answers/grouping.md) · [Templating](./answers/templating.md) @@ -82,13 +82,14 @@ one is on PATH. ## What it is not -- **Not a PXE/TFTP/DHCP server.** It answers one question — *what configuration does this - machine get?* — and leaves netbooting to whatever you already run. +- **Not a DHCP server, in any form.** Not a responder, not a proxy, not behind a flag. + Sites that deploy this already run one, and pointing it at a boot server is a solved + problem with thirty years of tooling. +- **Not a config management system.** It hands over a document at install time and then + has nothing more to do with the machine — nothing it installs depends on it afterwards. - **Not a schema validator.** It proves your documents are well-formed and merge cleanly. Whether the result is valid *Proxmox* is `proxmox-auto-install-assistant`'s job, and `check` will call it when it is installed. -- **Not a config management system.** It hands over a document at install time and then - has nothing more to do with the machine. ## Two deployment realities @@ -96,7 +97,7 @@ Both are real, and the design has to satisfy both: - **A Synology DS416j** — ARMv7, 512 MB, DSM 7, no Docker. The original motivation, and the reason this is a single static binary with no runtime and no interpreter. -- **A datacenter host** fielding a provisioning burst, with one answer file per machine. +- **A datacenter host** fielding a provisioning burst, with one answer directory per machine. The reason it is async, bounds its own concurrency, and caches the directory listing instead of walking it per request. @@ -110,6 +111,7 @@ nothing parsed per request — grouping is the fast path, not just the tidy one. - [Preparing installer media](./iso.md) — the URL to bake into each ISO. - [Writing answers](./answers/index.md) — selection, formats, groups, templating. - [Running it](./operations/index.md) — deployment, security, storage, troubleshooting. +- [Boot media](./operations/media.md) and [netbooting](./operations/netboot.md) — serve the installer itself, not only its answer. Working on rescriptum rather than with it? The [Development](../development/index.md) space is the other half of this site. diff --git a/docs/guide/iso.fr.md b/docs/guide/iso.fr.md index 1e8b009..bfd376f 100644 --- a/docs/guide/iso.fr.md +++ b/docs/guide/iso.fr.md @@ -106,14 +106,14 @@ Le dernier segment du chemin est disponible comme fait `file`, ce qui permet de distinguer avec un sélecteur : ```yaml -# answers/groups/ubuntu-web.yaml +# answers/groups/ubuntu-web/ubuntu.yaml match: file: "user-data" product: "PowerEdge R6*" ``` ```yaml -# answers/groups/ubuntu-meta.yaml +# answers/groups/ubuntu-meta/ubuntu.yaml match: file: "meta-data" diff --git a/docs/guide/iso.md b/docs/guide/iso.md index 4d65c5b..e40e02f 100644 --- a/docs/guide/iso.md +++ b/docs/guide/iso.md @@ -103,14 +103,14 @@ The path's last segment is available as the `file` fact, so the two are told apa selector: ```yaml -# answers/groups/ubuntu-web.yaml +# answers/groups/ubuntu-web/ubuntu.yaml match: file: "user-data" product: "PowerEdge R6*" ``` ```yaml -# answers/groups/ubuntu-meta.yaml +# answers/groups/ubuntu-meta/ubuntu.yaml match: file: "meta-data" diff --git a/docs/guide/operations/admin-api.fr.md b/docs/guide/operations/admin-api.fr.md index 281e4de..d23720f 100644 --- a/docs/guide/operations/admin-api.fr.md +++ b/docs/guide/operations/admin-api.fr.md @@ -133,9 +133,12 @@ $ curl -s -H "$AUTH" -X PUT --data-binary 'x = = 1' http://127.0.0.1:8001/machin ## Identifiants -Lettres, chiffres et `- _ . :` uniquement. Ils sont écrits comme **noms de fichiers** par -`export`, donc tout ce qui pourrait traverser un répertoire est rejeté — à la frontière de -l'API *et* dans les deux stores. +Lettres, chiffres et `- _ . :` uniquement. Ils deviennent des **noms de répertoires** sous +`export` et dans le store fichiers, donc tout ce qui pourrait traverser un répertoire est +rejeté — à la frontière de l'API *et* dans les deux stores. `groups` et `default` sont +réservés comme identifiants de machine pour la même raison : ce sont les répertoires que +l'agencement garde pour lui, et une base qui en accepterait un exporterait vers un répertoire +incapable de le contenir. ## Codes de statut diff --git a/docs/guide/operations/admin-api.md b/docs/guide/operations/admin-api.md index 1859c3d..6fe1dbc 100644 --- a/docs/guide/operations/admin-api.md +++ b/docs/guide/operations/admin-api.md @@ -131,9 +131,11 @@ $ curl -s -H "$AUTH" -X PUT --data-binary 'x = = 1' http://127.0.0.1:8001/machin ## Identifiers -Letters, digits and `- _ . :` only. They are written out as **filenames** by `export`, so -anything that could traverse a directory is rejected — at the API boundary *and* in both -stores. +Letters, digits and `- _ . :` only. They become **directory names** under `export` and in +the file store, so anything that could traverse a directory is rejected — at the API boundary +*and* in both stores. `groups` and `default` are reserved as machine ids for the same reason: +those are the directories the layout keeps for itself, and a database that accepted one would +export into a directory that cannot hold it. ## Status codes diff --git a/docs/guide/operations/index.fr.md b/docs/guide/operations/index.fr.md index b2c057d..cb8c3d7 100644 --- a/docs/guide/operations/index.fr.md +++ b/docs/guide/operations/index.fr.md @@ -25,6 +25,8 @@ le droit de servir, et à qui. - **[L'API d'administration](./admin-api.md)** — gérer les réponses en HTTP, sur son propre listener, avec une écriture qui ne peut pas casser le parc. - **[Dépannage](./troubleshooting.md)** — la ligne de log est tout le diagnostic disponible. +- [Servir les médias de démarrage](./media.md) — le noyau, l'initrd et l'image de l'installeur, depuis le même serveur. +- [Démarrer une machine par le réseau](./netboot.md) — TFTP, le chargeur, le menu, et les deux lignes de leur DHCP. ## La forme d'un déploiement diff --git a/docs/guide/operations/index.md b/docs/guide/operations/index.md index f0e378b..f116a89 100644 --- a/docs/guide/operations/index.md +++ b/docs/guide/operations/index.md @@ -25,6 +25,8 @@ it is allowed to serve and to whom. - **[The admin API](./admin-api.md)** — manage answers over HTTP, on its own listener, with a write that cannot break the fleet. - **[Troubleshooting](./troubleshooting.md)** — the log line is the whole diagnostic +- [Serving boot media](./media.md) — the installer's own kernel, initrd and image, from the same server. +- [Netbooting a machine](./netboot.md) — TFTP, the loader, the menu, and their DHCP server's two lines. story. ## The shape of a deployment diff --git a/docs/guide/operations/media.fr.md b/docs/guide/operations/media.fr.md new file mode 100644 index 0000000..168fdef --- /dev/null +++ b/docs/guide/operations/media.fr.md @@ -0,0 +1,413 @@ +--- +title: Servir les médias de démarrage +description: Servir l'installeur lui-même — noyau, initrd et image — depuis le serveur qui décide déjà la réponse, sur son propre listener. +sidebar: + label: Médias de démarrage + order: 8 +--- + +# Servir les médias de démarrage + +Une réponse dit à une machine *comment* s'installer. Elle ne dit rien de l'endroit d'où +vient l'installeur — et jusqu'ici c'était le serveur web de quelqu'un d'autre, hébergeant +des images que personne ne confrontait aux réponses écrites pour elles. + +Avec un répertoire de médias, le même serveur fait les deux. **La MAC d'une machine +choisit sa réponse *et* l'image pour laquelle cette réponse a été écrite, et les deux ne +peuvent plus diverger puisqu'un seul composant décide des deux.** + +```console +$ export RESCRIPTUM_MEDIA_DIR=/srv/media +``` + +Non défini, tout est éteint. Rien ne change pour un déploiement existant tant que vous ne +la définissez pas. + +## Où vivent les images de base + +**Aucune image d'installation n'est dans ce projet, ni dans une version publiée.** Une +ISO est l'artefact de quelqu'un d'autre, elle pèse un à quatre gigaoctets, et elle change +à son propre rythme — trois raisons distinctes pour qu'elle vive sur votre disque plutôt +que dans le nôtre. `RESCRIPTUM_MEDIA_DIR` est l'endroit où vous les gardez, et **ce +répertoire est l'archive** : ce que l'éditeur a publié, sur disque, jamais modifié +ensuite. + +Ce dernier point est une propriété, pas une promesse. Rien ici ne réécrit une image — +en préparer une produit un fichier compagnon et une injection appliquée *au fil de +l'eau* (voir [Préparer une image Proxmox](#préparer-une-image-proxmox)), de sorte que les +octets sur disque restent exactement ce que l'éditeur a publié et que leur empreinte +reste vérifiable contre le `SHA256SUMS` de l'éditeur. `media list` dit quelles entrées +sont l'archive et lesquelles en dérivent. + +## Faire entrer une image + +Trois façons, et la première est celle à privilégier. + +### Choisir dans un catalogue + +```console +$ rescriptum media sources +SOURCE NAME WHAT IT INSTALLS +proxmox-ve Proxmox VE the founding case — answers come from a file injected into the image +debian Debian netinst images; the answer is a preseed on the kernel command line +ubuntu Ubuntu LTS autoinstall, via a cloud-init datasource on the kernel command line +almalinux AlmaLinux 9 kickstart, named on the kernel command line +rocky Rocky Linux 9 kickstart, named on the kernel command line + +$ rescriptum media sources proxmox-ve +reading https://enterprise.proxmox.com/iso/SHA256SUMS … +Proxmox VE — the founding case — answers come from a file injected into the image + proxmox-ve_9.2-1.iso + proxmox-ve_9.2-1-arm64.iso + proxmox-ve_9.1-1.iso + +$ rescriptum media add --from proxmox-ve proxmox-ve_9.2-1.iso +``` + +**Rien concernant une image précise n'est stocké dans ce serveur.** Chaque catalogue nomme +l'index de sommes que l'éditeur publie déjà à côté de ses propres images, et les noms comme +les empreintes en sont lus au moment où vous demandez — la liste est donc celle que cet +éditeur a aujourd'hui, et l'empreinte est la sienne. Une table d'URL figée dans une version +publiée proposerait les images du trimestre dernier, dont certaines supprimées depuis. + +**Ce que cela vaut, dit franchement.** Prendre l'empreinte sur le serveur qui sert l'image +n'est *pas* une vérification de signature. En HTTPS cela authentifie le domaine de +l'éditeur et cela attrape un téléchargement tronqué, un miroir corrompu et un fichier qui a +changé en dessous — l'essentiel de ce qui arrive vraiment — et rien de plus. Si vous voulez +davantage, utilisez la section suivante avec une empreinte que vous avez obtenue vous-même. + +### Laisser le serveur la récupérer + +```console +$ rescriptum media add https://enterprise.proxmox.com/iso/proxmox-ve_8.4-1.iso \ + --sha256 9f86d081884c7d65… +fetching https://enterprise.proxmox.com/iso/proxmox-ve_8.4-1.iso + with curl, into /srv/media/proxmox-ve_8.4-1.iso.part +######################################################################## 100.0% +verifying 1.5G … +fetched 1.5G via curl, digest verified +``` + +Elle atterrit sous un nom en `.part` et n'est renommée qu'une fois l'empreinte +vérifiée : **un téléchargement partiel ne devient jamais une entrée du catalogue** — le +catalogue analyse ce qu'il trouve, et une ISO tronquée s'analyse comme une image inconnue +qu'une machine essaierait ensuite de démarrer. Une récupération interrompue laisse le +`.part` en place, et relancer la commande la reprend. + +`--sha256` est **obligatoire** ici, parce que rien d'autre ne vérifierait ce qui est +arrivé. Les éditeurs publient un `SHA256SUMS` à côté de l'image. Si vous voulez vraiment +vous en passer, dites `--unverified` — l'important est que sauter cette vérification soit +un acte délibéré et non le défaut, puisque cela décide ce que chaque machine du réseau +installe. + +`--as NOM.iso` choisit le nom de fichier quand l'URL n'en implique pas d'utilisable. + +::: tip Il n'y a pas de TLS dans ce binaire +rustls et un magasin de racines, c'est une quarantaine de crates et plus d'un mégaoctet +sur ARMv7, pour un travail dont chaque hôte a déjà l'outil. Donc ceci lance `curl`, ou +`wget` si c'est lui qui est installé, et le dit franchement s'il n'en trouve aucun — auquel +cas la réponse est celle ci-dessous. +::: + +### Ou la poser vous-même + +En SMB, en `scp`, depuis là où l'ISO se trouve déjà — le geste naturel sur un NAS — puis +l'enregistrer : + +```console +$ rescriptum media add /srv/media/pve-8.4.iso --sha256 9f86d081884c7d65… +hashing /srv/media/pve-8.4.iso … + 10% (152.0M of 1.5G) + … +pve-8.4 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08 + proxmox Proxmox Virtual Environment 8.4-1 + kernel /boot/linux26 + initrd /boot/initrd.img + wrote /srv/media/pve-8.4.media +``` + +`--sha256` est facultatif et mérite d'être fourni : une empreinte qui ne correspond pas, +c'est soit un téléchargement tronqué soit le mauvais fichier, et les deux installeraient +la mauvaise chose sur chaque machine qui demande. Rien n'est enregistré en cas d'écart. + +**Rien n'est copié et l'image n'est jamais modifiée.** Ce que `media add` écrit, c'est le +fichier compagnon `.media` posé à côté, qui retient l'empreinte et ce que la détection a +trouvé. C'est tout l'intérêt : hacher 1,5 Go prend près d'une minute, et le serveur ne +doit jamais passer une minute *dans* une requête. + +Une image sans compagnon apparaît quand même et est servie quand même — elle n'a +simplement pas d'empreinte à revérifier, et elle est analysée à la volée. + +## Ce qu'il sait dire d'une image + +```console +$ rescriptum media list +ID FAMILY ARCH VERSION SIZE PINNED +pve-8.4 proxmox x86_64 Proxmox Virtual Environment… 1.5G 9f86d0818 +ubuntu-24.04 ubuntu x86_64 Ubuntu-Server 24.04.1 LTS 2.1G — +gparted-1.6 unknown — GPARTED-LIVE 420.0M — +``` + +Six familles sont reconnues — Proxmox, Debian, Ubuntu, RHEL et ses dérivés, SUSE et +Fedora CoreOS — à partir d'une table de marqueurs situés dans l'image. Là où un éditeur a +laissé une chaîne de version, elle est reprise ; l'identifiant de volume sert de repli. + +**Une image que rien ne reconnaît est quand même listée et quand même servie.** Ne pas +savoir la décrire n'est pas la même chose que ne pas savoir s'en servir : elle peut être +`sanboot`ée, écrite sur une clé, ou récupérée entière par le firmware. Ce qu'elle ne peut +pas faire, c'est produire une strophe de démarrage, et le serveur le dit plutôt que de +deviner. + +## Les points d'entrée + +Le listener média a sa propre socket, sur `0.0.0.0:8001` par défaut. + +| Route | Ce qui revient | +|---|---| +| `GET /` | le catalogue en texte, ou en JSON avec `Accept: application/json` | +| `GET //iso` | l'image | +| `GET //kernel` | le noyau, diffusé **depuis l'intérieur** de l'image | +| `GET //initrd` | l'initrd, de même | +| `GET //initrd+iso` | l'initrd avec l'image ajoutée, pour les vieux chargeurs | +| `GET //file/` | n'importe quel fichier dans l'image | +| `GET /health` | `200 OK` | + +Rien n'est extrait et rien n'est décompressé. Un fichier dans une image ISO9660 est une +plage d'octets contiguë : servir `/pve-8.4/kernel` est donc un positionnement et une +longueur — le même travail de quelques kilo-octets que l'image fasse 400 Mo ou 4 Go. + +Les plages (`Range`), `ETag`, `If-Range` et `HEAD` sont tous traités, parce que les vrais +clients en ont besoin : casper d'Ubuntu et anaconda de Red Hat récupèrent tous deux par +plages, et le démarrage HTTP UEFI envoie un `HEAD` avant de récupérer quoi que ce soit. + +### Pourquoi c'est un second listener + +Ce n'est pas une préférence — trois raisons distinctes, dont une seule suffirait : + +- Le point de réponse répond sur **n'importe quel chemin**, puisque l'URL est gravée dans + une ISO. Un préfixe `/media/…` découperait un espace réservé dans un espace + délibérément ouvert. +- `RESCRIPTUM_TIMEOUT_SECS` est une échéance de connexion entière de dix secondes. Un + transfert de 1,5 Go dure quinze secondes en gigabit et deux minutes en 100 Mbit : tous + les téléchargements seraient tués en vol — et cela ressemblerait à un réseau instable, + pas à un réglage. +- Un téléchargement retient un jeton de connexion pendant des minutes. Partager ce budget + avec les réponses, c'est un déploiement qui affame ses propres installations. + +Les deux ont des budgets séparés, et un test le prouve au lieu de l'espérer : les +réponses continuent d'aboutir avec quatre transferts en cours. + +## Démarrer une machine depuis tout ça + +`media ipxe` écrit la strophe de démarrage d'une image : + +```console +$ rescriptum media ipxe pve-8.4 +#!ipxe +# Proxmox Virtual Environment 8.4-1 — generated by `rescriptum media ipxe pve-8.4`. +# An ordinary answer document: selection, layering and templating all apply. +kernel http://192.0.2.10:8001/pve-8.4/kernel ramdisk_size=16777216 rw quiet initrd=initrd.img \ + splash=silent proxmox-start-auto-installer +initrd http://192.0.2.10:8001/pve-8.4/initrd initrd.img +initrd http://192.0.2.10:8001/pve-8.4/iso proxmox.iso +boot +``` + +**Il imprime un script, il n'en installe pas.** Enregistrez-le dans le répertoire des +réponses et c'est un document de réponse ordinaire — sélectionné, superposé et +gabarisé comme n'importe quel autre : + +```console +$ rescriptum media ipxe pve-8.4 > /srv/answers/groups/rack-a/boot.ipxe +``` + +C'est bien le point. Le serveur ne devient pas malin sur le démarrage ; il gagne un +générateur, et le moteur de composition que vous avez déjà fait le reste. Un `{{ mac }}` +dans l'URL de réponse générée est rempli à chaque requête depuis les faits de la machine. + +Chaque famille reçoit ce dont elle a réellement besoin, et elles ne se ressemblent pas : + +| Famille | Comment la réponse lui parvient | +|---|---| +| Proxmox VE | dans l'image, via `auto-installer-mode.toml` — et `proxmox-start-auto-installer` sur la ligne de commande pour choisir la voie automatisée | +| Debian | `preseed/url=…` | +| Ubuntu | `ds=nocloud-net;s=…/`, d'où cloud-init récupère `user-data` *et* `meta-data` | +| Famille RHEL | `inst.ks=…` | +| SUSE | `autoyast=…` | +| Fedora CoreOS | `ignition.config.url=…` | + +Proxmox est le cas à part, et il vaut la peine de savoir pourquoi : c'est le seul qui +porte l'emplacement de la réponse *à l'intérieur de l'image* plutôt que sur la ligne de +commande du noyau. C'est aussi pour cela que c'est le seul à devoir passer une fois par +`prepare-iso` — voir [Préparer les médias d'installation](../iso.md). + +::: tip Vous avez déjà lancé `prepare-iso --pxe` ? +Cela laisse un répertoire contenant `vmlinuz`, `initrd.img` et une ISO allégée. Pointez +`RESCRIPTUM_MEDIA_DIR` dessus et cela fonctionne tel quel : l'image allégée est toujours +reconnue comme Proxmox, et le noyau et l'initrd posés à côté sont trouvés et servis. +::: + +## Préparer une image Proxmox + +Proxmox est la seule famille à porter l'*emplacement* de la réponse à l'intérieur de +l'image, dans `/auto-installer-mode.toml`. Cela imposait jusqu'ici de lancer +`proxmox-auto-install-assistant prepare-iso` ailleurs d'abord. + +```console +$ rescriptum media prepare pve-8.4 +pve-8.4-http prepared from pve-8.4 + answer http://192.0.2.10:8000/proxmox + injects /auto-installer-mode.toml (198 bytes) + image 1610612736 bytes (source 1610610688 + 2048 appended) + wrote /srv/media/pve-8.4-http.media + +Nothing was copied. Serve it as /pve-8.4-http/iso, or write it to a stick with + rescriptum media export pve-8.4-http /tmp/pve-8.4-http.iso +``` + +**Ce qui vient d'être écrit est un fichier compagnon : environ deux cents octets qui +tiennent lieu de 1,5 Go.** La source n'est jamais modifiée, jamais copiée, et son +empreinte publiée reste vérifiable. Le fichier est injecté *au fil de l'eau*, donc +changer plus tard l'URL de réponse réécrit ces deux cents octets plutôt qu'un gigaoctet — +et les deux entrées apparaissent dans `media list`, adossées à une seule image sur disque. + +`--as NOM` choisit le nom de l'entrée dérivée, et `--url`, `--cert-fingerprint` et +`--token` disent ce qui va dans le fichier. + +### Pour une clé USB + +```console +$ rescriptum media export pve-8.4-http /tmp/pve-auto.iso +``` + +Matérialise exactement ce que le listener aurait servi, par le même chemin de code. Une +clé écrite autrement serait une seconde implémentation à maintenir honnête, et l'écart ne +se verrait que sur le bureau de quelqu'un. + +### Quand il refuse + +Refuser est ici une réponse **complète**, parce que le repli tient en une commande sur +n'importe quelle Debian et que ce serveur sert très bien son résultat : + +```console +$ proxmox-auto-install-assistant prepare-iso pve.iso --fetch-from http --url … +``` + +Il refuse quand l'image n'a **ni Rock Ridge ni Joliet** — le fichier ne pourrait alors +exister que sous un nom 8.3 tronqué comme `AUTO_INS.TOM;1`, et l'installeur ne le +trouverait jamais. Il refuse une image **UDF**, parce qu'une ISO Windows ne garde ses gros +fichiers que dans l'arbre UDF et que patcher l'arbre ISO9660 produirait quelque chose qui +a l'air juste et ne l'est pas. Et il refuse quand le répertoire racine n'a **pas de mou** +dans aucun de ses secteurs : déplacer l'extent entraînerait les tables de chemins, ce qui +n'est délibérément pas fait. + +Il refuse aussi de préparer une image non-Proxmox, en nommant l'alternative : toutes les +autres familles prennent l'URL sur la ligne de commande du noyau, là où `media ipxe` la +met déjà. + +### Si la source change en dessous + +Les décalages d'injection sont calculés contre une image donnée. Une source qui aurait +changé serait patchée au mauvais endroit, produisant une image qui se monte et qui est +fausse — le fichier compagnon retient donc la taille de la source, et le catalogue refuse +quand elle ne correspond plus : + +``` + problem: pve-8.4-http.media: pve-8.4 was 1610610688 bytes when this was prepared and + is 1610612736 now. The injection offsets no longer apply — re-run `media prepare`. +``` + +## Dire au serveur son propre nom + +Dès qu'il écrit des URL dans les scripts qu'il sert, le serveur a besoin d'un nom pour +lui-même qu'une machine puisse réellement atteindre. `0.0.0.0:8001` n'en est pas un. + +```console +$ export RESCRIPTUM_PUBLIC_HOST=192.0.2.10 +``` + +**Un hôte, jamais une URL.** Pas de schéma, pas de port, pas de chemin — le serveur écrit +des URL pour deux listeners, et une valeur portant un port épinglerait chaque script +généré sur l'un d'eux. Chaque URL ajoute le port de son propre listener. Une valeur +portant l'un des trois est refusée au démarrage, en nommant lequel. + +Laissée vide, elle demande à la table de routage laquelle des adresses de cet hôte fait +face à l'extérieur — et sur un segment sans route par défaut, se rabat sur la liste des +interfaces, ce qui sur un hôte à une seule adresse n'est pas une déduction du tout. Dans +les deux cas elle **dit au démarrage ce qu'elle a retenu**, et s'il y avait quelque chose +à trancher : + +``` +RESCRIPTUM_PUBLIC_HOST is not set — using 192.0.2.10, the only address this host has. +Every generated URL will name it. +``` + +``` +warning: RESCRIPTUM_PUBLIC_HOST is not set — derived 192.0.2.10, which is what every +generated URL will name. This host also has 10.8.0.4. If the machines reach it on one of +those instead, set it explicitly. +``` + +C'est la seconde qu'il faut prendre au sérieux : une mauvaise déduction produit une machine +qui démarre, enchaîne, et se bloque sur une adresse qui n'existe pas. Nommer les autres +adresses est ce qui rend la question tranchable depuis le journal lui-même, plutôt qu'en +allant regarder l'hôte. Le NAT est le cas qu'aucune des deux lignes ne peut attraper : +l'adresse est bien celle de cet hôte, et bien celle que les machines n'atteignent pas. + +## Le garder honnête + +```console +$ rescriptum media check +checking media in /srv/media + 2 image(s), 1 verified against a recorded digest + note: ubuntu-24.04 has no recorded digest — `media add` records one + ok — everything recorded still matches +``` + +Son code de sortie est un contrat, comme celui de `check` : zéro quand tout ce qui a été +enregistré correspond toujours, un quand quelque chose a dérivé. `deploy.sh` s'y fie. + +Une image qui a changé sous une empreinte enregistrée est la seule panne qui installe +silencieusement quelque chose que personne n'a relu, donc elle est bruyante : + +``` + FAIL pve-8.4: the image no longer matches what was recorded + recorded 9f86d081884c7d65… + found 7d793037a0760186… +``` + +Ce que cela prouve, c'est **l'intégrité, pas l'authenticité** : ce qui est servi est ce +qui a été enregistré. Savoir si ce qui a été enregistré est bien ce que l'éditeur a +publié relève de ses propres signatures, et `--sha256` au moment du `media add` est +l'endroit où cette vérification se place. + +## Qui a le droit de récupérer + +Le trafic de démarrage n'est pas authentifié, et forcément : une ROM PXE n'a aucun +identifiant — la même nécessité qui gouverne déjà le point de réponse. Les contrôles sont +donc structurels : lecture seule, borné au catalogue, et aucun chemin de système de +fichiers n'est jamais construit à partir d'une requête. Plus un qui peut dire *pas vous* : + +```console +$ export RESCRIPTUM_BOOT_ALLOW=10.0.0.0/8,192.168.0.0/16 +``` + +Non définie, n'importe qui pouvant atteindre le port, ce qui sur un VLAN de +provisionnement est la configuration honnête. **Un VLAN de démarrage est la recommandation +qui fonctionne vraiment** ; voir [Sécurité](./security.md). + +## Réglages + +| Variable | Défaut | À quoi elle sert | +|---|---|---| +| `RESCRIPTUM_MEDIA_ADDR` | `0.0.0.0:8001` | Le listener | +| `RESCRIPTUM_MEDIA_TIMEOUT_SECS` | `600` | Échéance du transfert entier | +| `RESCRIPTUM_MEDIA_MAX_CONNECTIONS` | `16` | Transferts simultanés | + +Seize, c'est bas volontairement. Chaque transfert retient son jeton pendant des minutes, +et le petit bout de ce sur quoi cela doit tourner est un NAS avec un disque mécanique : +seize transferts à 64 Kio par morceau font environ deux méga-octets de tampons, une +arithmétique qui doit tenir dans 512 Mo de RAM. + +Sur une machine de datacenter, montez-la. Le point de réponse a son propre budget et +n'est touché dans aucun des deux cas. diff --git a/docs/guide/operations/media.md b/docs/guide/operations/media.md new file mode 100644 index 0000000..31d6d38 --- /dev/null +++ b/docs/guide/operations/media.md @@ -0,0 +1,401 @@ +--- +title: Serving boot media +description: Serve the installer itself — kernel, initrd and image — from the same server that decides the answer, on its own listener. +sidebar: + label: Boot media + order: 8 +--- + +# Serving boot media + +An answer tells a machine *how* to install. It says nothing about where the installer +comes from — and until now that was somebody else's web server, holding images that +nobody checked against the answers written for them. + +With a media directory, the same server does both. **A machine's MAC selects its answer +and the image that answer was written for, and the two cannot drift apart because one +component decided both.** + +```console +$ export RESCRIPTUM_MEDIA_DIR=/srv/media +``` + +Unset is the whole off switch. Nothing changes for an existing deployment until you set +it. + +## Where the base images live + +**No installer image is in this project, and none is in a release.** An ISO is somebody +else's artefact, it is one to four gigabytes, and it changes on its own schedule — three +separate reasons it belongs on your disk rather than in ours. `RESCRIPTUM_MEDIA_DIR` is +where you keep them, and **that directory is the archive**: what a vendor published, on +disk, never modified afterwards. + +That last part is a property rather than a promise. Nothing here rewrites an image — +preparing one produces a sidecar and an injection applied *on the wire* (see +[Preparing a Proxmox image](#preparing-a-proxmox-image)), so the bytes on disk stay +exactly what the vendor published and their digest stays checkable against the vendor's +own `SHA256SUMS`. `media list` says which entries are the archive and which derive from +it. + +## Getting an image in + +Three ways, and the first is the one to reach for. + +### Pick one from a catalogue + +```console +$ rescriptum media sources +SOURCE NAME WHAT IT INSTALLS +proxmox-ve Proxmox VE the founding case — answers come from a file injected into the image +debian Debian netinst images; the answer is a preseed on the kernel command line +ubuntu Ubuntu LTS autoinstall, via a cloud-init datasource on the kernel command line +almalinux AlmaLinux 9 kickstart, named on the kernel command line +rocky Rocky Linux 9 kickstart, named on the kernel command line + +$ rescriptum media sources proxmox-ve +reading https://enterprise.proxmox.com/iso/SHA256SUMS … +Proxmox VE — the founding case — answers come from a file injected into the image + proxmox-ve_9.2-1.iso + proxmox-ve_9.2-1-arm64.iso + proxmox-ve_9.1-1.iso + +$ rescriptum media add --from proxmox-ve proxmox-ve_9.2-1.iso +``` + +**Nothing about a specific image is stored in this server.** Each catalogue names the +checksum index the vendor already publishes beside its own images, and the names and +digests are read from it when you ask — so the list is whatever that vendor has today, and +the digest is theirs. A table of URLs baked into a release would be offering last quarter's +images, some of them since deleted. + +**What that is worth, said plainly.** Taking the digest from the same host that serves the +image is *not* a signature check. Over HTTPS it authenticates the vendor's domain and it +catches a truncated download, a corrupt mirror and a file that changed underneath — most of +what actually goes wrong — and nothing beyond that. If you want more, use the next section +with a digest you obtained yourself. + +### Let the server fetch it + +```console +$ rescriptum media add https://enterprise.proxmox.com/iso/proxmox-ve_8.4-1.iso \ + --sha256 9f86d081884c7d65… +fetching https://enterprise.proxmox.com/iso/proxmox-ve_8.4-1.iso + with curl, into /srv/media/proxmox-ve_8.4-1.iso.part +######################################################################## 100.0% +verifying 1.5G … +fetched 1.5G via curl, digest verified +``` + +It lands on a `.part` name and is renamed only once the digest matches, so **a partial +download never becomes a catalogue entry** — the catalogue probes whatever it finds, and +a truncated ISO probes as an unknown image a machine would then try to boot. An +interrupted fetch leaves the `.part` in place and running the command again resumes it. + +`--sha256` is **required** here, because nothing else would check what arrived. Vendors +publish a `SHA256SUMS` beside the image. If you genuinely mean to go without, say +`--unverified` — the point is that skipping it is a deliberate act rather than the +default, since this decides what every machine on the network installs. + +`--as NAME.iso` picks the filename when the URL does not imply a usable one. + +::: tip There is no TLS in this binary +rustls plus a root store is forty-odd crates and over a megabyte on ARMv7, for a job +every host already has a tool for. So this runs `curl`, or `wget` if that is what is +installed, and says plainly when it finds neither — in which case the answer is the one +below. +::: + +### Or put it there yourself + +Over SMB, over `scp`, from wherever the ISO already is — the native act on a NAS — and +then register it: + +```console +$ rescriptum media add /srv/media/pve-8.4.iso --sha256 9f86d081884c7d65… +hashing /srv/media/pve-8.4.iso … + 10% (152.0M of 1.5G) + … +pve-8.4 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08 + proxmox Proxmox Virtual Environment 8.4-1 + kernel /boot/linux26 + initrd /boot/initrd.img + wrote /srv/media/pve-8.4.media +``` + +`--sha256` is optional and worth giving: a mismatch is either a truncated download or +the wrong file, and both would install the wrong thing on every machine that asks. +Nothing is recorded when it does not match. + +**Nothing is copied and the image is never modified.** What `media add` writes is the +`.media` sidecar beside it, recording the digest and what the probe found. That is the +whole point: hashing 1.5 GB takes the better part of a minute, and the server must never +spend a minute inside a request. + +An image with no sidecar still appears and is still served — it just has no digest to +re-check, and it is probed on sight. + +## What it can tell about an image + +```console +$ rescriptum media list +ID FAMILY ARCH VERSION SIZE PINNED +pve-8.4 proxmox x86_64 Proxmox Virtual Environment… 1.5G 9f86d0818 +ubuntu-24.04 ubuntu x86_64 Ubuntu-Server 24.04.1 LTS 2.1G — +gparted-1.6 unknown — GPARTED-LIVE 420.0M — +``` + +Six families are recognised — Proxmox, Debian, Ubuntu, RHEL and its rebuilds, SUSE and +Fedora CoreOS — from a table of markers inside the image. Where a vendor left a version +string it is used; the volume identifier is the fallback. + +**An image nothing recognises is still listed and still served.** Not describable is not +the same as not usable: it can be `sanboot`ed, or written to a stick, or fetched whole by +firmware. What it cannot do is produce a boot stanza, and the server says so rather than +guessing. + +## The endpoints + +The media listener is its own socket, on `0.0.0.0:8001` by default. + +| Route | What comes back | +|---|---| +| `GET /` | the catalogue as text, or JSON with `Accept: application/json` | +| `GET //iso` | the image | +| `GET //kernel` | the kernel, streamed **from inside** the image | +| `GET //initrd` | the initrd, likewise | +| `GET //initrd+iso` | the initrd with the image appended, for old loaders | +| `GET //file/` | any file inside the image | +| `GET /health` | `200 OK` | + +Nothing is extracted and nothing is unpacked. A file in an ISO9660 image is one +contiguous run of bytes, so serving `/pve-8.4/kernel` is a seek and a length — the same +few kilobytes of work whether the image is 400 MB or 4 GB. + +Ranges, `ETag`, `If-Range` and `HEAD` are all answered, because real clients need them: +Ubuntu's casper and Red Hat's anaconda both range-fetch, and UEFI HTTP Boot sends `HEAD` +before it fetches. + +### Why it is a second listener + +Not preference — three separate reasons, any one of which would be enough: + +- The answer endpoint answers on **any path**, because the URL is baked into an ISO. A + `/media/…` prefix would carve a reserved space out of one that is deliberately open. +- `RESCRIPTUM_TIMEOUT_SECS` is a whole-connection deadline of ten seconds. A 1.5 GB + transfer is fifteen seconds on gigabit and two minutes on 100 Mbit, so every download + would be killed mid-flight — and it would look like a flaky network, not a setting. +- A download holds a connection permit for minutes. Sharing that budget with answers + means a rollout starves its own installs. + +The two have separate budgets, and a test proves it rather than hoping: answers keep +succeeding with four transfers in flight. + +## Booting a machine from it + +`media ipxe` writes the boot stanza for one image: + +```console +$ rescriptum media ipxe pve-8.4 +#!ipxe +# Proxmox Virtual Environment 8.4-1 — generated by `rescriptum media ipxe pve-8.4`. +# An ordinary answer document: selection, layering and templating all apply. +kernel http://192.0.2.10:8001/pve-8.4/kernel ramdisk_size=16777216 rw quiet initrd=initrd.img \ + splash=silent proxmox-start-auto-installer +initrd http://192.0.2.10:8001/pve-8.4/initrd initrd.img +initrd http://192.0.2.10:8001/pve-8.4/iso proxmox.iso +boot +``` + +**It prints a script; it does not install one.** Save it into the answers directory and +it is an ordinary answer document — selected, layered and templated like any other: + +```console +$ rescriptum media ipxe pve-8.4 > /srv/answers/groups/rack-a/boot.ipxe +``` + +Which is the point. The server does not become clever about booting; it gains a +generator, and the composition engine you already have does the rest. A `{{ mac }}` in +the generated answer URL is filled per request from the machine's own facts. + +Each family gets what it actually needs, and they are not alike: + +| Family | How the answer reaches it | +|---|---| +| Proxmox VE | inside the image, via `auto-installer-mode.toml` — and `proxmox-start-auto-installer` on the command line to select the automated path | +| Debian | `preseed/url=…` | +| Ubuntu | `ds=nocloud-net;s=…/`, from which cloud-init fetches `user-data` *and* `meta-data` | +| RHEL family | `inst.ks=…` | +| SUSE | `autoyast=…` | +| Fedora CoreOS | `ignition.config.url=…` | + +Proxmox is the odd one out, and it is worth knowing why: it is the only one that carries +the answer's location *inside the image* rather than on the kernel command line. That is +also why it is the only one that needs `prepare-iso` run over it once — see +[Preparing installer media](../iso.md). + +::: tip Already ran `prepare-iso --pxe`? +That leaves a directory holding `vmlinuz`, `initrd.img` and a trimmed ISO. Point +`RESCRIPTUM_MEDIA_DIR` at it and it works as-is — the trimmed image is still recognised +as Proxmox, and the kernel and initrd beside it are found and served. +::: + +## Preparing a Proxmox image + +Proxmox is the only family that carries the answer's *location* inside the image, in +`/auto-installer-mode.toml`. That used to mean running +`proxmox-auto-install-assistant prepare-iso` somewhere else first. + +```console +$ rescriptum media prepare pve-8.4 +pve-8.4-http prepared from pve-8.4 + answer http://192.0.2.10:8000/proxmox + injects /auto-installer-mode.toml (198 bytes) + image 1610612736 bytes (source 1610610688 + 2048 appended) + wrote /srv/media/pve-8.4-http.media + +Nothing was copied. Serve it as /pve-8.4-http/iso, or write it to a stick with + rescriptum media export pve-8.4-http /tmp/pve-8.4-http.iso +``` + +**What that wrote is a sidecar: about two hundred bytes standing in for 1.5 GB.** The +source is never modified, never copied, and its published digest stays verifiable. The +file is injected *on the wire*, so changing the answer URL later rewrites those two +hundred bytes rather than a gigabyte — and both entries appear in `media list`, backed by +one image on disk. + +`--as NAME` picks the derived entry's name, and `--url`, `--cert-fingerprint` and +`--token` say what goes in the file. + +### For a USB stick + +```console +$ rescriptum media export pve-8.4-http /tmp/pve-auto.iso +``` + +Materialises exactly what the listener would have served, through the same code path. A +stick written any other way would be a second implementation to keep honest, and the +difference would only show up on somebody's desk. + +### When it refuses + +Refusing is a **complete** answer here, because the fallback is one command on any Debian +box and this server is perfectly happy to serve its output: + +```console +$ proxmox-auto-install-assistant prepare-iso pve.iso --fetch-from http --url … +``` + +It refuses when the image has **neither Rock Ridge nor Joliet** — the file could then +only exist under a mangled 8.3 name like `AUTO_INS.TOM;1`, and the installer would never +find it. It refuses a **UDF** image, because a Windows ISO keeps its large files only in +the UDF tree and patching the ISO9660 tree would produce something that looks right and +is not. And it refuses when the root directory has **no slack** in any of its sectors: +relocating the extent would drag in the path tables, which is deliberately not done. + +It also refuses to prepare a non-Proxmox image, and names the alternative: every other +family takes the URL on the kernel command line, where `media ipxe` already puts it. + +### If the source changes underneath + +The injection offsets are computed against one image. A source that changed would be +patched in the wrong place, producing an image that mounts and is wrong — so the sidecar +records the source's length and the catalogue refuses when it no longer matches: + +``` + problem: pve-8.4-http.media: pve-8.4 was 1610610688 bytes when this was prepared and + is 1610612736 now. The injection offsets no longer apply — re-run `media prepare`. +``` + +## Telling the server its own name + +The moment it writes URLs into scripts, the server needs a name for itself that a machine +can actually reach. `0.0.0.0:8001` is not one. + +```console +$ export RESCRIPTUM_PUBLIC_HOST=192.0.2.10 +``` + +**A host, never a URL.** No scheme, no port, no path — the server writes URLs for two +listeners, and a value carrying one port would pin every generated script to one of them. +Each URL appends its own listener's port. A value with any of the three is refused at +startup, naming which. + +Left unset, it asks the routing table which of this host's addresses faces outward — and +on a segment with no default route, falls back to the interface list, which on a host with +one address is not a guess at all. Either way it **says at startup what it settled on**, +and whether there was anything to settle: + +``` +RESCRIPTUM_PUBLIC_HOST is not set — using 192.0.2.10, the only address this host has. +Every generated URL will name it. +``` + +``` +warning: RESCRIPTUM_PUBLIC_HOST is not set — derived 192.0.2.10, which is what every +generated URL will name. This host also has 10.8.0.4. If the machines reach it on one of +those instead, set it explicitly. +``` + +The second is the one to take seriously: a wrong guess produces a machine that boots, +chains, and hangs on an address that does not exist. Naming the alternatives is what makes +that answerable from the log itself, rather than by going to look at the host. NAT is the +case neither line can catch — the address is genuinely this host's, and genuinely not the +one the machines reach. + +## Keeping it honest + +```console +$ rescriptum media check +checking media in /srv/media + 2 image(s), 1 verified against a recorded digest + note: ubuntu-24.04 has no recorded digest — `media add` records one + ok — everything recorded still matches +``` + +Its exit code is a contract, like `check`'s: zero when everything recorded still matches, +one when something drifted. `deploy.sh` keys on it. + +An image that changed under a recorded digest is the one failure that silently installs +something nobody reviewed, so it is loud: + +``` + FAIL pve-8.4: the image no longer matches what was recorded + recorded 9f86d081884c7d65… + found 7d793037a0760186… +``` + +What this proves is **integrity, not authenticity**: what is served is what was +registered. Whether what was registered is what the vendor published is a question for +the vendor's own signatures, and `--sha256` at `media add` is where that check belongs. + +## Who may fetch + +Boot traffic is unauthenticated, and necessarily so — a PXE ROM has no credentials, the +same necessity that already governs the answer endpoint. The controls are therefore +structural: read-only, catalogue-bound, and no filesystem path is ever built from a +request. Plus one that can say *not you*: + +```console +$ export RESCRIPTUM_BOOT_ALLOW=10.0.0.0/8,192.168.0.0/16 +``` + +Unset means anyone who can reach the port, which on a provisioning VLAN is the honest +configuration. **A boot VLAN is the recommendation that actually works**; see +[Security](./security.md). + +## Tuning + +| Variable | Default | What it is for | +|---|---|---| +| `RESCRIPTUM_MEDIA_ADDR` | `0.0.0.0:8001` | The listener | +| `RESCRIPTUM_MEDIA_TIMEOUT_SECS` | `600` | Whole-transfer deadline | +| `RESCRIPTUM_MEDIA_MAX_CONNECTIONS` | `16` | Concurrent transfers | + +Sixteen is low on purpose. Each transfer holds its permit for minutes, and the small end +of what this has to run on is a NAS with one spinning disk: sixteen transfers at 64 KiB a +chunk is about two megabytes of buffers, which is arithmetic that has to hold in 512 MB +of RAM. + +On a datacenter host, raise it. The answer endpoint has its own budget and is untouched +either way. diff --git a/docs/guide/operations/netboot.fr.md b/docs/guide/operations/netboot.fr.md new file mode 100644 index 0000000..f27ee6a --- /dev/null +++ b/docs/guide/operations/netboot.fr.md @@ -0,0 +1,492 @@ +--- +title: Démarrer une machine par le réseau +description: TFTP, le chargeur, le menu — toute la chaîne de la mise sous tension à l'installation sans surveillance, avec deux options ajoutées à un serveur DHCP que vous exploitez déjà. +sidebar: + label: Démarrage réseau + order: 9 +--- + +# Démarrer une machine par le réseau + +Une machine s'allume. Quatre maillons plus tard, elle s'installe comme quelqu'un l'a +décidé — ou, si personne n'a encore rien décidé à son sujet, elle attend dans un menu où +un humain peut le faire. + +``` + mise sous tension + │ +(1) ├── le DHCP dit d'où démarrer ............ À EUX. Deux options, et nous + │ générons l'extrait qui les pose. + ▼ +(2) ├── TFTP livre un chargeur ............... À NOUS + │ un iPXE adapté à l'architecture, qui enchaîne via ${next-server} + ▼ +(3) ├── iPXE demande quoi faire .............. À NOUS + │ machine connue → sa propre réponse sans surveillance + │ machine inconnue → le menu + ▼ +(4) └── les octets arrivent .................. À NOUS + noyau, initrd, l'image elle-même — HTTP avec plages +``` + +**Le maillon 1 appartient à quelqu'un d'autre et cela ne changera pas.** rescriptum ne +parle pas DHCP du tout — ni serveur, ni proxy, ni derrière un drapeau. Les sites qui +déploient ceci en ont déjà un, et le faire pointer vers un serveur de démarrage est un +problème résolu depuis trente ans. + +## Mise en route + +```console +$ export RESCRIPTUM_MEDIA_DIR=/srv/media # les images +$ export RESCRIPTUM_BOOT_DIR=/srv/boot # les chargeurs +$ export RESCRIPTUM_PUBLIC_HOST=192.0.2.10 # ce que nommeront les scripts générés +``` + +`RESCRIPTUM_BOOT_DIR` dit où sont les chargeurs : non définie, il n'y a aucun listener +TFTP et rien sur `/boot/…`. La nommer démarre TFTP sur `0.0.0.0:69` sauf si vous dites le +contraire. + +Le port 69 est privilégié, et c'est le *seul* port privilégié que ce serveur demandera +jamais — sans répondeur DHCP, il n'y a rien après 67 ni 4011. Quatre façons de traiter la +question, toutes portables : + +```console +$ export RESCRIPTUM_USER=rescriptum # démarrer en root, lier, puis abandonner +$ setcap cap_net_bind_service=+ep rescriptum # ou n'accorder que cette capacité +$ export RESCRIPTUM_TFTP_ADDR=0.0.0.0:6969 # ou le déplacer, si leur DHCP sait le dire +$ export RESCRIPTUM_TFTP_ADDR=off # ou n'avoir aucun listener du tout +``` + +**`off` est une valeur, pas une absence** — c'est ainsi qu'on dit qu'un autre service de +cette machine livre le chargeur pendant que rescriptum sert le reste de la chaîne. Les +chargeurs restent servis en HTTP sur `/boot/…` et restent vérifiés par `boot check` ; seul +le listener disparaît. C'est un contournement de déploiement pour qui le veut, **jamais la +façon dont quoi que ce soit est livré ici** : c'est rescriptum le serveur TFTP, et une +version qui le couperait par défaut aurait cédé la chose même qu'elle est. Le [paquet +Synology](./synology.md) ouvre le port 69 avec un `setcap`. + +**Un port TFTP qu'on ne peut pas lier n'arrête pas le serveur**, et c'est le seul endroit +où la règle « un listener qui ne peut pas se lier est fatal » s'inverse dans ce projet. Le +port 69 est le seul port privilégié de la conception, donc le seul bind qui puisse échouer +pour quelque chose que personne n'a configuré — une capacité qu'une mise à jour a +discrètement perdue, le plus souvent. Les réponses sont le produit ; mourir ici ferait +échouer toutes les installations en cours pour signaler qu'un second port n'a pas pu être +ouvert. Donc il avertit, continue de servir, et `boot check` sort en non-zéro : + +```console +$ rescriptum boot check + BROKEN nothing answers on 0.0.0.0:69 and it cannot be bound either: Permission denied. + Port 69 is privileged: run as root and set RESCRIPTUM_USER to drop afterwards, or grant + the binary cap_net_bind_service with setcap — the server still answers and still serves + media, but a machine sent here by DHCP asks for a loader and gets nothing +``` + +Il demande un vrai chargeur au port plutôt que d'essayer de le lier, car lier prouve le +contraire de ce qu'on croit : un bind qui *réussit* signifie que personne n'écoute, et un +bind qui échoue ne distingue pas ce serveur d'un autre service qui squatterait le port. + +**On lie d'abord, on abandonne ensuite**, toujours. L'ordre inverse fonctionne en test +sous root et échoue au déploiement, à un redémarrage — le seul moment où personne ne +regarde. + +## Les deux lignes de leur serveur DHCP + +```console +$ rescriptum boot dhcp-snippet --format dnsmasq +# rescriptum 0.2.0 - boot handoff for 192.0.2.10 +# Architecture values are IANA option 93 codes; see docs/guide/boot/dhcp. +# Generated from the same table the TFTP server serves from. +dhcp-match=set:bios,option:client-arch,0 +dhcp-match=set:efi64,option:client-arch,7 +dhcp-match=set:efi64,option:client-arch,9 +dhcp-match=set:efiarm64,option:client-arch,11 +… +``` + +`--format` couvre `dnsmasq`, `isc`, `kea`, `powershell`, `pfsense` et `mikrotik` ; +`--one-loader` produit la forme d'une seule ligne pour un parc d'une seule architecture. + +**L'extrait et le serveur TFTP sont générés depuis une même table**, de sorte que ce que +vous collez et ce que le serveur distribue ne peuvent pas diverger. Ce qu'ils *peuvent* +faire, c'est nommer un chargeur que personne n'a encore téléchargé, et cela échoue +silencieusement au niveau de la ROM : la machine demande, ne reçoit rien, et s'arrête +sans un message sur aucune console. Une commande l'attrape : + +```console +$ rescriptum boot check +checking boot assets in /srv/boot + ok ipxe-arm64.efi (1.0M) + MISSING ipxe-undionly.kpxe — every machine the snippet sends here will ask for it, + get nothing, and stop +``` + +Son code de sortie est un contrat, comme celui de `check`. Placez-le au même endroit. + +### Quatre détails que l'extrait généré traite correctement + +Chacun est une façon d'échouer sans bruit sur le réseau de quelqu'un d'autre, et aucun +n'est évident : + +- **Le champ BOOTP `file` *et* l'option 67.** Certaines ROM ne lisent que l'un des deux, + et lequel n'est pas prévisible d'après le fournisseur. +- **Un défaut sans étiquette à la fin.** Chaque ligne d'architecture est étiquetée : une + ROM qui n'envoie pas d'option 93 ne correspondrait à rien et n'obtiendrait aucun + fichier de démarrage. +- **`HTTPClient` renvoyé dans l'option 60** pour les clients UEFI HTTP Boot. Le firmware + *filtre les offres* dessus : une réponse ne portant que l'URL est écartée, en silence, + ce qui est indiscernable d'une absence de serveur DHCP. +- **Un next-server pour ces clients aussi**, bien qu'ils récupèrent en HTTP. Sans lui, le + script embarqué du chargeur lit un `${next-server}` vide et enchaîne vers nulle part. + +::: tip Windows Server +Une stratégie DHCP **ne peut pas se conditionner sur l'option 93** — les types de +condition sont la classe fournisseur, la classe utilisateur, la MAC, l'identifiant +client, le FQDN et les informations de relais. L'architecture n'atteint une stratégie +qu'à l'intérieur de la chaîne de l'option 60, donc le PowerShell généré définit des +classes fournisseur sur `PXEClient:Arch:00007*` et y accroche les stratégies. Même +résultat, mécanisme différent, et c'est exactement le genre de chose qu'on retient à +moitié. +::: + +## Le chargeur + +TFTP livre **un fichier**, et la règle est écrite dans le code : + +> **TFTP livre le chargeur. Tout ce qui suit passe en HTTP.** + +À 1468 octets par aller-retour, TFTP déplace environ 1,4 Mo/s sur une milliseconde de +latence. Le chargeur fait un mégaoctet : deux secondes. Une image de 1,5 Go prendrait +près de vingt minutes, contre quinze secondes en HTTP sur le même câble. + +Quel chargeur dépend de ce que le firmware a annoncé : + +| Option 93 | Client | Servi | +|---|---|---| +| `0x0000` | BIOS PXE | `ipxe-undionly.kpxe` | +| `0x0007`, `0x0009` | UEFI x86-64 | `ipxe-x86_64.efi`, plus `-snp` / `-snponly` | +| `0x000b` | UEFI ARM64 | `ipxe-arm64.efi` | +| `0x0010`, `0x0013` | UEFI HTTP Boot | les mêmes fichiers, en HTTP, sans TFTP du tout | +| tout le reste | UEFI 32 bits, EBC, U-Boot | refusé, avec la raison | + +`0x0009` mérite un mot. La RFC 4578 le définissait comme « EFI x86-64 » ; le registre +IANA, réécrit par la RFC 5970, le liste comme « EBC ». Les vrais firmwares x64 envoient +l'un ou l'autre, donc les deux pointent vers x64 — une table produite depuis le seul +registre ne donnerait rien à la moitié d'un parc. + +`snponly` existe parce que la construction UEFI ordinaire ne voit pas toujours la carte +réseau. Toutes les variantes sont servies et la table choisit ; c'est précisément le +savoir qu'un exploitant ne devrait pas avoir à acquérir. + +### Se les procurer + +Chaque version publiée attache `rescriptum-boot-assets-.tar.gz`. Décompressez-le +là où le serveur peut le lire, nommez le répertoire, et vérifiez-le : + +```console +$ tar -xzf rescriptum-boot-assets-0.2.0.tar.gz -C /srv +$ export RESCRIPTUM_BOOT_DIR=/srv/rescriptum-boot-assets-0.2.0 +$ rescriptum boot check +``` + +Il contient les huit chargeurs, un `SHA256SUMS`, un `ipxe.iso` et un `ipxe.usb` +démarrables pour une machine sans ROM PXE utilisable, et un `NOTICE` — c'est iPXE, en +GPLv2, construit depuis un commit amont épinglé. **C'est un téléchargement séparé, et il +ne fait partie d'aucune archive binaire ni d'aucun `.spk`**, délibérément : des fichiers +séparés servis à côté relèvent de la simple agrégation, et `packaging/ipxe/` est l'offre +écrite qui les accompagne. + +Pour les construire vous-même à la place — le même script que la release exécute, depuis +le même épinglage : + +```console +$ packaging/ipxe/build.sh --out /srv/boot +``` + +Un chargeur venu d'ailleurs convient aussi, à condition qu'il enchaîne vers *ce* serveur +plutôt que vers Internet — voir ci-dessous pourquoi un chargeur d'origine ne le fait pas. + +## Ce qui se passe au *deuxième* démarrage + +La première question que tout le monde se pose après une installation réussie, et elle a +une vraie réponse. + +Une machine qui vient d'être installée redémarre, et si le démarrage réseau est encore +premier dans son BIOS, elle revient ici. Ce qui suit est décidé par un seul réglage : + +| `RESCRIPTUM_BOOT_UNCLAIMED` | Une machine qu'aucune réponse ne revendique | +|---|---| +| `menu` (défaut) | reçoit le menu, dont la première entrée est le disque local et dont le délai y retombe — quinze secondes, puis le disque | +| `local` | est rendue directement à son firmware, qui passe au périphérique suivant | + +**Ce sont deux lectures opposées de ce que signifie un fichier de réponse**, et le choix +appartient au déploiement. + +Avec le menu, un fichier qui revendique une machine est la façon de dire *laisse celle-ci +tranquille* — car sans lui elle atterrit dans un menu que quelqu'un pourrait cliquer. C'est +juste pendant qu'on provisionne, et c'est la thèse du projet : une machine dont personne +n'a rien décidé doit finir là où un humain peut décider. + +Avec `local`, un fichier de réponse veut dire *installe celle-ci*, et son absence est +l'état sûr. Il n'arrive rien à une machine pour laquelle vous n'avez pas écrit de fichier — +elle démarre sur son disque, à chaque fois, sans menu à cliquer par accident. C'est la +lecture dont un parc en production a besoin, et c'est celle qui passe à l'échelle : le +nombre de machines qu'on veut réinstaller est toujours plus petit que celui des autres. + +**Le bénéfice, c'est que le démarrage réseau peut rester premier dans le BIOS pour +toujours.** Réinstaller une machine devient *ajouter un fichier, redémarrer* — sans +console, sans menu de démarrage, sans toucher au matériel. Retirer le fichier est ce qui +empêche que cela se reproduise. + +```console +$ rescriptum config set RESCRIPTUM_BOOT_UNCLAIMED=local +``` + +Dans les deux cas l'identité de la machine part d'abord. Le réglage décide seulement de ce +qui arrive quand rien ne l'a revendiquée — pas s'il faut demander. + +## Installer une machine une fois, et une seule + +Une machine revendiquée par une réponse `.ipxe` s'installe, redémarre, est revendiquée de +nouveau, et se réinstalle — en effaçant son disque à chaque tour. Tous les systèmes de +provisionnement répondent pareil : une machine est *armée* pour l'installation, et quelque +chose la désarme ensuite. + +**C'est la machine qui sait.** Proxmox appelle un webhook après une installation réussie et +**avant le redémarrage**, avec ses interfaces réseau dans le corps : + +```toml +[post-installation-webhook] +url = "http://192.0.2.10:8000/installed" +auth-token = "nas:s3cr3t" +``` + +```console +$ rescriptum config set RESCRIPTUM_INSTALLED_TOKEN=nas:s3cr3t +``` + +C'est tout. La machine termine, elle le dit, et son `.ipxe` passe de `98fa9b50d810/` à +`installed-98fa9b50d810/` — un nom de répertoire qui ne lui correspond plus, le préfixe +faisant partie du nom comparé. Elle démarre sur son disque désormais, et la réarmer consiste +à remettre le document en place. + +Le document désarmé va dans un répertoire **frère** plutôt que de rester dans celui de la +machine, pour que `98fa9b50d810/` continue de vouloir dire « la configuration de cette +machine » et que rien de ce qu'il contient n'ait à se lire comme désactivé. + +**Pas de jeton, pas d'endpoint** — absent plutôt qu'ouvert. Sans lui, `/installed` est une +demande de réponse ordinaire comme n'importe quel chemin, ce qui permet à une URL de rester +gravable dans une ISO. + +Trois choses qu'il ne fait pas, et chacune est délibérée : + +- **Il ne touche jamais un groupe.** Un groupe revendique un rack entier, et une machine + qui finit son installation ne doit pas désarmer ses voisines. La recherche ne consulte + pas les groupes du tout, plutôt que de les écarter après coup. +- **Il ne touche rien d'autre que le `.ipxe`.** Le `.toml` de la machine, à côté dans le + même répertoire, est ce que l'installateur a lu pour la construire, et il reste comme + trace de la manière. +- **Il déplace, il ne supprime pas.** C'est le seul chemin où quelque chose venu du réseau + modifie le jeu de réponses : rien de ce qu'il fait n'est irréversible. + +Arriver deux fois n'est pas une erreur — un webhook peut être réessayé, et une machine +installée depuis le menu n'a jamais été revendiquée. Un désarmement qui *échoue* est +journalisé en `still armed`, parce que sa conséquence est autrement silencieuse : la +machine se réinstalle au démarrage suivant et rien d'autre ne le dirait. + +### Toutes les autres familles rapportent aussi + +Proxmox est le seul à avoir son propre webhook. **La revendication n'est pas propre à +Proxmox** — c'est un document `.ipxe`, qui concerne le chargeur et non le système +d'exploitation — donc toutes les familles ont besoin du même désarmement, et toutes ont un +endroit où lancer une ligne à la fin de leur installation : + +```bash +curl -fsS -X POST -H "Authorization: Bearer nas:s3cr3t" \ + "http://192.0.2.10:8000/installed?mac=$(cat /sys/class/net/*/address | head -1)" +``` + +Pas de corps, pas de JSON : la query dit quelle machine, l'en-tête dit qu'elle en a le +droit. Où mettre cette ligne : + +| Famille | Où | +|---|---| +| Proxmox | `[post-installation-webhook]` — natif, rien à écrire | +| Debian | `d-i preseed/late_command string in-target sh -c '…'` | +| Ubuntu | `late-commands:` dans le document autoinstall | +| RHEL, AlmaLinux, Rocky | la section `%post` du kickstart | +| SUSE | `` du profil AutoYaST | + +**Prenez la MAC de l'interface qui a démarré, pas la première par ordre alphabétique.** +L'exemple ci-dessus prend la première entrée de `/sys/class/net`, ce qui va sur une machine +à une carte et se trompe sur une machine à quatre — et une mauvaise MAC désarme la mauvaise +machine, ou personne. Sur une machine à plusieurs cartes, nommez l'interface. + +L'endpoint accepte les deux formes du justificatif : l'`auth-token` de Proxmox arrive dans +le corps JSON parce que c'est ce que Proxmox envoie, et un en-tête bearer parce que c'est +ce qu'envoie un script shell. Même secret, même comparaison en temps constant. + +## Quand tout est juste et que la machine refuse quand même de s'installer + +La chaîne peut être parfaite et échouer à la dernière marche, côté machine et non côté +serveur. Deux cas réellement rencontrés, tous deux sur un Lenovo vPro : + +**Intel AMT en adresse statique, sur une carte partagée avec le système.** Le `dhclient` de +l'installateur envoie deux requêtes à onze secondes d'intervalle puis abandonne ; si le +Management Engine tient l'interface avec une configuration statique pendant que l'hôte +demande du DHCP, ces onze secondes passent sans offre et l'installation s'arrête sur +`Fetching answer file via HTTP failed: Network is unreachable`. **Mettez l'AMT en DHCP +aussi.** Lancer `dhclient -v eno1` à la main depuis le shell de l'installateur réussit +ensuite immédiatement, et c'est ce qui rend le diagnostic déroutant : le réseau va bien, +c'est la temporisation qui ne va pas. + +**Un port de commutateur qui ne transmet pas tout de suite**, pour la même raison et avec +le même symptôme — RSTP en convergence, ou un lien encore en négociation après que le noyau +a repris la carte des mains d'iPXE. Ce serveur n'y peut rien dans les deux cas : les onze +secondes sont la fenêtre de l'installateur, pas la nôtre. + +L'installateur ouvre un shell root quand il abandonne, et ce shell est le diagnostic le +plus rapide qui soit : + +```console +# ip link # l'interface est-elle seulement montée ? +# dhclient -v eno1 # une offre revient-elle quand on la demande à la main ? +# ip addr show eno1 +``` + +Une adresse qui apparaît là et pas pendant l'installation veut dire que le réseau +fonctionne et que la machine a simplement demandé trop tôt. + +## Comment iPXE finit par parler à *nous* + +La question qu'on ne s'attend pas à devoir trancher. Quel que soit le livreur du +chargeur : + +- Un `undionly.kpxe` ordinaire venu d'ipxe.org fait du DHCP, se fait dire de charger + iPXE, et **se recharge lui-même indéfiniment** — la boucle d'enchaînement documentée + par iPXE. +- Un binaire netboot.xyz d'origine embarque un script qui va droit au menu **public** + `boot.netboot.xyz`. Pas de boucle, mais votre menu et vos réponses ne sont jamais + consultés. + +Les chargeurs que rescriptum distribue portent un script de trois lignes qui enchaîne via +`${next-server}` — la valeur que l'option 66 a déjà posée, puisque c'est ainsi que le +chargeur est arrivé. Une seule construction générique fonctionne donc dans tous les +déploiements, sans seconde condition dans un fichier de configuration qui appartient à +quelqu'un d'autre. + +Le script enchaîne vers le **port 8001**, et c'est un contrat plutôt qu'une préférence : +il est gravé dans le chargeur avant qu'aucun déploiement n'existe et ne peut lire aucune +configuration. Déplacer `RESCRIPTUM_MEDIA_ADDR` est permis, et `boot check` le signale. + +## Ce qu'une machine voit + +L'étape deux met l'identité de la machine dans la chaîne de requête, la seule chose que +DHCP ne peut pas faire — une option DHCP ne peut pas porter `${net0/mac}` : + +```console +$ rescriptum boot bootstrap +#!ipxe +chain http://192.0.2.10:8000/ipxe/boot?mac=${netX/mac}&uuid=${uuid}\ +&serial=${serial:uristring}&asset=${asset:uristring}\ +… +|| chain http://192.0.2.10:8001/ipxe/menu +``` + +Deux détails y sont porteurs. **`netX`, pas `net0`** — `net0` n'est que la première +interface, donc un serveur démarrant par son second port s'identifierait par le premier, +inutilisé. Et **`:uristring`** sur chaque chaîne SMBIOS, parce que `${manufacturer}` +s'étend en `Dell Inc.` avec l'espace et qu'iPXE n'encode rien de lui-même. + +Ce `||` final, c'est tout « un menu est la réponse par défaut » : une machine que quelque +chose réclame reçoit sa propre réponse sans surveillance, et une machine que rien ne +réclame retombe sur le menu. C'est la description de poste de `default/`, mot pour +mot, appliquée à un autre format. + +## Le menu + +```console +$ rescriptum boot menu +``` + +Rendu depuis le catalogue **au moment de la requête**, et non maintenu comme un fichier : +posez une ISO dans le répertoire de médias et elle est dans le menu à la requête +suivante. + +- **« Boot from the local disk » est en premier, et le délai y retombe.** Une machine qui + démarre en PXE par accident, et que rien ne réclame, finit sur son propre disque au + bout de quinze secondes. Elle n'attend jamais un humain qui ne vient pas, et elle + n'installe jamais rien. Avec la règle qui veut qu'une machine non réclamée reçoive un + menu plutôt qu'une installation, **le pire cas d'une erreur sur le périmètre des + machines qui atteignent ce serveur est quelques secondes ajoutées à un démarrage.** +- Les entrées sont **filtrées sur l'architecture du client** : une image ARM64 n'est pas + proposée à une machine x86 — ce serait une entrée qui démarre le mauvais noyau. +- Une image qu'aucune détection n'a su placer est quand même proposée, comme un CD. +- Les entrées de diagnostic — un shell, `netinfo`, et une qui démarre un *autre* + rescriptum — sont ce dont tout serveur de démarrage finit par avoir besoin. La dernière + sert à tester un serveur candidat sur site, depuis celui qui tourne, sans toucher au + DHCP ni aux chargeurs. + +`RESCRIPTUM_BOOT_TIMEOUT_SECS` (15 par défaut) règle l'attente, et +`RESCRIPTUM_BOOT_TITLE` la barre de titre. Le logo est récupéré par +`console --picture … ||`, qui **tolère son propre échec** : une console série via IPMI +n'a pas de framebuffer, et c'est ainsi que la moitié des installations en datacenter sont +suivies. + +## Ce qui casse quand ce serveur est arrêté + +Cela mérite d'être dit franchement, parce que « serveur de démarrage » sonne critique et +ne l'est pas : + +| | rescriptum arrêté | +|---|---| +| Adressage DHCP, DNS, routage | **inchangés** — il ne parle aucun de ces protocoles | +| Machines déjà installées et en service | **inchangées** | +| Machines qui redémarrent | **inchangées** — elles démarrent sur disque | +| Une machine qui démarre en PXE par accident | passe au périphérique suivant, comme elle l'aurait fait | +| Démarrer une *nouvelle* installation | s'arrête | + +**Rien de ce que rescriptum installe ne dépend de rescriptum ensuite.** Le point de +réponse est consulté pendant une installation et plus jamais. + +## Sécurité + +Le trafic de démarrage n'est pas authentifié, et forcément — une ROM PXE n'a aucun +identifiant, la même nécessité qui gouverne déjà le point de réponse. Les contrôles sont +donc structurels, et l'un d'eux peut dire *pas vous* : + +```console +$ export RESCRIPTUM_BOOT_ALLOW=10.0.0.0/8 # partagée par TFTP et les médias +``` + +UDP est falsifiable et TFTP est de l'UDP, donc le serveur **ne répond jamais à une +destination de diffusion ou de multidiffusion** — de l'hygiène anti-amplification plutôt +que de la politesse —, plafonne les transferts au total et par pair, et journalise chacun +d'eux. Il est en lecture seule : une requête d'écriture est refusée comme violation +d'accès, car écrire un chargeur en UDP non authentifié serait un moyen de changer ce que +démarre chaque machine du segment. + +**Un VLAN de démarrage est la recommandation honnête** et celle qui fonctionne vraiment. +Voir [Sécurité](./security.md). + +::: tip Secure Boot +Nos chargeurs ne sont pas signés, et shim ne charge que ce que la clé de son +distributeur a signé — servir un shim à côté d'un iPXE non signé n'est donc pas un +support de Secure Boot, c'est un démarrage qui s'arrête sur une erreur de signature. Ce +qui fonctionne : désactiver Secure Boot, enrôler une MOK, ou laisser le firmware démarrer +en PXE le shim et le GRUB signés *de la distribution cible*, servis par le listener média +comme n'importe quel fichier. Nous ne signons rien, ne retirons rien, et rien ici +n'affaiblit une machine dont Secure Boot est actif. +::: + +## Quand leur DHCP est vraiment intouchable + +Rien de tout cela ne coûte une ligne de code, et les trois fonctionnent : + +- **UEFI HTTP Boot avec une URL saisie dans le firmware.** Les firmwares serveur récents + permettent d'entrer directement une URL de démarrage. La chaîne commence alors sur le + listener média, sans aucune option DHCP. +- **iPXE depuis un média virtuel IPMI, une clé USB ou la ROM de la carte réseau**, + portant l'adresse de ce serveur. Une image d'un mégaoctet, montée une fois par machine. +- **dnsmasq en mode proxy-DHCP**, pour un site qui a vraiment un serveur DHCP qu'il ne + peut pas modifier. Il existe, il est mature, il tient en trois lignes de configuration, + et ce n'est pas à nous de le réécrire. Le nommer est la réponse honnête. diff --git a/docs/guide/operations/netboot.md b/docs/guide/operations/netboot.md new file mode 100644 index 0000000..7bc7a5e --- /dev/null +++ b/docs/guide/operations/netboot.md @@ -0,0 +1,466 @@ +--- +title: Netbooting a machine +description: TFTP, the loader, the menu — the whole chain from power-on to an unattended install, with two options added to a DHCP server you already run. +sidebar: + label: Netbooting + order: 9 +--- + +# Netbooting a machine + +A machine powers on. Four links later it is installing itself the way somebody decided — +or, if nobody has decided anything about it yet, sitting in a menu where a human can. + +``` + power on + │ +(1) ├── DHCP says where to boot from ......... THEIRS. Two options, and we + │ generate the snippet that sets them. + ▼ +(2) ├── TFTP hands over a loader ............. OURS + │ arch-matched iPXE, chaining through ${next-server} + ▼ +(3) ├── iPXE asks what to do ................. OURS + │ known machine → its own unattended answer + │ unknown machine → the menu + ▼ +(4) └── the bits arrive ..................... OURS + kernel, initrd, the image itself — HTTP with ranges +``` + +**Link 1 is somebody else's and stays that way.** rescriptum speaks no DHCP at all — not +as a server, not as a proxy, not behind a flag. Sites that deploy this already run one, +and pointing it at a boot server is a solved problem with thirty years of tooling. + +## Turning it on + +```console +$ export RESCRIPTUM_MEDIA_DIR=/srv/media # the images +$ export RESCRIPTUM_BOOT_DIR=/srv/boot # the loaders +$ export RESCRIPTUM_PUBLIC_HOST=192.0.2.10 # what generated scripts will name +``` + +`RESCRIPTUM_BOOT_DIR` says where the loaders are: unset, there is no TFTP listener and +nothing at `/boot/…`. Naming it starts TFTP on `0.0.0.0:69` unless you say otherwise. + +Port 69 is privileged, and it is the *only* privileged port this server ever wants — +with no DHCP responder there is nothing after 67 or 4011. Four ways to deal with it, all +portable: + +```console +$ export RESCRIPTUM_USER=rescriptum # start as root, bind, then drop +$ setcap cap_net_bind_service=+ep rescriptum # or grant just that one capability +$ export RESCRIPTUM_TFTP_ADDR=0.0.0.0:6969 # or move it, if their DHCP can say so +$ export RESCRIPTUM_TFTP_ADDR=off # or have no listener at all +``` + +**`off` is a value, not an absence** — it is how you say another daemon on this host +hands the loader over while rescriptum serves the rest of the chain. The loaders stay +served over HTTP at `/boot/…` and stay checked by `boot check`; only the listener is gone. +It is a deployment workaround for somebody who wants it, **never how anything here +ships**: rescriptum *is* the TFTP server, and a build that turned it off by default would +have traded away the thing it is for. The [Synology package](./synology.md) binds port 69 +with a `setcap`. + +**A TFTP port that cannot be bound does not stop the server**, and that is the one place +this project's "a listener that cannot bind is fatal" rule inverts. Port 69 is the only +privileged port in the design, so it is the only bind that can fail for something nobody +configured — a capability an upgrade quietly dropped, most often. Answers are the product; +dying here would fail every install in flight to report that a second port could not be +opened. So it warns, keeps serving, and `boot check` exits non-zero: + +```console +$ rescriptum boot check + BROKEN nothing answers on 0.0.0.0:69 and it cannot be bound either: Permission denied. + Port 69 is privileged: run as root and set RESCRIPTUM_USER to drop afterwards, or grant + the binary cap_net_bind_service with setcap — the server still answers and still serves + media, but a machine sent here by DHCP asks for a loader and gets nothing +``` + +It asks the port for a real loader rather than trying to bind it, because binding proves +the opposite of what it looks like: a bind that *succeeds* means nothing is listening, and +a bind that fails cannot tell this server apart from another daemon squatting the port. + +**Binding happens first and dropping second**, always. The other order works in testing +as root and fails on deployment, at a reboot, which is the one moment nobody is watching. + +## Their DHCP server's two lines + +```console +$ rescriptum boot dhcp-snippet --format dnsmasq +# rescriptum 0.2.0 - boot handoff for 192.0.2.10 +# Architecture values are IANA option 93 codes; see docs/guide/boot/dhcp. +# Generated from the same table the TFTP server serves from. +dhcp-match=set:bios,option:client-arch,0 +dhcp-match=set:efi64,option:client-arch,7 +dhcp-match=set:efi64,option:client-arch,9 +dhcp-match=set:efiarm64,option:client-arch,11 +… +``` + +`--format` covers `dnsmasq`, `isc`, `kea`, `powershell`, `pfsense` and `mikrotik`; +`--one-loader` emits the single-line form for a fleet that is all one architecture. + +**The snippet and the TFTP server are generated from one table**, so what you paste in +and what the server hands out cannot drift apart. What they *can* do is name a loader +nobody has downloaded yet, and that fails silently at the ROM — the machine asks, gets +nothing, and stops with no message on any console. One command catches it: + +```console +$ rescriptum boot check +checking boot assets in /srv/boot + ok ipxe-arm64.efi (1.0M) + MISSING ipxe-undionly.kpxe — every machine the snippet sends here will ask for it, + get nothing, and stop +``` + +Its exit code is a contract, like `check`'s. Put it in the same place. + +### Four details the generated snippet gets right + +Each is a way this fails quietly on somebody else's network, and none is obvious: + +- **Both the BOOTP `file` field and option 67.** Some ROMs read only one, and which is + not predictable from the vendor. +- **An untagged default at the end.** Every architecture line is tag-matched, so a ROM + that sends no option 93 would match nothing and get no boot file at all. +- **`HTTPClient` echoed back in option 60** for UEFI HTTP Boot clients. The firmware + *filters offers* on it: a reply carrying only the URL is discarded, silently, which is + indistinguishable from having no DHCP server. +- **A next-server for those clients too**, even though they fetch over HTTP. Without one + the loader's embedded script reads an empty `${next-server}` and chains into nowhere. + +::: tip Windows Server +A DHCP policy **cannot condition on option 93** — the condition types are vendor class, +user class, MAC, client id, FQDN and relay information. The architecture reaches a policy +only inside the option 60 string, so the generated PowerShell defines vendor classes on +`PXEClient:Arch:00007*` and hangs the policies off those. Same outcome, different +mechanism, and it is exactly the sort of thing that gets half-remembered. +::: + +## The loader + +TFTP hands over **one file**, and the rule is written into the code: + +> **TFTP hands over the loader. Everything after that is HTTP.** + +At 1468 bytes a round-trip, TFTP moves about 1.4 MB/s on a millisecond of latency. The +loader is a megabyte — two seconds. A 1.5 GB image would be the better part of twenty +minutes, against fifteen seconds over HTTP on the same wire. + +Which loader depends on what the firmware announced: + +| Option 93 | Client | Served | +|---|---|---| +| `0x0000` | BIOS PXE | `ipxe-undionly.kpxe` | +| `0x0007`, `0x0009` | UEFI x86-64 | `ipxe-x86_64.efi`, plus `-snp` / `-snponly` | +| `0x000b` | UEFI ARM64 | `ipxe-arm64.efi` | +| `0x0010`, `0x0013` | UEFI HTTP Boot | the same files, over HTTP, no TFTP at all | +| everything else | 32-bit UEFI, EBC, U-Boot | refused, with the reason | + +`0x0009` needs a word. RFC 4578 defined it as "EFI x86-64"; IANA's registry, rewritten by +RFC 5970, lists it as "EBC". Real x64 firmware sends either, so both map to x64 — a table +generated from the registry alone would hand half a fleet nothing. + +`snponly` exists because the plain UEFI build cannot always see the NIC. All the variants +are served and the table picks; this is precisely the knowledge an operator should not +have to acquire. + +### Getting them + +Every release attaches `rescriptum-boot-assets-.tar.gz`. Unpack it where the +server can read it, name the directory, and check it: + +```console +$ tar -xzf rescriptum-boot-assets-0.2.0.tar.gz -C /srv +$ export RESCRIPTUM_BOOT_DIR=/srv/rescriptum-boot-assets-0.2.0 +$ rescriptum boot check +``` + +It carries the eight loaders, a `SHA256SUMS`, a bootable `ipxe.iso` and `ipxe.usb` for a +machine with no usable PXE ROM, and a `NOTICE` — they are iPXE, GPLv2, built from a +pinned upstream commit. **They are a separate download and not part of any binary archive +or `.spk`**, deliberately: separate files served alongside is mere aggregation, and +`packaging/ipxe/` is the written offer that goes with them. + +To build them yourself instead — the same script the release runs, from the same pin: + +```console +$ packaging/ipxe/build.sh --out /srv/boot +``` + +A loader from elsewhere works too, provided it chains to *this* server rather than to the +internet — see below for why a stock one does not. + +## What happens on the *second* boot + +The first question anybody asks after a successful install, and it has a real answer. + +A machine that was just installed reboots, and if network boot is still first in its BIOS +order it arrives back here. What happens next is decided by one setting: + +| `RESCRIPTUM_BOOT_UNCLAIMED` | A machine no answer claims | +|---|---| +| `menu` (default) | gets the menu, whose first entry is the local disk and whose timeout falls through to it — fifteen seconds, then the disk | +| `local` | is handed straight back to its firmware, which moves to the next boot device | + +**These are opposite readings of what an answer file means**, and the choice belongs to the +deployment. + +With the menu, a file claiming a machine is how you say *leave this one alone* — because +without one it lands in a menu somebody could click. That is right while machines are being +provisioned, and it is the project's thesis: a machine nobody has decided anything about +should end up where a human can decide. + +With `local`, an answer file means *install this one*, and its absence is the safe state. +Nothing happens to a machine you have not written a file for — it boots its own disk, every +time, with no menu to click by accident. That is the reading a fleet in production needs, +and it is the one that scales: the number of machines you want to reinstall is always +smaller than the number you do not. + +**The payoff is that netboot can stay first in the BIOS order forever.** Reinstalling a +machine becomes *add a file, reboot* — no console, no boot menu, no hands on the hardware. +Removing the file is what stops it happening twice. + +```console +$ rescriptum config set RESCRIPTUM_BOOT_UNCLAIMED=local +``` + +Either way the machine's identity still goes up first. The setting decides only what +happens when nothing claimed it — not whether to ask. + +## Installing a machine once, and only once + +A machine claimed by an `.ipxe` answer installs, reboots, is claimed again, and installs +again — wiping its disk every time. Every provisioning system answers this the same way: a +machine is *armed* for install, and something disarms it afterwards. + +**The machine is what knows.** Proxmox calls a webhook after a successful install and +**before the reboot**, with its network interfaces in the body: + +```toml +[post-installation-webhook] +url = "http://192.0.2.10:8000/installed" +auth-token = "nas:s3cr3t" +``` + +```console +$ rescriptum config set RESCRIPTUM_INSTALLED_TOKEN=nas:s3cr3t +``` + +That is the whole of it. The machine finishes, says so, and its `.ipxe` moves from +`98fa9b50d810/` to `installed-98fa9b50d810/` — a directory name that no longer matches it, +because the prefix is part of the name that gets compared. It boots its own disk from then +on, and re-arming it is moving the document back. + +The disarmed document goes to a **sibling directory** rather than staying inside the +machine's own, so `98fa9b50d810/` keeps meaning "this machine's configuration" and nothing +in it has to be read as switched off. + +**No token, no endpoint** — absent rather than open. Without one, `/installed` is an +ordinary answer request like any other path, which is what keeps a URL bakeable into an +ISO. + +Three things it will not do, and each is deliberate: + +- **It never touches a group.** A group claims a whole rack, and one machine finishing its + install must not disarm its neighbours. The lookup does not consult groups at all rather + than filtering them out afterwards. +- **It never touches anything but the `.ipxe`.** The machine's own `.toml`, beside it in + the same directory, is what the installer read to build it, and it stays as the record of + how. +- **It moves, it does not delete.** This is the one path where something arriving over the + network changes the answer set, so nothing it does is irreversible. + +Arriving twice is not an error — a webhook may be retried, and a machine installed from +the menu was never claimed at all. A disarm that *fails* is logged as `still armed`, +because the consequence is otherwise silent: the machine reinstalls on its next boot and +nothing else would say so. + +### Every other family reports back too + +Proxmox is the only one with a webhook of its own. **The claim is not Proxmox-specific** — +it is an `.ipxe` document, which is about the loader rather than the operating system — so +every family needs the same disarm, and every family has somewhere to run one line at the +end of its install: + +```bash +curl -fsS -X POST -H "Authorization: Bearer nas:s3cr3t" \ + "http://192.0.2.10:8000/installed?mac=$(cat /sys/class/net/*/address | head -1)" +``` + +No body, no JSON: the query says which machine, the header says it is allowed. Where that +line goes: + +| Family | Where | +|---|---| +| Proxmox | `[post-installation-webhook]` — native, nothing to write | +| Debian | `d-i preseed/late_command string in-target sh -c '…'` | +| Ubuntu | `late-commands:` in the autoinstall document | +| RHEL, AlmaLinux, Rocky | the `%post` section of the kickstart | +| SUSE | `` in the AutoYaST profile | + +**Pick the booting interface's MAC, not the first one alphabetically.** The example above +takes whichever `/sys/class/net` entry comes first, which is fine on a machine with one +NIC and wrong on a machine with four — and the wrong MAC disarms the wrong machine, or +nothing at all. On a machine with several, name the interface. + +The endpoint takes the credential either way — Proxmox's `auth-token` arrives inside the +JSON body because that is what Proxmox sends, and a bearer header because that is what a +shell script sends. Same secret, same constant-time comparison. + +## When everything is right and the machine still will not install + +The chain can be perfect and fail at the last step, on the machine rather than on the +server. Two that have actually happened, both on a Lenovo with vPro: + +**Intel AMT with a static address, on a NIC it shares with the host.** The installer's own +`dhclient` sends two requests about eleven seconds apart and then gives up; if the +Management Engine holds the interface with a static configuration while the host asks for +DHCP, those eleven seconds pass with no offer and the install aborts with +`Fetching answer file via HTTP failed: Network is unreachable`. **Set AMT to DHCP too.** +Running `dhclient -v eno1` by hand from the installer's shell afterwards succeeds +immediately, which is what makes this so confusing to diagnose: the network is fine, the +timing is not. + +**A switch port that does not forward straight away**, for the same reason and with the +same symptom — RSTP converging, or a link still negotiating after the kernel takes the NIC +over from iPXE. There is nothing this server can do about either: eleven seconds is the +installer's window, not ours. + +The installer drops to a root shell when it aborts, and that shell is the fastest +diagnosis there is: + +```console +# ip link # is the interface up at all? +# dhclient -v eno1 # does an offer come back when asked by hand? +# ip addr show eno1 +``` + +An address appearing there and not during the install means the network works and the +machine simply asked too early. + +## How iPXE ends up talking to *us* + +The question nobody expects to have to answer. Whatever delivers the loader: + +- A plain `undionly.kpxe` from ipxe.org does DHCP, is told to load iPXE, and **loads + itself forever** — iPXE's documented chainloading loop. +- A stock netboot.xyz binary has an embedded script that goes straight to the **public** + `boot.netboot.xyz`. No loop, but your menu and your answers are never consulted. + +The loaders rescriptum ships carry a three-line script that chains through +`${next-server}` — the value option 66 already set, which is how the loader arrived in +the first place. That makes one generic build work in every deployment, with no second +condition in a configuration file somebody else owns. + +The script chains to **port 8001**, and that is a contract rather than a preference: it +is baked into the loader before any deployment exists and can read no configuration. +Moving `RESCRIPTUM_MEDIA_ADDR` is allowed and `boot check` warns about it. + +## What a machine sees + +Stage two puts the machine's identity in the query string, which is the one thing DHCP +cannot do — a DHCP option cannot carry `${net0/mac}`: + +```console +$ rescriptum boot bootstrap +#!ipxe +chain http://192.0.2.10:8000/ipxe/boot?mac=${netX/mac}&uuid=${uuid}\ +&serial=${serial:uristring}&asset=${asset:uristring}\ +… +|| chain http://192.0.2.10:8001/ipxe/menu +``` + +Two details in there are load-bearing. **`netX`, not `net0`** — `net0` is merely the +first interface, so a server booting from its second port would identify as its unused +first. And **`:uristring`** on every SMBIOS string, because `${manufacturer}` expands to +`Dell Inc.` with the space and iPXE percent-encodes nothing on its own. + +That final `||` is the whole of "a menu is the default answer": a machine something +claims gets its own unattended answer, and a machine nothing claims falls through to the +menu. It is `default/`'s job description word for word, applied to a different +format. + +## The menu + +```console +$ rescriptum boot menu +``` + +Rendered from the catalogue **at request time**, not kept in sync as a file: drop an ISO +in the media directory and it is in the menu on the next fetch. + +- **`Boot from the local disk` is first, and the timeout falls through to it.** A machine + that PXE-boots by accident, and that nothing claims, ends up on its own disk after + fifteen seconds. It never sits waiting for a human who is not coming, and it never + installs anything. Combined with the rule that an unclaimed machine gets a menu rather + than an install, **the worst case of being wrong about which machines reach this server + is a few seconds added to a boot.** +- Entries are **gated on the client's architecture**, so an ARM64 image is not offered to + an x86 machine — that is an entry that boots the wrong kernel. +- An image no probe could place is still offered, as a CD. +- The diagnostics entries — a shell, `netinfo`, and one that boots a *different* + rescriptum — are what every boot server ends up needing. The last is how you test a + candidate server on site, from the running one, without touching DHCP or the loaders. + +`RESCRIPTUM_BOOT_TIMEOUT_SECS` (default 15) sets the wait, and `RESCRIPTUM_BOOT_TITLE` +the title bar. The logo is fetched with `console --picture … ||`, which **tolerates its +own failure**: a serial console over IPMI has no framebuffer, and that is how half of all +datacenter installs are watched. + +## What breaks when this server is down + +Worth stating plainly, because "a boot server" sounds load-bearing and is not: + +| | rescriptum down | +|---|---| +| DHCP addressing, DNS, routing | **unaffected** — it speaks none of those protocols | +| Machines already installed and running | **unaffected** | +| Machines rebooting | **unaffected** — they boot from disk | +| A machine that PXE-boots by accident | falls to its next boot device, as it would anyway | +| Starting a *new* installation | stops | + +**Nothing rescriptum installs depends on rescriptum afterwards.** The answer endpoint is +consulted during an install and never again. + +## Security + +Boot traffic is unauthenticated, and necessarily — a PXE ROM has no credentials, the same +necessity that already governs the answer endpoint. So the controls are structural, and +one of them can say *not you*: + +```console +$ export RESCRIPTUM_BOOT_ALLOW=10.0.0.0/8 # shared by TFTP and media +``` + +UDP is forgeable and TFTP is UDP, so the server **never answers a broadcast or multicast +destination** — amplification hygiene rather than politeness — caps concurrent transfers +in total and per peer, and logs every one. It is read-only: a write request is refused as +an access violation, because writing a loader over unauthenticated UDP would be a way to +change what every machine on the segment boots. + +**A boot VLAN is the honest recommendation** and the one that actually works. See +[Security](./security.md). + +::: tip Secure Boot +Our loaders are unsigned, and shim only loads what its distro's vendor key signed — so +serving a shim beside an unsigned iPXE is not Secure Boot support, it is a boot that +stops at a signature error. What does work: turn Secure Boot off, enrol a MOK, or let +firmware PXE-boot the target distro's *own* signed shim and GRUB, served from the media +listener like any other file. We sign nothing and strip nothing, and nothing here weakens +a machine that has Secure Boot on. +::: + +## When their DHCP genuinely cannot be touched + +None of this costs a line of code, and all three work: + +- **UEFI HTTP Boot with a URL typed into firmware setup.** Modern server firmware lets + you enter a boot URL directly. The chain then starts on the media listener with no DHCP + option involved at all. +- **iPXE from IPMI virtual media, a USB stick, or the NIC's own ROM**, carrying this + server's address. A one-megabyte image, mounted once per machine. +- **dnsmasq in proxy-DHCP mode**, for a site that truly has a DHCP server it cannot edit. + It exists, it is mature, it is three lines of configuration, and it is not ours to + rewrite. Naming it is the honest answer. diff --git a/docs/guide/operations/synology.fr.md b/docs/guide/operations/synology.fr.md index 8964352..82cd2ae 100644 --- a/docs/guide/operations/synology.fr.md +++ b/docs/guide/operations/synology.fr.md @@ -60,8 +60,13 @@ puis le paquet : ## Ce que le paquet ne fait pas -Quatre choses à savoir avant qu'elles ne vous surprennent. +Cinq choses à savoir avant qu'elles ne vous surprennent. +- **Il ne peut pas ouvrir le port 69 tout seul.** DSM 7 n'autorise pas un paquet non signé + à tourner en root, donc le TFTP vous demande une commande root, une seule fois — voir + [Le TFTP demande une commande root](#le-tftp-demande-une-commande-root). Tant qu'elle + n'est pas donnée, le serveur avertit, continue de répondre et de servir les images, et + seule la livraison du chargeur est coupée. - **Il n'ouvre pas le pare-feu.** Enregistrer le port fait apparaître *rescriptum* par son nom dans l'éditeur de règles au lieu d'un numéro à taper. Si votre pare-feu est actif avec une règle par défaut qui refuse, il faut toujours créer la règle. @@ -226,6 +231,150 @@ d'environnement puis déplacez l'entrée du pare-feu, qui ne suit pas toute seul $ sudo /usr/syno/sbin/synopkghelper update rescriptum port-config ``` +## Servir les médias d'installation, et le PXE + +Le paquet sait aussi servir l'installeur lui-même — noyaux, initrds et images — depuis le +NAS qui décide déjà la réponse. C'est éteint jusqu'à ce que vous l'allumiez : + +1. Décommentez `RESCRIPTUM_MEDIA_DIR` dans le fichier d'environnement et redémarrez le + paquet. +2. Posez une ISO dans le dossier `media` du partage `rescriptum`, via File Station ou SMB. +3. Enregistrez-la, pour qu'elle soit vérifiée et analysée une fois plutôt qu'à chaque + requête : + +```console +$ rescriptum-cli media add /volume1/rescriptum/media/proxmox-ve_8.4-1.iso \ + --sha256 9f86d081884c7d65… +$ rescriptum-cli media list +``` + +Le listener média est sur le **port 8001**, déjà déclaré au pare-feu à côté du port de +réponse — il reste à créer la règle. + +**Aucune image n'est livrée avec le paquet**, et aucune ne le sera jamais : une ISO est +l'artefact de quelqu'un d'autre, elle pèse des gigaoctets, et elle évolue à son rythme. Ce +dossier est là où vous les gardez, et c'est **l'archive** — rien ici ne modifie une image +après son arrivée. Préparer une image Proxmox produit un fichier compagnon de deux cents +octets et une injection appliquée au fil de l'eau, donc les octets sur disque restent +exactement ce que Proxmox a publié et leur somme reste vérifiable contre celle de Proxmox. +Voir [Servir les médias de démarrage](./media.md). + +### Le TFTP demande une commande root + +**C'est rescriptum le serveur TFTP ici, pas DSM.** Le port 69 est privilégié et DSM 7 +refuse qu'un paquet non signé tourne en root : le paquet ne peut donc pas s'accorder le +port lui-même — mais il n'a pas besoin de root pour s'en *servir*, seulement qu'on l'y +autorise une fois : + +```console +$ sudo setcap cap_net_bind_service=+ep /volume1/@appstore/rescriptum/bin/rescriptum +$ sudo synopkg restart rescriptum +``` + +Après quoi le paquet ouvre `udp/69` sous son propre utilisateur non privilégié +`rescriptum`, à côté de 8000 et 8001. Les trois sont enregistrés auprès du pare-feu. + +**Rendez-la durable, car une mise à jour la perd.** Installer une nouvelle version remplace +le binaire, et les capacités de fichier appartiennent au fichier — elles partent donc avec +l'ancien. Panneau de configuration → **Planificateur de tâches** → Créer → Tâche déclenchée +→ Script défini par l'utilisateur, utilisateur `root`, événement **Démarrage**, avec la +ligne `setcap` comme script. Relancez-la depuis cette page après chaque mise à jour, ou +redémarrez. + +**Rien d'autre ne casse pendant ce temps.** Un port TFTP qu'on ne peut pas ouvrir est le +seul écouteur de ce serveur dont l'échec n'est pas fatal, et c'est délibéré : les réponses +sont le produit, et une mise à jour ne doit pas couper les installations d'une flotte pour +signaler qu'un second port n'a pas pu être ouvert. Ce que vous obtenez à la place, c'est un +avertissement dans le journal, une ligne `tftp:` dans l'onglet **État** du panneau de +réglages, et : + +```console +$ rescriptum-cli boot check + BROKEN nothing answers on 0.0.0.0:69 and it cannot be bound either: Permission denied. + Port 69 is privileged: run as root and set RESCRIPTUM_USER to drop afterwards, or grant + the binary cap_net_bind_service with setcap — the server still answers and still serves + media, but a machine sent here by DHCP asks for a loader and gets nothing +``` + +Notez qu'il demande un chargeur au port plutôt que d'essayer de l'ouvrir. Ouvrir le port +prouve le contraire de ce qu'on croit : une ouverture qui *réussit* signifie que personne +n'écoute. + +**Les chargeurs sont dans le paquet.** Le dossier `boot` du partage arrive rempli au +premier démarrage, et une mise à jour les rafraîchit — il n'y a pas de second +téléchargement. C'est iPXE, en GPLv2, des fichiers séparés servis à côté plutôt que soudés +dans quoi que ce soit, et le `NOTICE` posé avec eux nomme le commit amont exact dont ils +sont issus. + +```console +$ rescriptum-cli boot check + ok 0.0.0.0:69 handed over ipxe-undionly.kpxe +``` + +Les remplacer est possible, mais pas en modifiant ce dossier : une mise à jour réécrit les +noms de fichiers que ce paquet fournit. Pointez plutôt `RESCRIPTUM_BOOT_DIR` ailleurs, et +rien ici n'y écrira jamais. + +Puis faites pointer le DHCP vers ce NAS — **Panneau de configuration → Serveur DHCP → PXE** +si le NAS sert le DHCP, ou votre propre serveur avec ce qu'imprime : + +```console +$ rescriptum-cli boot dhcp-snippet --format dnsmasq +``` + +#### Si vous préférez éviter setcap + +`RESCRIPTUM_TFTP_ADDR` accepte un port non privilégié, qui ne demande aucune capacité — il +faut alors le dire à votre serveur DHCP, puisqu'une ROM PXE a 69 gravé dedans et que seul +un premier étage de chaînage peut être redirigé. Ou mettez-le à `off` et laissez un autre +service de ce NAS livrer le chargeur ; DSM a son propre serveur TFTP sous Panneau de +configuration → Services de fichiers → Avancé, pointé sur le dossier `boot` du partage. Ce +sont deux contournements pour un déploiement qui les veut, pas ce que le paquet attend. + +### L'onglet Images + +L'application a un quatrième onglet, et c'est là que les images d'installation se gèrent +sans toucher à un terminal : ce qui est présent, un catalogue où piocher, et un champ URL +pour ce que le catalogue ne propose pas. + +**Le catalogue n'est pas une liste livrée par ce paquet.** Chaque entrée nomme l'index de +sommes que l'éditeur publie déjà à côté de ses propres images ; en choisir un lit cet index +par le réseau, donc les versions proposées sont celles que l'éditeur a aujourd'hui et +l'empreinte vérifiée est la sienne. Cela veut dire aussi que cet onglet a besoin que le NAS +atteigne Internet — la seule partie de ce paquet qui en a besoin. + +Le téléchargement d'une image de 1,5 Go ne peut pas être tenu par une requête web : l'onglet +le lance et le suit. `media add` écrit dans un fichier `.part` à côté de sa destination et +ne le renomme qu'une fois l'empreinte vérifiée — la taille du fichier partiel *est* donc +l'avancement, et sa disparition *est* la fin. Fermer la fenêtre n'arrête pas le +téléchargement. + +**Préparer une image Proxmox est aussi un bouton.** Toutes les autres familles prennent +l'URL de leur réponse sur la ligne de commande du noyau : il n'y a rien à préparer, et +l'onglet le dit plutôt que de proposer une étape sans effet. + +### Un réglage qui mérite d'être rempli + +``` +RESCRIPTUM_PUBLIC_HOST=192.168.1.10 +``` + +Chaque script généré nomme cette adresse. Laissée vide, elle est déduite en interrogeant +la table de routage, et le panneau de réglages affiche ce que cela a donné plutôt qu'une +case vide — donc sur un NAS à une seule interface, il n'y a rien à remplir ici. + +**C'est le NAS à deux interfaces qui mérite la lecture.** La déduction en retient une, et +le journal de démarrage nomme les autres à côté : + +``` +warning: RESCRIPTUM_PUBLIC_HOST is not set — derived 192.168.1.10, which is what every +generated URL will name. This host also has 10.0.0.10. If the machines reach it on one of +those instead, set it explicitly. +``` + +Se tromper produit une machine qui démarre, enchaîne, et se bloque sur une adresse qui +n'existe pas — long à diagnostiquer depuis la machine. + ## Le journal `RESCRIPTUM_LOG_FILE` pointe le serveur vers diff --git a/docs/guide/operations/synology.md b/docs/guide/operations/synology.md index a34091c..079fcb8 100644 --- a/docs/guide/operations/synology.md +++ b/docs/guide/operations/synology.md @@ -57,8 +57,13 @@ and then the package: ## What the package does not do -Four things worth knowing before they surprise you. +Five things worth knowing before they surprise you. +- **It cannot bind port 69 on its own.** DSM 7 does not let an unsigned package run as + root, so TFTP takes one root command from you, once — see + [TFTP needs one root command](#tftp-needs-one-root-command). Until it is given, the + server warns, keeps answering and keeps serving media, and only the loader handoff is + down. - **It does not open the firewall.** Registering the port makes *rescriptum* appear by name in the rule editor instead of you typing a number. If your firewall is on with a default-deny rule, you still have to create the rule. @@ -210,6 +215,141 @@ the firewall entry, which does not follow by itself: $ sudo /usr/syno/sbin/synopkghelper update rescriptum port-config ``` +## Serving installer media, and PXE + +The package can also serve the installer itself — kernels, initrds and images — from the +same NAS that decides the answer. It is off until you turn it on: + +1. Uncomment `RESCRIPTUM_MEDIA_DIR` in the env file and restart the package. +2. Drop an ISO into the `rescriptum` share's `media` folder, over File Station or SMB. +3. Register it, so it is verified and probed once rather than per request: + +```console +$ rescriptum-cli media add /volume1/rescriptum/media/proxmox-ve_8.4-1.iso \ + --sha256 9f86d081884c7d65… +$ rescriptum-cli media list +``` + +The media listener is on **port 8001**, already registered with the firewall alongside +the answer port — you still have to create the rule. + +**No image ships with the package**, and none ever will: an ISO is somebody else's +artefact, gigabytes, on its own schedule. That folder is where you keep them, and it is +**the archive** — nothing here modifies an image after it lands. Preparing a Proxmox +image produces a two-hundred-byte sidecar and an injection applied on the wire, so the +bytes on disk stay exactly what Proxmox published and their checksum stays verifiable +against Proxmox's own. See [Serving boot media](./media.md). + +### TFTP needs one root command + +**rescriptum is the TFTP server here, not DSM.** Port 69 is privileged and DSM 7 refuses +to let an unsigned package run as root, so the package cannot grant itself the port — but +it does not need root to *use* it, only to be given permission once: + +```console +$ sudo setcap cap_net_bind_service=+ep /volume1/@appstore/rescriptum/bin/rescriptum +$ sudo synopkg restart rescriptum +``` + +After that the package binds `udp/69` as its own unprivileged `rescriptum` user, alongside +8000 and 8001. All three are registered with the firewall. + +**Make it durable, because an upgrade drops it.** Installing a new version replaces the +binary, and file capabilities belong to the file — so the capability goes with the old one. +Control Panel → **Task Scheduler** → Create → Triggered Task → User-defined script, user +`root`, event **Boot-up**, with the `setcap` line as the script. Run it once from that page +after every upgrade, or reboot. + +**Nothing else breaks while it is missing.** A TFTP port that cannot be bound is the one +listener in this server whose failure is not fatal, deliberately: answers are the product, +and an upgrade must not take a fleet's installs down to report that a second port could not +be opened. What you get instead is a warning in the log, a `tftp:` line in the settings +panel's **Status** tab, and: + +```console +$ rescriptum-cli boot check + BROKEN nothing answers on 0.0.0.0:69 and it cannot be bound either: Permission denied. + Port 69 is privileged: run as root and set RESCRIPTUM_USER to drop afterwards, or grant + the binary cap_net_bind_service with setcap — the server still answers and still serves + media, but a machine sent here by DHCP asks for a loader and gets nothing +``` + +Note it asks the port for a loader rather than trying to bind it. Binding proves the +opposite of what it looks like: a bind that *succeeds* means nothing is listening. + +**The loaders are in the package.** The share's `boot` folder arrives filled the first +time you start it, and an upgrade refreshes them — there is no second download. They are +iPXE, GPLv2, separate files served alongside rather than linked into anything, and the +`NOTICE` beside them names the exact upstream commit they were built from. + +```console +$ rescriptum-cli boot check + ok 0.0.0.0:69 handed over ipxe-undionly.kpxe +``` + +Replacing them is possible but not by editing that folder — an upgrade rewrites the +filenames this package ships. Point `RESCRIPTUM_BOOT_DIR` somewhere else instead, and +nothing here will ever write to it. + +Then point DHCP at this NAS — **Control Panel → DHCP Server → PXE** if the NAS serves DHCP, +or your own server with what this prints: + +```console +$ rescriptum-cli boot dhcp-snippet --format dnsmasq +``` + +#### If you would rather not use setcap + +`RESCRIPTUM_TFTP_ADDR` takes an unprivileged port, which needs no capability at all — your +DHCP server has to be told, since a PXE ROM has 69 burned into it and only a chainloading +first stage can be redirected. Or set it to `off` and let another daemon on this NAS hand +the loader over; DSM has its own TFTP server under Control Panel → File Services → +Advanced, pointed at the share's `boot` folder. Both are workarounds for a deployment that +wants them, not what the package expects. + +### The Images tab + +The application has a fourth tab, and it is where installer images are managed without +touching a terminal: what is held, a catalogue to pick from, and a URL field for anything +the catalogue does not offer. + +**The catalogue is not a list this package ships.** Each entry names the checksum index the +vendor already publishes beside its own images; picking one reads that index over the +network, so the versions offered are whatever the vendor has today and the digest that gets +verified is theirs. That also means the tab needs the NAS to reach the internet — the only +part of this package that does. + +A download of a 1.5 GB image cannot be held open by a web request, so the tab starts it and +follows it: `media add` writes into a `.part` file beside its destination and renames it +only once the digest checks out, so the partial file's size *is* the progress and its +disappearance *is* the completion. Closing the window does not stop the download. + +**Preparing a Proxmox image is a button there too.** Every other family takes its answer's +URL on the kernel command line, so there is nothing to prepare and the tab says so rather +than offering a step that would do nothing. + +### One setting worth filling in + +``` +RESCRIPTUM_PUBLIC_HOST=192.168.1.10 +``` + +Every generated script names this address. Left empty it is derived by asking the routing +table, and the settings panel shows what that came out as rather than an empty box — so on +a NAS with one interface there is nothing here to fill in. + +**A NAS with two is the case worth reading.** The derived answer is one of them, and the +startup log names the others beside it: + +``` +warning: RESCRIPTUM_PUBLIC_HOST is not set — derived 192.168.1.10, which is what every +generated URL will name. This host also has 10.0.0.10. If the machines reach it on one of +those instead, set it explicitly. +``` + +Getting it wrong produces a machine that boots, chains, and hangs on an address that does +not exist, which is a slow thing to diagnose from the machine's end. + ## The log `RESCRIPTUM_LOG_FILE` points the server at `/var/packages/rescriptum/var/rescriptum.log`, diff --git a/docs/guide/quickstart.fr.md b/docs/guide/quickstart.fr.md index df698f7..7172624 100644 --- a/docs/guide/quickstart.fr.md +++ b/docs/guide/quickstart.fr.md @@ -15,16 +15,17 @@ pouvez avoir la bonne réponse avant qu'aucune machine ne démarre. ## 1. Un répertoire et un document ```console -$ mkdir -p answers/groups +$ mkdir -p answers/groups/rack-a ``` -Le répertoire de réponses est plat. Les documents à la racine appartiennent chacun à une -machine ; `groups/` contient ceux qui sont partagés. Commencez par un groupe, puisque c'est -la forme que prend presque tout déploiement réel — une baie de machines d'accord sur tout -sauf sur les disques qu'elles ont : +**Un répertoire par identité.** Un répertoire à la racine est une machine, nommé d'après +elle ; `groups/` contient ceux qui sont partagés. Dans l'un comme dans l'autre, l'extension +nomme le format et le reste du nom de fichier n'est qu'une étiquette. Commencez par un +groupe, puisque c'est la forme que prend presque tout déploiement réel — une baie de +machines d'accord sur tout sauf sur les disques qu'elles ont : ```toml -# answers/groups/rack-a.toml +# answers/groups/rack-a/proxmox.toml members = ["98:fa:9b:50:d8:10", "98:fa:9b:50:d8:11"] [global] @@ -70,15 +71,15 @@ disk-list = ["sda", "sdb"] La première ligne part sur stderr et dit comment la réponse a été obtenue — la famille de format, quel document machine a matché, quel groupe s'est appliqué. Le document lui-même -part sur stdout, donc `render … > answer.toml` ne vous donne que le fichier. +part sur stdout, donc `render … > answer.toml` ne vous donne que le document. ## 3. Une machine qui diffère -Le second nœud de la baie a quatre disques. Il reçoit un fichier contenant **seulement la -différence**, nommé d'après sa MAC : +Le second nœud de la baie a quatre disques. Il reçoit un répertoire nommé d'après sa MAC, +contenant un document avec **seulement la différence** : ```toml -# answers/98-fa-9b-50-d8-10.toml +# answers/98-fa-9b-50-d8-10/proxmox.toml [global] fqdn = "node01.example.com" @@ -107,7 +108,7 @@ zfs.raid = "raid10" disk-list = ["sda", "sdb", "sdc", "sdd"] ``` -Le groupe d'abord, le fichier machine par-dessus, et **la machine a gagné** partout où les +Le groupe d'abord, le document propre à la machine par-dessus, et **la machine a gagné** partout où les deux étaient en désaccord. Les tables ont fusionné clé par clé ; `disk-list` a été **remplacée**, pas concaténée — une liste qui ne pourrait que grandir ne pourrait jamais être raccourcie depuis une couche supérieure. @@ -117,7 +118,7 @@ deux étaient en désaccord. Les tables ont fusionné clé par clé ; `disk-list ```console $ RESCRIPTUM_ANSWERS_DIR=answers rescriptum check checking files:answers - 1 group(s), 1 machine file(s) + 1 group(s), 1 machine document(s) note: toml answers not schema-checked — proxmox-auto-install-assistant is not on PATH ok — everything renders ``` @@ -152,8 +153,10 @@ et regardez le serveur dire ce qu'il a fait : Cette ligne est tout le diagnostic disponible quand un déploiement dérape : qui a demandé, quelle taille faisait son corps, ce qu'il a reçu, et à partir de quoi c'était construit. -Les nouveaux fichiers sont pris en compte au fur et à mesure — pas de redémarrage, pas de -signal de rechargement. +Les nouveaux documents sont pris en compte au fur et à mesure — pas de redémarrage, pas de +signal de rechargement. L'apparition ou la disparition du répertoire entier d'une machine +est vue immédiatement ; un document ajouté ou modifié *à l'intérieur* de l'un d'eux est pris +en compte en moins d'une seconde. ## À lire ensuite @@ -162,5 +165,5 @@ signal de rechargement. - **[Un document par système d'exploitation](./answers/formats.md)** — la même machine en Proxmox, en Debian, en Ubuntu, côte à côte. - **[Templating](./answers/templating.md)** — `fqdn = "node-{{ serial }}.example.com"`, pour - qu'un groupe couvre une baie sans un fichier par machine. + qu'un groupe couvre une baie sans un répertoire par machine. - **[Préparer les médias d'installation](./iso.md)** — l'URL à graver dans l'ISO, par OS. diff --git a/docs/guide/quickstart.md b/docs/guide/quickstart.md index 885faf8..23b99d1 100644 --- a/docs/guide/quickstart.md +++ b/docs/guide/quickstart.md @@ -15,16 +15,17 @@ answer right before any machine boots. ## 1. A directory and one document ```console -$ mkdir -p answers/groups +$ mkdir -p answers/groups/rack-a ``` -The answers directory is flat. Documents at the top level belong to one machine each; -`groups/` holds the shared ones. Start with a group, since that is the shape almost every -real deployment ends up with — a rack of machines that agree about everything except +**One directory per identity.** A directory at the top level is one machine, named after +it; `groups/` holds the shared ones. Inside either, the extension names the format and the +rest of the filename is just a label. Start with a group, since that is the shape almost +every real deployment ends up with — a rack of machines that agree about everything except which disks they have: ```toml -# answers/groups/rack-a.toml +# answers/groups/rack-a/proxmox.toml members = ["98:fa:9b:50:d8:10", "98:fa:9b:50:d8:11"] [global] @@ -70,15 +71,15 @@ disk-list = ["sda", "sdb"] The first line goes to stderr and says how the answer was reached — the format family, which machine document matched, which group applied. The document itself goes to stdout, -so `render … > answer.toml` gives you just the file. +so `render … > answer.toml` gives you just the document. ## 3. One machine that differs -The second node in the rack has four disks. It gets a file containing **only the -difference**, named after its MAC: +The second node in the rack has four disks. It gets a directory named after its MAC, +holding a document with **only the difference** in it: ```toml -# answers/98-fa-9b-50-d8-10.toml +# answers/98-fa-9b-50-d8-10/proxmox.toml [global] fqdn = "node01.example.com" @@ -107,7 +108,7 @@ zfs.raid = "raid10" disk-list = ["sda", "sdb", "sdc", "sdd"] ``` -The group came first, the machine file on top, and **the machine won** wherever the two +The group came first, the machine's own document on top, and **the machine won** wherever the two disagreed. Tables merged key by key; `disk-list` was **replaced**, not appended — a list that could only grow could never be shortened from a higher layer. @@ -116,7 +117,7 @@ that could only grow could never be shortened from a higher layer. ```console $ RESCRIPTUM_ANSWERS_DIR=answers rescriptum check checking files:answers - 1 group(s), 1 machine file(s) + 1 group(s), 1 machine document(s) note: toml answers not schema-checked — proxmox-auto-install-assistant is not on PATH ok — everything renders ``` @@ -151,7 +152,9 @@ and watch the server say what it did: That line is the whole diagnostic story when a rollout misbehaves: who asked, how big their body was, what they got, and what it was built from. -New files are picked up as you add them — no restart, no reload signal. +New documents are picked up as you add them — no restart, no reload signal. A machine's +whole directory appearing or leaving is noticed at once; a document added or edited *inside* +one is picked up within a second. ## What to read next @@ -160,5 +163,5 @@ New files are picked up as you add them — no restart, no reload signal. - **[One document per operating system](./answers/formats.md)** — the same machine as Proxmox, as Debian, as Ubuntu, side by side. - **[Templating](./answers/templating.md)** — `fqdn = "node-{{ serial }}.example.com"`, - so one group covers a rack without a file per machine. + so one group covers a rack without a directory per machine. - **[Preparing installer media](./iso.md)** — the URL to bake into the ISO, per OS. diff --git a/docs/guide/reference/cli.fr.md b/docs/guide/reference/cli.fr.md index 0917b90..65053f5 100644 --- a/docs/guide/reference/cli.fr.md +++ b/docs/guide/reference/cli.fr.md @@ -19,6 +19,8 @@ Sans argument, `rescriptum` lance le serveur. Tout le reste est une sous-command | `rescriptum check` | rendre tout le store configuré et signaler ce qui casse | | `rescriptum import ` | copier un répertoire de documents dans le store configuré | | `rescriptum export ` | écrire le store configuré comme un répertoire de documents | +| `rescriptum migrate []` | montrer ce que deviendrait un répertoire de réponses plat | +| `rescriptum migrate --apply` | déplacer ces documents dans un répertoire chacun | | `rescriptum config` | afficher la configuration, et d'où vient chaque valeur | | `rescriptum config --json` | la même chose, pour un panneau de réglages | | `rescriptum config --value CLÉ` | une valeur, pour un script — jamais un identifiant | @@ -75,8 +77,35 @@ $ RESCRIPTUM_STORE=sqlite RESCRIPTUM_DB_PATH=/srv/answers.db rescriptum export / ``` `import` lit un **répertoire** et écrit dans le store configuré ; `export` fait l'inverse. -L'aller-retour est identique octet pour octet. Aucun des deux ne lance `check` pour vous — la -sortie vous le dit. +L'aller-retour est identique octet pour octet, chemins compris. Aucun des deux ne lance +`check` pour vous — la sortie vous le dit. + +## `migrate` + +Les réponses étaient des fichiers à la racine du répertoire de réponses — `98fa9b50d810.toml` +à côté de `98fa9b50d810.preseed`. Elles ont désormais un répertoire chacune, et un document +resté à plat est **signalé et non servi**. Cette commande les déplace : + +```console +$ rescriptum migrate +migrating /srv/answers + 98fa9b50d810.toml -> 98fa9b50d810/proxmox.toml + 98fa9b50d810.ipxe -> 98fa9b50d810/boot.ipxe + groups/rack-a.toml -> groups/rack-a/proxmox.toml + default.toml -> default/proxmox.toml + 4 document(s) to move — nothing has been changed. Re-run with --apply. +``` + +**Elle montre par défaut et ne déplace que si on le lui demande.** Le répertoire de réponses +est ce à partir de quoi une baie s'installe ; taper la commande pour savoir ce qu'elle ferait +ne doit pas le réorganiser. + +`--apply` effectue les déplacements, chacun un `rename` dans le même répertoire, si bien +qu'aucun document n'est jamais réécrit. Si une destination est déjà prise, **rien ne bouge du +tout** — y compris les documents qui auraient pu — et les collisions sont nommées : un +répertoire à moitié migré est l'état sur lequel personne ne peut raisonner. Elle prend un +répertoire en argument, `RESCRIPTUM_ANSWERS_DIR` par défaut, et sur un répertoire déjà migré +elle dit qu'il n'y a rien à déplacer. ## `config` @@ -125,6 +154,61 @@ Contrairement à toutes les autres sous-commandes, celle-ci fonctionne quand la est trop cassée pour démarrer un serveur — un fichier qui ne parse pas, un jeton d'un caractère trop court. C'est l'état dont on se sert d'elle pour *sortir*. +## `media` + +Les médias de démarrage : les images d'installation que ce serveur détient. Chacune de +ces commandes exige `RESCRIPTUM_MEDIA_DIR` ; sans elle, elles le disent et sortent en +`1`. Voir [Servir les médias de démarrage](../operations/media.md). + +```console +$ rescriptum media list # ce qui est détenu : famille, architecture, version, empreinte +$ rescriptum media add FILE [--sha256 D] # enregistrer une image déjà dans le répertoire +$ rescriptum media add URL --sha256 D # la récupérer dedans, puis l'enregistrer +$ rescriptum media check # revérifier chaque empreinte enregistrée +$ rescriptum media ipxe ID # imprimer la réponse .ipxe qui démarre une image +$ rescriptum media prepare ID [--url URL] # une image Proxmox avec son URL de réponse dedans +$ rescriptum media export ID FICHIER # matérialiser une entrée préparée, pour une clé +``` + +`media add` prend un fichier **déjà dans le répertoire de médias** — rien n'est +téléchargé et rien n'est copié. Il le hache avec une progression, l'analyse, et écrit un +fichier compagnon `.media` à côté. `--sha256` est vérifié avant tout enregistrement : +un écart n'écrit rien et sort en `1`. + +Le code de sortie de `media check` est un contrat, comme celui de `check`. `deploy.sh` +s'y fie. + +`media ipxe` imprime sur **stdout** et met tout le reste sur stderr, de sorte que +`rescriptum media ipxe pve-8.4 > groups/rack-a/boot.ipxe` produit un document de réponse +utilisable — ce qu'il est, rien de plus. Il imprime un script, il n'en installe pas. + +## `boot` + +La moitié démarrage réseau : les chargeurs de TFTP, la configuration DHCP générée, et les +deux scripts qu'une machine exécute. Voir +[Démarrer une machine par le réseau](../operations/netboot.md). + +```console +$ rescriptum boot dhcp-snippet [--format F] [--one-loader] +$ rescriptum boot check # les chargeurs qu'un extrait nomme sont-ils sur le disque ? +$ rescriptum boot bootstrap # imprimer le script de l'étape deux +$ rescriptum boot menu # imprimer le menu intégré +``` + +`--format` vaut `dnsmasq` (par défaut), `isc`, `kea`, `powershell`, `pfsense` ou +`mikrotik`. L'extrait part sur **stdout** et les avertissements sur stderr, de sorte que +`boot dhcp-snippet > dhcpd.conf` produit un fichier incluable tel quel. + +Le code de sortie de `boot check` est un contrat, comme celui de `check`. Ce qu'il +attrape est la panne la moins diagnosticable de la chaîne : **un extrait nommant un +chargeur absent du disque échoue silencieusement au niveau de la ROM**, sans rien sur +aucune console. Il signale aussi que le listener média a quitté le port que les chargeurs +distribués ont gravé. + +`boot bootstrap` et `boot menu` impriment ce qu'une machine exécutera, pour la même +raison que `render` imprime une réponse : tout ce qu'une baie exécute devrait d'abord +être lisible par un humain. + ## Codes de sortie | Code | Signifie | diff --git a/docs/guide/reference/cli.md b/docs/guide/reference/cli.md index b204d6d..31d5057 100644 --- a/docs/guide/reference/cli.md +++ b/docs/guide/reference/cli.md @@ -19,6 +19,8 @@ With no arguments, `rescriptum` runs the server. Everything else is a subcommand | `rescriptum check` | render everything in the configured store and report what breaks | | `rescriptum import ` | copy a directory of documents into the configured store | | `rescriptum export ` | write the configured store out as a directory of documents | +| `rescriptum migrate []` | show what a flat answers directory would become | +| `rescriptum migrate --apply` | move those documents into a directory each | | `rescriptum config` | show the configuration, and where each value comes from | | `rescriptum config --json` | the same, for a settings panel | | `rescriptum config --value KEY` | one value, for a script — never a credential | @@ -75,8 +77,34 @@ $ RESCRIPTUM_STORE=sqlite RESCRIPTUM_DB_PATH=/srv/answers.db rescriptum export / ``` `import` reads a **directory** and writes into the configured store; `export` does the -reverse. The round trip is byte-identical. Neither runs `check` for you — the output says -to. +reverse. The round trip is byte-identical, paths included. Neither runs `check` for you — +the output says to. + +## `migrate` + +Answers used to be files at the top of the answers directory — `98fa9b50d810.toml` beside +`98fa9b50d810.preseed`. They are now a directory each, and a document left flat is +**reported and not served**. This moves them: + +```console +$ rescriptum migrate +migrating /srv/answers + 98fa9b50d810.toml -> 98fa9b50d810/proxmox.toml + 98fa9b50d810.ipxe -> 98fa9b50d810/boot.ipxe + groups/rack-a.toml -> groups/rack-a/proxmox.toml + default.toml -> default/proxmox.toml + 4 document(s) to move — nothing has been changed. Re-run with --apply. +``` + +**It shows by default and moves only when told to.** The answers directory is what a rack +installs from; typing the command to find out what it would do must not rearrange it. + +`--apply` performs the moves, each a `rename` within the same directory, so no document is +ever rewritten. If any destination is already taken, **nothing moves at all** — including +the documents that could have — and the collisions are named: a half-migrated directory is +the state nobody can reason about. It takes a directory as an argument, defaulting to +`RESCRIPTUM_ANSWERS_DIR`, and running it on an already-migrated directory says there is +nothing to move. ## `config` @@ -123,6 +151,57 @@ Unlike every other subcommand, this one works when the configuration is too brok a server — a file that will not parse, a token one character short. That is the state people run it *to get out of*. +## `media` + +Boot media: the installer images this server holds. Every one of these needs +`RESCRIPTUM_MEDIA_DIR`; without it they say so and exit `1`. See +[Serving boot media](../operations/media.md). + +```console +$ rescriptum media list # what is held: family, architecture, version, digest +$ rescriptum media add FILE [--sha256 D] # register one already in the directory +$ rescriptum media add URL --sha256 D # fetch one into it, then register it +$ rescriptum media check # re-verify every recorded digest +$ rescriptum media ipxe ID # print the .ipxe answer that boots one image +$ rescriptum media prepare ID [--url URL] # a Proxmox image with its answer URL inside +$ rescriptum media export ID FILE # materialise a prepared entry, for a stick +``` + +`media add` takes a file **already inside the media directory** — nothing is downloaded +and nothing is copied. It hashes it with progress, probes it, and writes a `.media` +sidecar beside it. `--sha256` is checked before anything is recorded: a mismatch writes +nothing and exits `1`. + +`media check`'s exit status is a contract, like `check`'s. `deploy.sh` keys on it. + +`media ipxe` prints to **stdout** and puts everything else on stderr, so +`rescriptum media ipxe pve-8.4 > groups/rack-a/boot.ipxe` produces a usable answer document — +which is all it is. It prints a script; it does not install one. + +## `boot` + +The netboot half: TFTP's loaders, the generated DHCP configuration, and the two scripts +a machine executes. See [Netbooting a machine](../operations/netboot.md). + +```console +$ rescriptum boot dhcp-snippet [--format F] [--one-loader] +$ rescriptum boot check # are the loaders a snippet names actually on disk? +$ rescriptum boot bootstrap # print the stage-two script +$ rescriptum boot menu # print the built-in menu +``` + +`--format` is `dnsmasq` (the default), `isc`, `kea`, `powershell`, `pfsense` or +`mikrotik`. The snippet goes to **stdout** and warnings to stderr, so +`boot dhcp-snippet > dhcpd.conf` produces a file that can be included as-is. + +`boot check`'s exit status is a contract, like `check`'s. What it catches is the least +diagnosable failure in the chain: **a snippet naming a loader that is not on disk fails +silently at the ROM**, with nothing on any console. It also warns when the media listener +has moved off the port shipped loaders embed. + +`boot bootstrap` and `boot menu` print what a machine will execute, for the same reason +`render` prints an answer: everything a rack runs should be readable by a human first. + ## Exit statuses | Status | Means | diff --git a/docs/guide/reference/configuration.fr.md b/docs/guide/reference/configuration.fr.md index e7199e7..2a6410e 100644 --- a/docs/guide/reference/configuration.fr.md +++ b/docs/guide/reference/configuration.fr.md @@ -29,6 +29,22 @@ pas de *format* de configuration à apprendre ni de ligne de commande à se trom | `RESCRIPTUM_CAPTURE_DIR` | non défini | Enregistre les corps de requête ici. Non défini = pas de capture | | `RESCRIPTUM_LOG` | `all` | `all`, `problems` ou `off` — voir [plus bas](#journalisation) | | `RESCRIPTUM_LOG_FILE` | non défini | Un fichier où ajouter, ou `stdout` / `stderr`. Non défini = stderr | +| `RESCRIPTUM_MEDIA_DIR` | non défini | Images d'installation. **Non défini = pas de média et pas de listener média** | +| `RESCRIPTUM_MEDIA_ADDR` | `0.0.0.0:8001` | Le listener média, quand un répertoire de médias existe | +| `RESCRIPTUM_MEDIA_TIMEOUT_SECS` | `600` | Échéance du transfert entier. Volontairement pas les 10 s du point de réponse | +| `RESCRIPTUM_MEDIA_MAX_CONNECTIONS` | `16` | Transferts simultanés. Bas exprès : chacun retient son jeton des minutes durant | +| `RESCRIPTUM_PUBLIC_HOST` | déduit | L'hôte que nomment les URL générées. **Un hôte, jamais une URL** | +| `RESCRIPTUM_BOOT_ALLOW` | non défini | CIDR clients autorisés à récupérer les médias. Non défini = quiconque atteint le port | +| `RESCRIPTUM_BOOT_DIR` | non défini | Chargeurs et menus, distribués en TFTP. **Non défini = pas de TFTP du tout** | +| `RESCRIPTUM_TFTP_ADDR` | `0.0.0.0:69` | Le listener TFTP, ou **`off`** pour aucun. Le port 69 est privilégié ; voir `RESCRIPTUM_USER` | +| `RESCRIPTUM_TFTP_PORT_RANGE` | non défini | Les ports depuis lesquels un transfert répond, en `premier-dernier`. **Un transfert TFTP quitte le port 69 aussitôt** — le serveur répond depuis un port neuf et le client acquitte vers celui-là — donc un pare-feu n'autorisant que 69 jette l'acquittement, et la machine semble s'être désintéressée. Épinglez la plage pour pouvoir l'ouvrir. Non définie, le noyau choisit | +| `RESCRIPTUM_TFTP_BLKSIZE` | `1468` | Le plus grand bloc TFTP accepté. 1468 remplit **exactement** un chemin de 1500 octets — 1468 de charge, 4 TFTP, 8 UDP, 20 IP — donc un tag VLAN ou un tunnel rend la trame trop grande et une ROM PXE s'arrête en général sans rien dire. À baisser (1400, ou 512) quand un démarrage cale au premier bloc | +| `RESCRIPTUM_BOOT_TIMEOUT_SECS` | `15` | Secondes avant que le menu ne retombe sur le disque local | +| `RESCRIPTUM_BOOT_UNCLAIMED` | `menu` | Ce que reçoit une machine qu'aucune réponse ne revendique. `local` la rend à son firmware, ce qui inverse le sens d'un fichier de réponse : présent veut dire *installe celle-ci* plutôt que *laisse celle-ci tranquille* | +| `RESCRIPTUM_INSTALLED_TOKEN` | non défini | Le jeton du `[post-installation-webhook]` de Proxmox. Défini, `POST /installed` existe et retire la revendication d'installation d'une machine quand elle signale sa réussite. **Non défini, il n'y a pas d'endpoint** | +| `RESCRIPTUM_BOOT_LOGO` | intégré | Un PNG à afficher derrière le menu | +| `RESCRIPTUM_BOOT_TITLE` | intégré | La barre de titre du menu | +| `RESCRIPTUM_USER` / `_GROUP` | non défini | Basculer dessus **après** avoir lié. L'ordre inverse échoue au déploiement | `/srv` est l'endroit où la norme de hiérarchie des fichiers range les données servies par le système, ce qu'est précisément un répertoire de réponses. Les deux valeurs par défaut y vivent, @@ -158,6 +174,12 @@ Celles-ci arrêtent le serveur au lieu d'avertir, parce que démarrer quand mêm | `RESCRIPTUM_ADMIN_TOKEN` de moins de 16 caractères | assez court pour être deviné | | L'adresse d'écoute ne peut pas être bindée | rien à faire | | Le store ne peut pas être ouvert | rien à servir | +| `RESCRIPTUM_MEDIA_ADDR` défini sans `RESCRIPTUM_MEDIA_DIR` | un listener sans rien à servir | +| `RESCRIPTUM_MEDIA_ADDR` égal à l'adresse de réponse ou d'administration | le second bind perd, et lequel dépend de l'ordre de démarrage | +| `RESCRIPTUM_PUBLIC_HOST` portant un schéma, un port ou un chemin | il est écrit dans les URL de deux listeners ; un port dans la valeur épingle chaque script généré sur l'un d'eux | +| `RESCRIPTUM_TFTP_ADDR` défini sans `RESCRIPTUM_BOOT_DIR` | un listener sans chargeur à distribuer | +| Le répertoire de démarrage ne peut pas être résolu | chaque contrôle de chemin s'y compare | +| `RESCRIPTUM_USER` nomme un compte inexistant | rien à devenir | ## Avertissements de démarrage @@ -171,12 +193,26 @@ Ceux-ci sont affichés et le serveur continue : | API d'administration hors boucle locale | `warning: the admin API is not bound to loopback — …` | | `RESCRIPTUM_ANSWER_TOKEN` de moins de 16 caractères | un avertissement, **pas** une erreur — refuser de démarrer laisserait un parc incapable de s'installer | | Tout problème dans le jeu de réponses | une ligne `warning:` chacun, le même jeu que signale `check` | +| `RESCRIPTUM_PUBLIC_HOST` non défini | La réponse de la table de routage, ou l'unique adresse d'interface s'il n'y a pas de route par défaut. Journalisé dans les deux cas, en avertissement **nommant les autres adresses** s'il y en a. Un hôte derrière du NAT se trompe toujours en silence | +| TFTP ne peut pas se lier | `warning: cannot bind TFTP on … ` — **le seul listener dont l'échec de liaison n'est pas fatal.** Le port 69 est le seul port privilégié de la conception, donc le seul bind qui puisse échouer pour quelque chose que personne n'a configuré ; les réponses sont le produit, et mourir ferait échouer toutes les installations en cours pour signaler qu'un second port n'a pas pu être ouvert. `boot check` sort en non-zéro et le message nomme les façons d'obtenir le port | +| Répertoire de médias absent ou illisible | une ligne `warning: media: …` — un parc ne doit jamais être incapable de s'installer parce qu'une image est bizarre | ## Options de compilation | Feature | Défaut | Effet | |---|---|---| -| `sqlite` | activée | Le store SQLite et l'API d'administration. `cargo build --no-default-features` retire les deux — 944 928 octets au lieu de 2 103 456 sur ARMv7 | +| `sqlite` | activée | Le store SQLite et l'API d'administration | +| `boot` | activée | Le catalogue de médias, le lecteur ISO et le listener média | + +Mesuré sur ARMv7 (gnueabihf, plancher glibc 2.17). Remesurez plutôt que de citer ces +chiffres : ils ont bougé d'environ 375 Ko quand cette cible est passée de musl à glibc. + +| Build | Octets | +|---|---| +| les deux (défaut) | 2 602 056 | +| `sqlite` seule | 2 482 000 | +| `boot` seule | 1 436 704 | +| aucune | 1 316 648 | ## Limites fixes diff --git a/docs/guide/reference/configuration.md b/docs/guide/reference/configuration.md index 2e79e8e..e7b40e2 100644 --- a/docs/guide/reference/configuration.md +++ b/docs/guide/reference/configuration.md @@ -29,6 +29,22 @@ no configuration *format* to learn and no command line to get wrong. | `RESCRIPTUM_CAPTURE_DIR` | unset | Record request bodies here. Unset means no capture | | `RESCRIPTUM_LOG` | `all` | `all`, `problems` or `off` — see [below](#logging) | | `RESCRIPTUM_LOG_FILE` | unset | A file to append to, or `stdout` / `stderr`. Unset means stderr | +| `RESCRIPTUM_MEDIA_DIR` | unset | Installer images. **Unset means no media and no media listener** | +| `RESCRIPTUM_MEDIA_ADDR` | `0.0.0.0:8001` | The media listener, when there is a media directory | +| `RESCRIPTUM_MEDIA_TIMEOUT_SECS` | `600` | Whole-transfer deadline. Deliberately not the answer listener's 10 | +| `RESCRIPTUM_MEDIA_MAX_CONNECTIONS` | `16` | Concurrent transfers. Low on purpose: each holds its permit for minutes | +| `RESCRIPTUM_PUBLIC_HOST` | derived | The host generated URLs name. **A host, never a URL** | +| `RESCRIPTUM_BOOT_ALLOW` | unset | Client CIDRs allowed to fetch boot media. Unset means anyone who can reach the port | +| `RESCRIPTUM_BOOT_DIR` | unset | Loaders and menus, handed out over TFTP. **Unset means no TFTP at all** | +| `RESCRIPTUM_TFTP_ADDR` | `0.0.0.0:69` | The TFTP listener, or **`off`** for none. Port 69 is privileged; see `RESCRIPTUM_USER` | +| `RESCRIPTUM_TFTP_PORT_RANGE` | unset | The ports transfers answer from, as `first-last`. **A TFTP transfer leaves port 69 immediately** — the server replies from a fresh port and the client acknowledges to that — so a firewall allowing only 69 drops the acknowledgement and the machine looks like it lost interest. Pin the range so it can be opened. Unset, the kernel picks | +| `RESCRIPTUM_TFTP_BLKSIZE` | `1468` | The largest TFTP block to agree to. 1468 fills a 1500-byte path **exactly** — 1468 payload, 4 TFTP, 8 UDP, 20 IP — so a VLAN tag or a tunnel makes the frame too big and a PXE ROM usually just stops. Lower it (1400, or 512) when a boot stalls at the first block | +| `RESCRIPTUM_BOOT_TIMEOUT_SECS` | `15` | Seconds before the menu falls through to local boot | +| `RESCRIPTUM_BOOT_UNCLAIMED` | `menu` | What a machine no answer claims gets. `local` hands it back to its firmware instead, which inverts what an answer file means: present is *install this one* rather than *leave this one alone* | +| `RESCRIPTUM_INSTALLED_TOKEN` | unset | Proxmox's `[post-installation-webhook]` token. Set it and `POST /installed` exists, dropping a machine's install claim when it reports success. **Unset, there is no endpoint** | +| `RESCRIPTUM_BOOT_LOGO` | built-in | A PNG to show behind the menu | +| `RESCRIPTUM_BOOT_TITLE` | built-in | The menu's title bar | +| `RESCRIPTUM_USER` / `_GROUP` | unset | Drop to these **after** binding. The other order fails on deployment | `/srv` is where the filesystem hierarchy standard puts data served by the system, which is what an answers directory is. Both defaults live there so that a bare `rescriptum` does @@ -153,6 +169,12 @@ These stop the server rather than warning, because starting anyway would be wors | `RESCRIPTUM_ADMIN_TOKEN` under 16 characters | short enough to guess | | The listen address cannot be bound | nothing to do | | The store cannot be opened | nothing to serve | +| `RESCRIPTUM_MEDIA_ADDR` set with no `RESCRIPTUM_MEDIA_DIR` | a listener with nothing to serve | +| `RESCRIPTUM_MEDIA_ADDR` equal to the answer or admin address | the second bind loses, and which one depends on start order | +| `RESCRIPTUM_PUBLIC_HOST` carrying a scheme, a port or a path | it is written into URLs for two listeners; one port in the value pins every generated script to one of them | +| `RESCRIPTUM_TFTP_ADDR` set with no `RESCRIPTUM_BOOT_DIR` | a listener with no loaders to hand out | +| The boot directory cannot be resolved | every path check compares against it | +| `RESCRIPTUM_USER` names an account that does not exist | nothing to become | ## Startup warnings @@ -166,12 +188,26 @@ These are printed and the server carries on: | Admin API not on loopback | `warning: the admin API is not bound to loopback — …` | | `RESCRIPTUM_ANSWER_TOKEN` under 16 characters | a warning, **not** an error — refusing to start would leave a fleet unable to install | | Any problem in the answer set | one `warning:` line each, the same set `check` reports | +| `RESCRIPTUM_PUBLIC_HOST` unset | The routing table's answer, or the sole interface address when there is no default route. Logged either way, as a warning **naming the other addresses** when there are any. A NAT host still gets it wrong silently | +| TFTP cannot bind | `warning: cannot bind TFTP on … ` — **the one listener whose failed bind is not fatal.** Port 69 is the only privileged port in the design, so it is the only bind that can fail for something nobody configured; answers are the product, and dying would fail every install in flight to report that a second port could not be opened. `boot check` exits non-zero and the message names the ways to have the port | +| Media directory missing or unlistable | one `warning: media: …` line — a fleet must never be unable to install because one image is odd | ## Compile-time options | Feature | Default | Effect | |---|---|---| -| `sqlite` | on | The SQLite store and the admin API. `cargo build --no-default-features` drops both — 944,928 bytes instead of 2,103,456 on ARMv7 | +| `sqlite` | on | The SQLite store and the admin API | +| `boot` | on | The media catalogue, the ISO reader and the media listener | + +Measured on ARMv7 (gnueabihf, glibc floor 2.17). Re-measure rather than quoting these: +they moved by about 375 KB when that target changed from musl. + +| Build | Bytes | +|---|---| +| both (default) | 2,602,056 | +| `sqlite` only | 2,482,000 | +| `boot` only | 1,436,704 | +| neither | 1,316,648 | ## Fixed limits diff --git a/docs/home.fr.md b/docs/home.fr.md index 3ef2b8b..5134bdd 100644 --- a/docs/home.fr.md +++ b/docs/home.fr.md @@ -13,9 +13,9 @@ résultat. Ce qu'une machine s'apprête à recevoir, vous pouvez le lire avant d flowchart LR M["N'importe quelle machine
même image, même URL"] M -->|"MAC · numéro de série · DMI"| R["rescriptum"] - R --> B["groups/base.toml"] - R --> A["groups/rack-a.toml"] - R --> H["98fa9b50d810.toml"] + R --> B["groups/base/"] + R --> A["groups/rack-a/"] + R --> H["98fa9b50d810/"] B --> G["fusion
la machine gagne toujours"] A --> G H --> G @@ -47,8 +47,8 @@ que sur un hôte de datacenter encaissant une rafale de provisioning. ## Trente secondes ```console -$ mkdir -p answers/groups -$ cat > answers/groups/rack-a.toml <<'TOML' +$ mkdir -p answers/groups/rack-a +$ cat > answers/groups/rack-a/proxmox.toml <<'TOML' members = ["98:fa:9b:50:d8:10", "98:fa:9b:50:d8:11"] [global] @@ -68,8 +68,9 @@ timezone = "Europe/Paris" … ``` -Voilà une baie **en tant que Proxmox**. Le même répertoire contient `groups/rack-a.ks` pour -les nœuds RHEL et `groups/rack-a.preseed` pour les Debian — même idée, autre extension. Un +Voilà une baie **en tant que Proxmox**. Le même répertoire contient `groups/rack-a/rhel.ks` +pour les nœuds RHEL et `groups/rack-a/debian.preseed` pour les Debian — même répertoire, autre +extension. Un document est indexé par *(machine, format)*, donc une machine peut être plusieurs systèmes d'exploitation à la fois et c'est l'URL qui tranche. @@ -101,7 +102,7 @@ Puis pointez ce que vous installez sur **son URL** — un seul serveur leur rép membres, ou par ce que la machine *est*. - **[Un document par système d'exploitation](./guide/answers/formats.md)** — l'extension est le format, l'endpoint choisit entre eux. -- **[Groupes et fusion](./guide/answers/grouping.md)** — une baie partage un fichier ; une +- **[Groupes et fusion](./guide/answers/grouping.md)** — une baie partage un document ; une machine qui diffère ne porte que sa différence. - **[Templating](./guide/answers/templating.md)** — `{{ serial }}` dans un groupe couvre cinq cents machines. diff --git a/docs/home.md b/docs/home.md index 006053a..16c4d6e 100644 --- a/docs/home.md +++ b/docs/home.md @@ -13,9 +13,9 @@ about to receive, you can read it before you power the machine on. flowchart LR M["Any machine
same image, same URL"] M -->|"MAC · serial · DMI"| R["rescriptum"] - R --> B["groups/base.toml"] - R --> A["groups/rack-a.toml"] - R --> H["98fa9b50d810.toml"] + R --> B["groups/base/"] + R --> A["groups/rack-a/"] + R --> H["98fa9b50d810/"] B --> G["merge
the machine always wins"] A --> G H --> G @@ -45,8 +45,8 @@ datacenter host fielding a provisioning burst. ## Thirty seconds ```console -$ mkdir -p answers/groups -$ cat > answers/groups/rack-a.toml <<'TOML' +$ mkdir -p answers/groups/rack-a +$ cat > answers/groups/rack-a/proxmox.toml <<'TOML' members = ["98:fa:9b:50:d8:10", "98:fa:9b:50:d8:11"] [global] @@ -66,8 +66,9 @@ timezone = "Europe/Paris" … ``` -That is one rack **as Proxmox**. The same directory holds `groups/rack-a.ks` for the RHEL -nodes and `groups/rack-a.preseed` for the Debian ones — same idea, different extension. A +That is one rack **as Proxmox**. The same directory holds `groups/rack-a/rhel.ks` for the +RHEL nodes and `groups/rack-a/debian.preseed` for the Debian ones — same directory, different +extension. A document is keyed by *(machine, format)*, so one machine can be several operating systems at once and the URL picks between them. @@ -99,7 +100,7 @@ Then point whatever you are installing at **its own URL** — one server answers by what the machine *is*. - **[One document per operating system](./guide/answers/formats.md)** — the extension is the format, the endpoint chooses between them. -- **[Groups and merging](./guide/answers/grouping.md)** — a rack shares one file; a machine +- **[Groups and merging](./guide/answers/grouping.md)** — a rack shares one document; a machine that differs carries only its difference. - **[Templating](./guide/answers/templating.md)** — `{{ serial }}` in a group covers five hundred machines. diff --git a/examples/52-54-00-aa-00-04.yml b/examples/52-54-00-aa-00-04/ubuntu.yml similarity index 90% rename from examples/52-54-00-aa-00-04.yml rename to examples/52-54-00-aa-00-04/ubuntu.yml index a9bdd27..5eaf5c2 100644 --- a/examples/52-54-00-aa-00-04.yml +++ b/examples/52-54-00-aa-00-04/ubuntu.yml @@ -1,7 +1,7 @@ # Ubuntu autoinstall for one machine — spelled `.yml` rather than `.yaml`. # # Both are YAML and both are served by /ubuntu/, /autoinstall/, /cloudinit/ and -# /nocloud/. But they are DIFFERENT KEYS: a `.yml` machine file will not layer onto a +# /nocloud/. But they are DIFFERENT KEYS: a `.yml` machine document will not layer onto a # `.yaml` group, because `extends` and merging resolve within one format. Pick one # spelling per fleet and stay with it; this file exists to show that the other is # accepted, not to recommend mixing them. diff --git a/examples/52-54-00-aa-00-05.json b/examples/52-54-00-aa-00-05/answer.json similarity index 100% rename from examples/52-54-00-aa-00-05.json rename to examples/52-54-00-aa-00-05/answer.json diff --git a/examples/52-54-00-aa-00-06.xml b/examples/52-54-00-aa-00-06/answer.xml similarity index 100% rename from examples/52-54-00-aa-00-06.xml rename to examples/52-54-00-aa-00-06/answer.xml diff --git a/examples/98fa9b50d810.preseed b/examples/98fa9b50d810/debian.preseed similarity index 100% rename from examples/98fa9b50d810.preseed rename to examples/98fa9b50d810/debian.preseed diff --git a/examples/98fa9b50d810.toml b/examples/98fa9b50d810/proxmox.toml similarity index 82% rename from examples/98fa9b50d810.toml rename to examples/98fa9b50d810/proxmox.toml index b548796..ff16fab 100644 --- a/examples/98fa9b50d810.toml +++ b/examples/98fa9b50d810/proxmox.toml @@ -1,6 +1,7 @@ # One machine, as Proxmox VE. # -# Its sibling `98fa9b50d810.preseed` is the SAME hardware as Debian. A machine's answer +# Its sibling `debian.preseed`, in this same directory, is the SAME hardware as Debian. +# A machine's answer # is specific to the operating system it is for, so the two are different answers to # different questions and both may exist at once — the extension is what tells them # apart, and the endpoint is what chooses between them: diff --git a/examples/README.md b/examples/README.md index 6927e52..76a3a83 100644 --- a/examples/README.md +++ b/examples/README.md @@ -12,8 +12,8 @@ RESCRIPTUM_ANSWERS_DIR=examples rescriptum # serve them on :8000 `check` has to come back clean here. `deploy.sh` runs it before it ships anything, and these files are the only place the formats are shown composing together — two of them -(`suse-node.autoyast`, `windows-node.unattend`) are what caught a missing doctype and an -unpaired `pass` attribute. +(`groups/suse-node/suse.autoyast`, `groups/windows-node/windows.unattend`) are what caught +a missing doctype and an unpaired `pass` attribute. > **These are examples, not configuration.** Every password hash is `REPLACE$ME` and every > SSH key is `AAAA...REPLACE`. Copy what you need into your own answers directory; do not @@ -21,41 +21,52 @@ unpaired `pass` attribute. ## What is here -| File | Format | Claimed by | +**One directory per identity.** The directory names the machine or the group; the +extension inside it names the format. The part before the dot is only a label for whoever +opens the folder — `proxmox.toml` and `answer.toml` are the same document to the server. + +| Document | Format | Claimed by | |---|---|---| -| `example.toml` | Proxmox VE | nothing — a commented reference, copy it and rename to a MAC | -| `98fa9b50d810.toml` | Proxmox VE | its filename; overrides `groups/example-rack.toml` | -| `98fa9b50d810.preseed` | Debian | its filename — **the same machine as Debian** | -| `aabbccddeeff.yaml` | Ubuntu autoinstall | its filename | -| `aabbccddeeff.seed` | Debian | its filename — the same machine again, second OS | -| `52-54-00-aa-00-04.yml` | Ubuntu autoinstall | its filename (`.yml` spelling) | -| `52-54-00-aa-00-05.json` | Ignition | its filename (`.json` spelling) | -| `52-54-00-aa-00-06.xml` | AutoYaST | its filename (generic `.xml` spelling) | -| `groups/example-rack.toml` | Proxmox VE | a `members` list | -| `groups/base.preseed` | Debian | a `# answer: member` directive | -| `groups/rhel-compute.ks` | kickstart | `# answer: match serial=7ABC*` | -| `groups/ubuntu-web.yaml` | Ubuntu autoinstall | `match: file=user-data, product=PowerEdge R6*` | -| `groups/ubuntu-meta.yaml` | Ubuntu autoinstall | `match: file=meta-data` — NoCloud's other half | -| `groups/flatcar-node.ign` | Ignition | a `members` list | -| `groups/suse-node.autoyast` | AutoYaST | `` | -| `groups/windows-node.unattend` | Windows unattend | `` | -| `groups/edge-router.ipxe` | iPXE boot script | `# answer: member` | -| `groups/legacy-node.cfg` | generic line-oriented | `# answer: member` | - -Between them they exercise all three ways of claiming a machine — by filename, by member -list, by selector — and both layering strategies: structural merge for TOML, YAML, JSON -and XML; concatenation for kickstart, preseed, `.cfg` and iPXE. +| `example/proxmox.toml` | Proxmox VE | nothing — a commented reference, copy it into a directory named after a MAC | +| `98fa9b50d810/proxmox.toml` | Proxmox VE | its directory; overrides `groups/example-rack` | +| `98fa9b50d810/debian.preseed` | Debian | the same directory — **the same machine as Debian** | +| `aabbccddeeff/ubuntu.yaml` | Ubuntu autoinstall | its directory | +| `aabbccddeeff/debian.seed` | Debian | the same machine again, second OS | +| `52-54-00-aa-00-04/ubuntu.yml` | Ubuntu autoinstall | its directory (`.yml` spelling) | +| `52-54-00-aa-00-05/answer.json` | Ignition | its directory (`.json` spelling) | +| `52-54-00-aa-00-06/answer.xml` | AutoYaST | its directory (generic `.xml` spelling) | +| `groups/example-rack/proxmox.toml` | Proxmox VE | a `members` list | +| `groups/base/debian.preseed` | Debian | a `# answer: member` directive | +| `groups/rhel-compute/rhel.ks` | kickstart | `# answer: match serial=7ABC*` | +| `groups/ubuntu-web/ubuntu.yaml` | Ubuntu autoinstall | `match: file=user-data, product=PowerEdge R6*` | +| `groups/ubuntu-meta/ubuntu.yaml` | Ubuntu autoinstall | `match: file=meta-data` — NoCloud's other half | +| `groups/flatcar-node/flatcar.ign` | Ignition | a `members` list | +| `groups/suse-node/suse.autoyast` | AutoYaST | `` | +| `groups/windows-node/windows.unattend` | Windows unattend | `` | +| `groups/edge-router/boot.ipxe` | iPXE boot script | `# answer: member` | +| `groups/legacy-node/answer.cfg` | generic line-oriented | `# answer: member` | + +Between them they exercise all three ways of claiming a machine — by directory name, by +member list, by selector — and both layering strategies: structural merge for TOML, YAML, +JSON and XML; concatenation for kickstart, preseed, `.cfg` and iPXE. + +Two of them, `52-54-00-aa-00-05/answer.json` and `groups/legacy-node/answer.cfg`, are +deliberately named `answer`: `.json` and `.cfg` name no single installer, so there is no +better label than none at all. Both are the names `rescriptum` itself writes. ## Three things these files are trying to teach -**A machine's answer is specific to the operating system it is for.** `98fa9b50d810.toml` -and `98fa9b50d810.preseed` are one piece of hardware with two answers, and the URL decides -which one is served. A document is keyed by *(identifier, format)*, not by identifier. +**A machine's answer is specific to the operating system it is for.** `98fa9b50d810/` holds +`proxmox.toml` and `debian.preseed`: one piece of hardware with two answers, and the URL +decides which one is served. A document is keyed by *(identifier, format)*, not by +identifier — which is exactly what the directory makes visible. A directory may hold one +document per format and no more; two `.toml` in one directory is a reported problem, +because nothing could pick between them. **Some extensions are two spellings of one format.** `.yml`/`.yaml`, `.json`/`.ign`, `.seed`/`.preseed`, `.xml`/`.autoyast`/`.unattend`. They parse identically, but they are -different keys — a `.yml` machine file will not layer onto a `.yaml` group. Pick one -spelling per fleet. +different keys — a `.yml` machine document will not layer onto a `.yaml` group, and both +may sit in one directory without colliding. Pick one spelling per fleet. **`check` renders from an identity alone.** It has no request, so it cannot supply a fact that only ever arrives with one. That is why the templating in here uses `{{ machine }}` diff --git a/examples/aabbccddeeff.seed b/examples/aabbccddeeff/debian.seed similarity index 84% rename from examples/aabbccddeeff.seed rename to examples/aabbccddeeff/debian.seed index c899cd5..b1e8d78 100644 --- a/examples/aabbccddeeff.seed +++ b/examples/aabbccddeeff/debian.seed @@ -1,4 +1,4 @@ -# The same machine as examples/aabbccddeeff.yaml — that one as Ubuntu, this one as +# The same machine as `ubuntu.yaml` beside it — that one as Ubuntu, this one as # Debian. A document is keyed by (identifier, format), so both exist at once and the URL # decides which is served. # @@ -6,7 +6,7 @@ # serve either. The two extensions exist because `seed` is deliberately NOT an endpoint # alias — `s=http://server/seed/` is an ordinary NoCloud seed URL, and it serves YAML. # -# Prefer `.preseed` for new files; this one is here to show the alias is real. +# Prefer `.preseed` for new documents; this one is here to show the alias is real. d-i debian-installer/locale string fr_FR.UTF-8 d-i keyboard-configuration/xkb-keymap select fr diff --git a/examples/aabbccddeeff.yaml b/examples/aabbccddeeff/ubuntu.yaml similarity index 80% rename from examples/aabbccddeeff.yaml rename to examples/aabbccddeeff/ubuntu.yaml index db7ef9b..7ae6311 100644 --- a/examples/aabbccddeeff.yaml +++ b/examples/aabbccddeeff/ubuntu.yaml @@ -1,6 +1,6 @@ # One Ubuntu machine, carrying only what differs from its group. # -# It is claimed by `ubuntu-web.yaml` through that group's `match` block, and layered on +# It is claimed by `groups/ubuntu-web/` through that group's `match` block, and layered on # top of it: this hostname wins, everything else is inherited. Naming a file after a MAC # is the most specific claim there is, so it always beats a selector. # diff --git a/examples/example.toml b/examples/example/proxmox.toml similarity index 79% rename from examples/example.toml rename to examples/example/proxmox.toml index 5a42847..54ab260 100644 --- a/examples/example.toml +++ b/examples/example/proxmox.toml @@ -1,8 +1,8 @@ # One machine's answer file. # # The filename is what matches: name it after the machine's MAC address, in any -# separator style — `98-fa-9b-50-d8-10.toml`, `98:fa:9b:50:d8:10.toml` and -# `98fa9b50d810.toml` all match the same machine. Both sides are lowercased and +# separator style — directories named `98-fa-9b-50-d8-10`, `98:fa:9b:50:d8:10` and +# `98fa9b50d810` all match the same machine. Both sides are lowercased and # stripped of non-alphanumerics before comparing. # # If this machine belongs to a group (see groups/), keep only the keys that differ from @@ -59,7 +59,10 @@ disk-list = ["sda", "sdb"] # url = "https://config.example.com/first-boot.sh" # cert-fingerprint = "AB:CD:..." -# Tell something that the install finished. +# **Tell this server the install finished, so the machine stops being claimed.** +# Without it a machine claimed by an `.ipxe` answer reinstalls on every reboot. Proxmox +# calls this after a successful install and before the reboot; rescriptum answers it when +# RESCRIPTUM_INSTALLED_TOKEN is set, and renames the claim out of the way. # [post-installation-webhook] -# url = "https://hooks.example.com/pve-installed" -# auth-token = "REPLACE" +# url = "http://192.168.1.10:8000/installed" +# auth-token = "nas:REPLACE" diff --git a/examples/groups/base.preseed b/examples/groups/base/debian.preseed similarity index 100% rename from examples/groups/base.preseed rename to examples/groups/base/debian.preseed diff --git a/examples/groups/edge-router.ipxe b/examples/groups/edge-router/boot.ipxe similarity index 100% rename from examples/groups/edge-router.ipxe rename to examples/groups/edge-router/boot.ipxe diff --git a/examples/groups/example-rack.toml b/examples/groups/example-rack/proxmox.toml similarity index 93% rename from examples/groups/example-rack.toml rename to examples/groups/example-rack/proxmox.toml index 4b7c715..7ef2313 100644 --- a/examples/groups/example-rack.toml +++ b/examples/groups/example-rack/proxmox.toml @@ -2,7 +2,7 @@ # # `members` lists the machines this group answers for — separator style does not # matter, same as filenames. A listed machine needs no file of its own unless it has -# something to override, in which case add `.toml` next to this directory and put +# something to override, in which case add a `/` directory beside `groups/` and put # only the difference in it. # # `members` and `extends` are this server's keys, not Proxmox's. They are stripped from diff --git a/examples/groups/flatcar-node.ign b/examples/groups/flatcar-node/flatcar.ign similarity index 100% rename from examples/groups/flatcar-node.ign rename to examples/groups/flatcar-node/flatcar.ign diff --git a/examples/groups/legacy-node.cfg b/examples/groups/legacy-node/answer.cfg similarity index 100% rename from examples/groups/legacy-node.cfg rename to examples/groups/legacy-node/answer.cfg diff --git a/examples/groups/rhel-compute.ks b/examples/groups/rhel-compute/rhel.ks similarity index 100% rename from examples/groups/rhel-compute.ks rename to examples/groups/rhel-compute/rhel.ks diff --git a/examples/groups/suse-node.autoyast b/examples/groups/suse-node/suse.autoyast similarity index 100% rename from examples/groups/suse-node.autoyast rename to examples/groups/suse-node/suse.autoyast diff --git a/examples/groups/ubuntu-meta.yaml b/examples/groups/ubuntu-meta/ubuntu.yaml similarity index 100% rename from examples/groups/ubuntu-meta.yaml rename to examples/groups/ubuntu-meta/ubuntu.yaml diff --git a/examples/groups/ubuntu-web.yaml b/examples/groups/ubuntu-web/ubuntu.yaml similarity index 95% rename from examples/groups/ubuntu-web.yaml rename to examples/groups/ubuntu-web/ubuntu.yaml index b726c0b..756d998 100644 --- a/examples/groups/ubuntu-web.yaml +++ b/examples/groups/ubuntu-web/ubuntu.yaml @@ -16,7 +16,7 @@ match: # cloud-init fetches several named files from one URL; this is the one that carries - # the install. See ubuntu-meta.yaml for the other required half. + # the install. See groups/ubuntu-meta/ for the other required half. file: "user-data" product: "PowerEdge R6*" diff --git a/examples/groups/windows-node.unattend b/examples/groups/windows-node/windows.unattend similarity index 100% rename from examples/groups/windows-node.unattend rename to examples/groups/windows-node/windows.unattend diff --git a/packaging/boot-rig/Dockerfile.client b/packaging/boot-rig/Dockerfile.client new file mode 100644 index 0000000..f33d307 --- /dev/null +++ b/packaging/boot-rig/Dockerfile.client @@ -0,0 +1,21 @@ +# A QEMU machine with its NIC on the rig's isolated network, and nothing else. +# +# **It must pass under TCG**, because the development machine is a Mac: /dev/kvm makes +# this fast, not possible. Ten times slower is a long boot, not a wall. +FROM debian:bookworm-slim + +# `iproute2` is not optional here and its absence is not obvious: without `ip` the +# script cannot bridge eth0, and QEMU would silently fall back to nothing rather than +# to a working network. The first run of this rig died on exactly that. +RUN apt-get update && apt-get install -y --no-install-recommends \ + qemu-system-x86 nasm ovmf iproute2 \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /rig +COPY packaging/boot-rig/local-disk.asm /rig/local-disk.asm +COPY packaging/boot-rig/boot-client.sh /rig/boot-client.sh +RUN nasm -f bin /rig/local-disk.asm -o /rig/local-disk.img \ + && test "$(stat -c%s /rig/local-disk.img)" = 512 \ + && chmod +x /rig/boot-client.sh + +ENTRYPOINT ["/rig/boot-client.sh"] diff --git a/packaging/boot-rig/Dockerfile.dhcp b/packaging/boot-rig/Dockerfile.dhcp new file mode 100644 index 0000000..ebfdfa0 --- /dev/null +++ b/packaging/boot-rig/Dockerfile.dhcp @@ -0,0 +1,26 @@ +# dnsmasq, installed at **image-build** time. +# +# The rig's network is `internal: true` — a harness that runs a DHCP server has to be +# unable to answer anything on the host's LAN — which also means nothing on it can reach +# an apt repository. Installing at run time looked fine and exited 127 with +# `exec: dnsmasq: not found`, after apt had failed silently into /dev/null. +# +# Same lesson as the loaders, and the rig's own README already said it: if a service on +# this network needs something from the internet, it gets it before the network exists. +FROM debian:bookworm-slim + +# tcpdump is a rig tool, not decoration: when no client boots, the only question worth +# asking is whether the DHCP request reached this container at all, and nothing else in +# the stack can answer it. The network is `internal`, so it has to be here at build time. +RUN apt-get update && apt-get install -y --no-install-recommends dnsmasq tcpdump \ + && rm -rf /var/lib/apt/lists/* \ + && dnsmasq --version | head -1 + +# `--log-facility=-` sends dnsmasq's logging to stderr. **Without it everything goes to +# syslog**, which in a container is nowhere: the rig's DHCPACK marker could never match, +# and a run where nothing booted would look identical to one where nothing was logged. +# +# The configuration under test is mounted, printed, and then handed to dnsmasq. Printing +# it is not decoration: when no client boots, the first question is what the DHCP server +# was actually told, and this is the answer. +CMD ["sh", "-c", "echo '--- the configuration under test ---'; cat /etc/rig/dnsmasq.conf; tcpdump -i eth0 -n -l port 67 or port 68 & exec dnsmasq --keep-in-foreground --log-facility=- --log-dhcp --conf-file=/etc/rig/dnsmasq.conf"] diff --git a/packaging/boot-rig/Dockerfile.loaders b/packaging/boot-rig/Dockerfile.loaders new file mode 100644 index 0000000..62ad1a2 --- /dev/null +++ b/packaging/boot-rig/Dockerfile.loaders @@ -0,0 +1,29 @@ +# Builds the branded iPXE loaders the rig boots. +# +# The rig cannot use a stock loader: a plain undionly.kpxe re-loads itself forever, and a +# stock netboot.xyz binary chains to the public menu. **Testing the chain means testing +# ours**, embedded script and all — which is why this shares packaging/ipxe/ with the +# release rather than having a build of its own, and why the pin is the same one. +# +# The clone happens at image-build time on purpose: the rig's own network is `internal`, +# so nothing on it can reach the internet. The fake DHCP server never touching the host's +# LAN is the same hygiene the product itself lives by. +# **Pinned to amd64, and not as a convenience.** iPXE's BIOS targets are 32-bit x86 and +# its `ipxe.efi` here is x86-64; a compiler on an ARM64 host cannot produce either, and +# the failure is a wall of `unrecognized command-line option '-m32'`. On an Apple Silicon +# machine this runs under emulation — slower, which is the same trade the rest of the rig +# already makes by refusing KVM. +ARG LOADER_PLATFORM=linux/amd64 +FROM --platform=${LOADER_PLATFORM} debian:bookworm-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential liblzma-dev git perl mtools xorriso ca-certificates \ + gcc-aarch64-linux-gnu \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /ipxe +COPY packaging/ipxe/ /ipxe/ +RUN chmod +x build.sh && ./build.sh --out /loaders + +# At run time the network is gone; this only copies what the build already produced. +CMD ["sh", "-c", "cp -a /loaders/. /out/ && ls -la /out"] diff --git a/packaging/boot-rig/Dockerfile.rig b/packaging/boot-rig/Dockerfile.rig new file mode 100644 index 0000000..cfd5b7e --- /dev/null +++ b/packaging/boot-rig/Dockerfile.rig @@ -0,0 +1,55 @@ +# The whole rig in one container: loaders, server, DHCP and a QEMU machine, on a bridge +# that exists nowhere else. +# +# ## Why one container rather than four +# +# The four-service shape is the honest one and it works on a Linux host. It does **not** +# work on Docker Desktop, and the reason is worth recording because it is not obvious: +# 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. Measured here: +# container-to-container TCP works, and a DHCP broadcast from the guest never reaches the +# next container — tcpdump on the receiving side captures nothing at all. +# +# So this variant puts everything in one network namespace, on a **private** bridge with +# no uplink. Nothing crosses Docker's network, which means nothing can be filtered by it, +# and the rig's network really is its whole world — a stronger version of the +# `internal: true` the four-service file asks for. +# +# What is under test is unchanged: the DHCP configuration still comes from +# `boot dhcp-snippet`, the loader is still ours, and the two markers are the same. + +FROM --platform=linux/amd64 debian:bookworm-slim AS loaders +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential liblzma-dev git perl mtools xorriso ca-certificates \ + gcc-aarch64-linux-gnu \ + && rm -rf /var/lib/apt/lists/* +WORKDIR /ipxe +COPY packaging/ipxe/ /ipxe/ +RUN chmod +x build.sh && ./build.sh --out /loaders + +# **amd64 here too, and not as a detail.** The runtime stage below is amd64 because +# iPXE's BIOS targets are 32-bit x86; a server binary built for the host's architecture +# lands in it and fails with `cannot execute: required file not found`, which reads as a +# missing file rather than as a wrong ELF. +FROM --platform=linux/amd64 rust:1-bookworm AS server +WORKDIR /src +COPY . . +RUN cargo build --release --locked + +FROM --platform=linux/amd64 debian:bookworm-slim +RUN apt-get update && apt-get install -y --no-install-recommends \ + qemu-system-x86 nasm ovmf iproute2 dnsmasq tcpdump \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=server /src/target/release/rescriptum /usr/local/bin/rescriptum +COPY --from=loaders /loaders/ /srv/boot/ +COPY packaging/boot-rig/local-disk.asm /rig/local-disk.asm +COPY packaging/boot-rig/answers/ /srv/answers/ +COPY packaging/boot-rig/rig-in-one.sh /rig/rig-in-one.sh + +RUN nasm -f bin /rig/local-disk.asm -o /rig/local-disk.img \ + && test "$(stat -c%s /rig/local-disk.img)" = 512 \ + && chmod +x /rig/rig-in-one.sh \ + && mkdir -p /srv/media /out + +ENTRYPOINT ["/rig/rig-in-one.sh"] diff --git a/packaging/boot-rig/Dockerfile.server b/packaging/boot-rig/Dockerfile.server new file mode 100644 index 0000000..1631c0e --- /dev/null +++ b/packaging/boot-rig/Dockerfile.server @@ -0,0 +1,17 @@ +# The real binary, built for Linux and run as the rig's server. +# +# Built here rather than copied in, so the rig always tests the working tree rather than +# whatever happened to be lying in ./target. +FROM rust:1-bookworm AS build +WORKDIR /src +COPY . . +RUN cargo build --release --locked + +FROM debian:bookworm-slim +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates \ + && rm -rf /var/lib/apt/lists/* +COPY --from=build /src/target/release/rescriptum /usr/local/bin/rescriptum +RUN mkdir -p /srv/answers /srv/media /srv/boot +# Root, because TFTP wants port 69 and the rig is not where privilege dropping is proved +# — `tests/` covers that, and dropping here would only obscure a bind failure. +ENTRYPOINT ["/usr/local/bin/rescriptum"] diff --git a/packaging/boot-rig/README.md b/packaging/boot-rig/README.md new file mode 100644 index 0000000..7d2c215 --- /dev/null +++ b/packaging/boot-rig/README.md @@ -0,0 +1,158 @@ +# The boot rig + +Everything from a DHCP offer to a machine sitting on its own disk, in one command, on a +network that is its whole world. + +```console +$ packaging/boot-rig/run.sh +``` + +**It produces no features and it is not optional.** Everything built on top of the boot +chain depends on knowing the chain works, and this is what keeps that known: from here +on, every push can re-prove the BIOS path without a single real machine. + +## What it proves, and what it cannot + +| Where | What it proves | +|---|---| +| `cargo test` | protocol and logic: TFTP over real UDP, ranges over real sockets, the ISO reader against synthetic images, snippet ↔ loader-table coherence | +| **This rig** | the chain end to end — a DHCP offer, a loader over TFTP, a script, a menu, and a machine that lands where it should | +| A real machine | that firmware agrees. **Nothing ships on rig evidence alone** | + +The third row is not a formality. The rig runs one emulator with one NIC model; the +failures it cannot see are exactly the ones firmware has — a ROM that reads only the +BOOTP `file` field, a UEFI build that cannot see its own network card, an option 93 value +nobody expected. + +## The two markers + +Both deterministic, neither a screenshot. + +**An unclaimed machine must reach its own disk.** It gets a disk whose only content is a +512-byte boot sector that prints `RESCRIPTUM-RIG-LOCAL-DISK-REACHED` to the serial console +and halts ([`local-disk.asm`](local-disk.asm)). Reaching it means the machine went through +DHCP, the loader, the bootstrap and the menu, found nothing claiming it, waited out the +timeout, and fell through — which is the safety behaviour the whole design rests on. A +machine that PXE-boots by accident must never sit at a menu forever, and must never +install anything. + +**A claimed machine must reach its own answer.** Its `.ipxe` answer ends by fetching a +sentinel URL, so the assertion is a line in the **server's own log** rather than something +the client printed. That is the stronger form: it proves the request arrived. + +And a third, which comes free: **dnsmasq answered from the snippet we generate.** The rig +builds its DHCP configuration by running `rescriptum boot dhcp-snippet`, so if what we +tell operators to paste is wrong, nothing here boots. + +## The shape, and why + +**One container**, holding the loaders, the server, dnsmasq and a QEMU machine on a +private bridge with no uplink. Nothing crosses Docker's network, which means nothing can +be filtered by it — the rig's network really is its whole world. + +[`run-compose.sh`](run-compose.sh) is the four-service variant the plan describes: +loaders, server, dnsmasq and client as separate containers on a Docker network with +`internal: true`. It is the more honest shape and it works on a Linux host. **It does not +work on Docker Desktop**, and the reason is worth recording because it is not obvious: 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. + +That was measured rather than assumed. Container-to-container TCP works (a SYN and its +SYN-ACK captured on the receiving side); a DHCP broadcast from the guest reaches nothing +at all — `tcpdump` on the DHCP container captures zero packets while the client's own +`tap0` and `eth0` counters show the frames leaving. The guest's MAC even turns up in the +bridge's forwarding table on the *wrong* port. + +**No `/dev/kvm` in either.** KVM would make this fast; the rig has to pass without it, +because the development machine is a Mac. It needs `NET_ADMIN` to build the bridge and +`/dev/net/tun` for the tap the guest sits on, and nothing else — no host network, no +published port. + +## What running it for the first time cost + +Four attempts, and every failure was invisible until the thing actually ran. They are +listed because each one is a shape that will recur, not because the fixes are +interesting: + +| What failed | Why it was invisible | +|---|---| +| `iproute2` missing from the client image | every `ip` call tolerated its own failure, and the guard below them never fired. Five `command not found` lines scrolled past and QEMU booted with no network | +| The client image was never rebuilt | `docker compose run` reuses whatever image exists, so a Dockerfile fix silently did not apply | +| dnsmasq installed at *run* time | the network is `internal: true`, so apt could not reach anything. The install failed into `/dev/null` and the container exited 127 a minute before a client was booted at it | +| A 6 GB build context | no `.dockerignore`, so every run spent two minutes transferring `target/` | + +The pattern in three of the four: **a service that died looks exactly like one still +starting.** `run.sh` now checks every container is still running before it boots a +client, and prints the log of any that is not. + +The rule the third one broke is the rig's own, stated two paragraphs above it: if +something on this network needs the internet, it gets it before the network exists. + +## Two host facts worth knowing before the first run + +- **The loader image is pinned to `linux/amd64`.** iPXE's BIOS targets are 32-bit x86 and + its `ipxe.efi` here is x86-64; a compiler on an ARM64 host produces neither, and the + failure is a wall of `unrecognized command-line option '-m32'` that reads like a broken + Makefile. On Apple Silicon that image builds under emulation, which is slow and works. +- **ARM64 loaders need `gcc-aarch64-linux-gnu`.** Without it `build.sh` says which package + is missing and carries on with the x86 loaders, rather than failing with + `unrecognized command-line option '-mlittle-endian'` from the host compiler. + +## Running it + +```console +$ packaging/boot-rig/run.sh # both clients, BIOS +$ packaging/boot-rig/run.sh --uefi # both clients, OVMF +$ packaging/boot-rig/run.sh --keep # leave the stack up to poke at +``` + +Results land in `results/`: `serial.log` from the clients, `server.log` and `dhcp.log` +from the containers. When a marker is missing those three files are the whole +investigation. + +## Watch it fail, link by link + +A green rig that has never been red proves nothing — the same reasoning as the +listing-cache test that passed for the wrong reason. Break each link and watch the marker +it guards disappear: + +| Break | What should go red | +|---|---| +| Delete a loader from the volume | the ROM gets nothing; `boot check` also goes red | +| Point `RESCRIPTUM_MEDIA_ADDR` somewhere else without rebuilding the loaders | the embedded script chains into a refused connection, and the recovery paths must *engage* | +| Remove the claimed machine's answer | it should land on its **disk**, not hang — the fallthrough covers a machine whose answer was deleted, too | +| Drop a fact a template needs | the claimed machine must fail loudly rather than install with a broken hostname | +| Stop the server, boot a client | it must fall through to its next boot device rather than wait | + +That last one turns the blast-radius table in the guide from a claim into a recorded run. + +## Status: green, and it has been red + +**Run, on this machine, under TCG.** All four markers reached: + +``` + ok the DHCP handoff answered, from our own generated snippet + ok a loader was fetched over TFTP + ok the unclaimed machine fell through to its local disk + ok the claimed machine fetched its sentinel + +rig: all markers reached +``` + +That is the whole chain: a DHCP offer built from `boot dhcp-snippet`'s own output, our +branded loader over TFTP, its embedded script, the bootstrap, the answer engine — and +then either a machine's own unattended answer or the menu and a fall through to its disk. + +And it has been **watched failing**, which is what makes the green mean anything: + +| Break | What went red | +|---|---| +| Delete `ipxe-undionly.kpxe` | `boot check` says MISSING and the run stops before a client boots | +| Delete the claimed machine's answer | that marker alone goes red — and the machine still falls through to its disk, so the fallthrough covers a deleted answer too | + +The rows in the table above that have *not* been run yet are the moved media port, the +missing template fact, and the stopped server. Those are the next ones to watch go red. + +**What is still not proven is what real firmware does.** The rig runs one emulator with +one NIC model, and the standing rule is unchanged: nothing ships on harness evidence +alone. diff --git a/packaging/boot-rig/answers/98-fa-9b-50-d8-10/boot.ipxe b/packaging/boot-rig/answers/98-fa-9b-50-d8-10/boot.ipxe new file mode 100644 index 0000000..6536e91 --- /dev/null +++ b/packaging/boot-rig/answers/98-fa-9b-50-d8-10/boot.ipxe @@ -0,0 +1,12 @@ +#!ipxe +# The **claimed** machine's answer, and the rig's other marker. +# +# It installs nothing: what is being tested is that the chain reached the point where an +# unattended answer would run, for this machine specifically and not for the other one. +# Fetching a sentinel puts one line in the *server's own* log — which is a stronger +# assertion than anything the client could print, because it proves the request arrived. +echo rescriptum rig: claimed machine {{ mac }} reached its own answer +chain --autofree http://10.99.0.2:8000/rig/claimed?mac={{ mac }} || +echo rescriptum rig: sentinel fetched +sleep 2 +reboot diff --git a/packaging/boot-rig/boot-client.sh b/packaging/boot-rig/boot-client.sh new file mode 100755 index 0000000..45a2945 --- /dev/null +++ b/packaging/boot-rig/boot-client.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +# Boot one QEMU machine on the rig's network and record what its serial console said. +# +# boot-client.sh [seconds] [bios|uefi] +# +# **The NIC is bridged into the container's own network, not QEMU's user-mode stack.** +# That distinction is the whole point: user-mode networking carries its own DHCP server, +# so a rig built on it would test everything except the handoff it exists to test. The +# guest has to see the rig's dnsmasq, and dnsmasq has to see a real DHCP broadcast. +# +# The serial log is the entire assertion surface. Two markers, both deterministic and +# neither a screenshot: +# +# * an **unclaimed** machine falls through the menu to its own disk, whose boot sector +# prints RESCRIPTUM-RIG-LOCAL-DISK-REACHED and halts; +# * a **claimed** machine runs its own unattended answer, which ends by fetching a +# sentinel — so "the whole chain worked" is a line in the *server's* log rather than +# something this script has to interpret. + +set -uo pipefail + +MAC="${1:?usage: boot-client.sh [seconds] [bios|uefi]}" +NAME="${2:?usage: boot-client.sh [seconds] [bios|uefi]}" +LIMIT="${3:-180}" +FIRMWARE="${4:-bios}" +OUT="/out/${NAME}.serial.log" +mkdir -p /out + +# Put eth0 on a bridge and hang a tap off it, so the guest is a peer of the other +# containers rather than a NAT client of this one. +setup_bridge() { + # Checked first, because every `ip` below is tolerant of its own failure and the + # guard at the bottom of this function would not have noticed. The first run of this + # rig printed five `ip: command not found` lines and carried on into QEMU. + command -v ip >/dev/null 2>&1 || return 1 + ip link add br0 type bridge 2>/dev/null || true + ip link set br0 up + ip addr flush dev eth0 || true + ip link set eth0 master br0 + ip tuntap add dev tap0 mode tap 2>/dev/null || true + ip link set tap0 master br0 + ip link set tap0 up + # Bridges default to forwarding delay; the guest's first DHCP would land in it. + ip link set br0 type bridge forward_delay 0 2>/dev/null || true +} + +if ! setup_bridge; then + echo "cannot bridge eth0: either \`ip\` is missing from this image or the container \ +lacks cap_add: NET_ADMIN. Without a bridge the guest would see no DHCP server, which \ +looks like a broken boot chain rather than a broken rig." | tee "${OUT}" + exit 1 +fi +# And prove it worked, rather than trusting that nothing printed an error. +if ! ip link show tap0 >/dev/null 2>&1; then + echo "tap0 was not created — the guest would have no network" | tee "${OUT}" + exit 1 +fi + +# A scratch copy, so a run cannot alter the image the next one boots. +cp /rig/local-disk.img "/tmp/${NAME}.img" + +# **`pc`, not `q35`, for the BIOS run.** q35 has no IDE controller, so a disk attached +# with `if=ide` is simply not there — SeaBIOS says "could not read the boot disk" and the +# fallthrough marker can never be reached. The first run of this rig failed exactly that +# way, and it read as a broken menu rather than a machine with no disk. +MACHINE=pc +FIRMWARE_ARGS=() +if [ "${FIRMWARE}" = "uefi" ]; then + MACHINE=q35 + cp /usr/share/OVMF/OVMF_VARS.fd "/tmp/${NAME}.vars.fd" + FIRMWARE_ARGS=( + -drive "if=pflash,format=raw,readonly=on,file=/usr/share/OVMF/OVMF_CODE.fd" + -drive "if=pflash,format=raw,file=/tmp/${NAME}.vars.fd" + ) +fi + +# `-boot order=nc`: network first, then the disk. **That ordering is the fallthrough +# being tested** — a machine that gets no answer must reach the disk, not stop. +# +# `-nographic` puts the serial console on stdout. KVM is never requested: the rig has to +# pass under TCG, because the development machine is a Mac. +timeout "${LIMIT}" qemu-system-x86_64 \ + -machine "${MACHINE}" \ + -m 1024 \ + -nographic \ + -no-reboot \ + -boot order=nc \ + "${FIRMWARE_ARGS[@]}" \ + -netdev tap,id=n0,ifname=tap0,script=no,downscript=no \ + -device e1000,netdev=n0,mac="${MAC}" \ + -drive file="/tmp/${NAME}.img",format=raw,if=ide \ + > "${OUT}" 2>&1 + +status=$? +echo "--- ${NAME} (${MAC}, ${FIRMWARE}) exited ${status} after at most ${LIMIT}s ---" >> "${OUT}" +# `timeout` returning 124 is the normal end of a run that halted at the marker: a halted +# machine does not exit on its own, and waiting for one that never will is a failure this +# deliberately does not have. The markers decide, not the exit code. +exit 0 diff --git a/packaging/boot-rig/docker-compose.yml b/packaging/boot-rig/docker-compose.yml new file mode 100644 index 0000000..bdcafb2 --- /dev/null +++ b/packaging/boot-rig/docker-compose.yml @@ -0,0 +1,108 @@ +# The boot rig: the whole chain, from a DHCP offer to a machine on its own disk, in one +# command and on a network that is its whole world. +# +# packaging/boot-rig/run.sh +# +# Three services, and the shape is the point: +# +# * **dhcp** — dnsmasq, configured from the snippet `boot dhcp-snippet` generates. The +# rig is therefore a test of the snippet too: if what we tell operators to paste is +# wrong, no client here boots. +# * **server** — the real rescriptum binary, answers and media and TFTP. +# * **client** — QEMU, its NIC bridged onto this network, booting from it. +# +# `internal: true` on the network is not tidiness. A rig that runs a DHCP server has to +# be unable to answer anything on the host's LAN, which is the same "did installing this +# break the network" hygiene the product itself lives by. It also means nothing here can +# reach the internet, which is why the loaders are built into their image. +# +# **No /dev/kvm anywhere.** KVM would make this fast; the rig has to pass without it, +# because the development machine is a Mac. + +name: rescriptum-boot-rig + +networks: + rig: + internal: true + driver: bridge + ipam: + config: + - subnet: 10.99.0.0/24 + +volumes: + loaders: + results: + +services: + # Copies the loaders its image already built into the shared volume, then exits. + # Everything else waits for it: a rig that boots before the loaders are there fails + # as a mysterious TFTP timeout rather than as a missing file. + loaders: + build: + context: ../.. + dockerfile: packaging/boot-rig/Dockerfile.loaders + volumes: + - loaders:/out + networks: [rig] + + server: + build: + context: ../.. + dockerfile: packaging/boot-rig/Dockerfile.server + depends_on: + loaders: + condition: service_completed_successfully + environment: + RESCRIPTUM_LISTEN_ADDR: 0.0.0.0:8000 + RESCRIPTUM_ANSWERS_DIR: /srv/answers + RESCRIPTUM_MEDIA_DIR: /srv/media + RESCRIPTUM_MEDIA_ADDR: 0.0.0.0:8001 + RESCRIPTUM_BOOT_DIR: /srv/boot + RESCRIPTUM_TFTP_ADDR: 0.0.0.0:69 + # The address the generated scripts name. Fixed here, so what a client is told to + # fetch is exactly what this file says it should be. + RESCRIPTUM_PUBLIC_HOST: 10.99.0.2 + # Every request, because the log *is* the claimed-machine assertion. + RESCRIPTUM_LOG: all + RESCRIPTUM_BOOT_TIMEOUT_SECS: "5" + volumes: + - loaders:/srv/boot + - ./answers:/srv/answers:ro + - ./media:/srv/media:ro + networks: + rig: + ipv4_address: 10.99.0.2 + + dhcp: + build: + context: ../.. + dockerfile: packaging/boot-rig/Dockerfile.dhcp + depends_on: + server: + condition: service_started + # dnsmasq needs to bind 67 and to hear broadcasts on the segment. + cap_add: [NET_ADMIN, NET_RAW, NET_BIND_SERVICE] + volumes: + - ./generated:/etc/rig:ro + networks: + rig: + ipv4_address: 10.99.0.3 + + client: + build: + context: ../.. + dockerfile: packaging/boot-rig/Dockerfile.client + depends_on: + dhcp: + condition: service_started + cap_add: [NET_ADMIN] + devices: + - /dev/net/tun + volumes: + - results:/out + networks: [rig] + # No profile: `up` below names the three services it wants, so this one does not + # start on its own — and leaving it out of a profile is what lets `build` see it. + # A `docker compose run` reuses whatever image exists, so a client image that is + # never built is a client image that never changes; the first two runs of this rig + # died on a fix that had been made and not compiled. diff --git a/packaging/boot-rig/generated/dnsmasq.conf b/packaging/boot-rig/generated/dnsmasq.conf new file mode 100644 index 0000000..74d5f1d --- /dev/null +++ b/packaging/boot-rig/generated/dnsmasq.conf @@ -0,0 +1,31 @@ +# --- rig scaffolding (not generated) --- +port=0 +interface=eth0 +bind-interfaces +log-dhcp +dhcp-range=10.99.0.100,10.99.0.200,1h +enable-tftp=no + +# --- everything below is: rescriptum boot dhcp-snippet --format dnsmasq --- +# rescriptum 0.2.0 - boot handoff for 10.99.0.2 +# Architecture values are IANA option 93 codes; see docs/guide/boot/dhcp. +# Generated from the same table the TFTP server serves from. +dhcp-match=set:bios,option:client-arch,0 +dhcp-match=set:efi64,option:client-arch,7 +dhcp-match=set:efi64,option:client-arch,9 +dhcp-match=set:efiarm64,option:client-arch,11 +dhcp-vendorclass=set:httpefi64,HTTPClient:Arch:00016 +dhcp-vendorclass=set:httpefiarm64,HTTPClient:Arch:00019 + +dhcp-boot=tag:bios,ipxe-undionly.kpxe,,10.99.0.2 +dhcp-boot=tag:efi64,ipxe-x86_64.efi,,10.99.0.2 +dhcp-boot=tag:efiarm64,ipxe-arm64.efi,,10.99.0.2 +dhcp-option-force=tag:httpefi64,60,HTTPClient +dhcp-boot=tag:httpefi64,http://10.99.0.2:8001/boot/ipxe-x86_64.efi,,10.99.0.2 +dhcp-option-force=tag:httpefiarm64,60,HTTPClient +dhcp-boot=tag:httpefiarm64,http://10.99.0.2:8001/boot/ipxe-arm64.efi,,10.99.0.2 + +# A ROM that sends no option 93 matches no tag above and would get nothing. +# The only clients that old are BIOS, and a DHCP client that is not netbooting +# ignores boot options entirely. +dhcp-boot=ipxe-undionly.kpxe,,10.99.0.2 diff --git a/packaging/boot-rig/generated/dnsmasq.conf.snippet b/packaging/boot-rig/generated/dnsmasq.conf.snippet new file mode 100644 index 0000000..a7a030b --- /dev/null +++ b/packaging/boot-rig/generated/dnsmasq.conf.snippet @@ -0,0 +1,22 @@ +# rescriptum 0.2.0 - boot handoff for 192.168.128.247 +# Architecture values are IANA option 93 codes; see docs/guide/boot/dhcp. +# Generated from the same table the TFTP server serves from. +dhcp-match=set:bios,option:client-arch,0 +dhcp-match=set:efi64,option:client-arch,7 +dhcp-match=set:efi64,option:client-arch,9 +dhcp-match=set:efiarm64,option:client-arch,11 +dhcp-vendorclass=set:httpefi64,HTTPClient:Arch:00016 +dhcp-vendorclass=set:httpefiarm64,HTTPClient:Arch:00019 + +dhcp-boot=tag:bios,ipxe-undionly.kpxe,,192.168.128.247 +dhcp-boot=tag:efi64,ipxe-x86_64.efi,,192.168.128.247 +dhcp-boot=tag:efiarm64,ipxe-arm64.efi,,192.168.128.247 +dhcp-option-force=tag:httpefi64,60,HTTPClient +dhcp-boot=tag:httpefi64,http://192.168.128.247:8001/boot/ipxe-x86_64.efi,,192.168.128.247 +dhcp-option-force=tag:httpefiarm64,60,HTTPClient +dhcp-boot=tag:httpefiarm64,http://192.168.128.247:8001/boot/ipxe-arm64.efi,,192.168.128.247 + +# A ROM that sends no option 93 matches no tag above and would get nothing. +# The only clients that old are BIOS, and a DHCP client that is not netbooting +# ignores boot options entirely. +dhcp-boot=ipxe-undionly.kpxe,,192.168.128.247 diff --git a/packaging/boot-rig/local-disk.asm b/packaging/boot-rig/local-disk.asm new file mode 100644 index 0000000..71f4963 --- /dev/null +++ b/packaging/boot-rig/local-disk.asm @@ -0,0 +1,66 @@ +; The marker a machine prints when it falls through the menu to its own disk. +; +; **This is the whole of the unclaimed-machine assertion**, and it is deterministic +; rather than a screenshot: QEMU's serial console goes to a log, and the rig greps it. +; A machine that PXE-boots by accident, and that nothing claims, must end up here — +; never installing anything, never sitting at a menu waiting for a human who is not +; coming. The worst case of being wrong about which machines reach the boot server is +; a few seconds added to a boot, and this is what proves it. +; +; Assembled by the client image's nasm into a 512-byte MBR. Nothing else is on the disk: +; if the firmware reaches this, it reached the disk. + +[BITS 16] +[ORG 0x7C00] + +start: + cli + xor ax, ax + mov ds, ax + mov es, ax + mov ss, ax + mov sp, 0x7C00 + sti + + ; COM1 at 115200 8N1. QEMU would emit the bytes without this, but relying on that + ; is relying on an implementation detail of one emulator, and the rig is supposed + ; to resemble a machine. + mov dx, 0x03FB ; line control + mov al, 0x80 ; divisor latch access + out dx, al + mov dx, 0x03F8 ; divisor low + mov al, 0x01 ; 115200 + out dx, al + mov dx, 0x03F9 ; divisor high + xor al, al + out dx, al + mov dx, 0x03FB + mov al, 0x03 ; 8 bits, no parity, one stop; latch off + out dx, al + + mov si, message +.next: + lodsb + test al, al + jz .done +.wait: + mov dx, 0x03FD ; line status + push ax + in al, dx + test al, 0x20 ; transmit holding register empty + pop ax + jz .wait + mov dx, 0x03F8 + out dx, al + jmp .next + +.done: + cli +.halt: + hlt + jmp .halt + +message: db 13, 10, "RESCRIPTUM-RIG-LOCAL-DISK-REACHED", 13, 10, 0 + +times 510-($-$$) db 0 +dw 0xAA55 diff --git a/packaging/boot-rig/media/.gitkeep b/packaging/boot-rig/media/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/packaging/boot-rig/rig-in-one.sh b/packaging/boot-rig/rig-in-one.sh new file mode 100755 index 0000000..cc2a0c1 --- /dev/null +++ b/packaging/boot-rig/rig-in-one.sh @@ -0,0 +1,163 @@ +#!/usr/bin/env bash +# Drive the whole boot chain inside one container, on a bridge with no uplink. +# +# rig-in-one.sh [bios|uefi] +# +# Everything the four-service rig does, minus Docker's network — which on a Mac is what +# stops a QEMU guest being seen at all. See Dockerfile.rig for the measurement. + +set -uo pipefail +FIRMWARE="${1:-bios}" +SERVER=10.99.0.2 +mkdir -p /out + +say() { echo "==> $*"; } + +# --------------------------------------------------------------------------- +# A private bridge. No uplink, no route out: the rig's network is its whole world, +# which is a stronger guarantee than `internal: true` and needs nothing from Docker. +# --------------------------------------------------------------------------- +say "building the network" +ip link add br0 type bridge +ip link set br0 type bridge forward_delay 0 +ip addr add "${SERVER}/24" dev br0 +ip link set br0 up +ip tuntap add dev tap0 mode tap +ip link set tap0 master br0 +ip link set tap0 up +ip -brief addr show br0 + +# --------------------------------------------------------------------------- +# The DHCP configuration comes from the server itself, so the rig tests the snippet too: +# if what we tell operators to paste is wrong, nothing here boots. +# --------------------------------------------------------------------------- +say "generating the DHCP configuration from the server's own snippet" +export RESCRIPTUM_PUBLIC_HOST="${SERVER}" +export RESCRIPTUM_MEDIA_DIR=/srv/media +export RESCRIPTUM_ANSWERS_DIR=/srv/answers +export RESCRIPTUM_BOOT_DIR=/srv/boot +export RESCRIPTUM_LISTEN_ADDR="${SERVER}:8000" +export RESCRIPTUM_MEDIA_ADDR="${SERVER}:8001" +export RESCRIPTUM_TFTP_ADDR="${SERVER}:69" +export RESCRIPTUM_BOOT_TIMEOUT_SECS=5 +export RESCRIPTUM_LOG=all + +# **The snippet's stderr is not discarded.** Hiding it once cost a run: the binary was +# the wrong architecture, the generator produced nothing, and the configuration below +# came out with no handoff in it at all — which looked like a DHCP server that simply +# did not answer. +rescriptum boot dhcp-snippet --format dnsmasq > /rig/snippet.conf || { + echo "could not generate the DHCP snippet — see above" >&2 + exit 1 +} +if ! grep -q "dhcp-boot" /rig/snippet.conf; then + echo "the generated snippet names no boot file; refusing to run a rig that tests nothing" >&2 + exit 1 +fi +{ + echo "port=0" + echo "interface=br0" + echo "bind-interfaces" + echo "log-dhcp" + echo "log-facility=-" + echo "dhcp-range=10.99.0.100,10.99.0.200,1h" + echo + cat /rig/snippet.conf +} > /rig/dnsmasq.conf +cat /rig/dnsmasq.conf + +say "what the server thinks of its own boot assets" +rescriptum boot check || exit 1 + +# --------------------------------------------------------------------------- +say "starting the server and the DHCP handoff" +rescriptum > /out/server.log 2>&1 & +SERVER_PID=$! +dnsmasq --keep-in-foreground --conf-file=/rig/dnsmasq.conf > /out/dhcp.log 2>&1 & +DHCP_PID=$! +# tcpdump is the answer to "did the request even arrive", which is the only question +# worth asking when no client boots. +tcpdump -i br0 -n -l "port 67 or port 68 or port 69" > /out/wire.log 2>&1 & +TCPDUMP_PID=$! + +# Both have to still be alive: a process that died looks exactly like one still starting. +sleep 3 +for pid_name in "SERVER_PID server" "DHCP_PID dhcp"; do + set -- $pid_name + if ! kill -0 "${!1}" 2>/dev/null; then + echo "the $2 process died before a client was booted. Its log:" >&2 + tail -20 "/out/$2.log" >&2 + exit 1 + fi +done + +boot() { + local mac="$1" name="$2" limit="$3" + say "booting a machine as $name ($mac, ${FIRMWARE})" + cp /rig/local-disk.img "/tmp/${name}.img" + + local machine=pc + local firmware=() + if [ "${FIRMWARE}" = "uefi" ]; then + machine=q35 + cp /usr/share/OVMF/OVMF_VARS.fd "/tmp/${name}.vars.fd" + firmware=( + -drive "if=pflash,format=raw,readonly=on,file=/usr/share/OVMF/OVMF_CODE.fd" + -drive "if=pflash,format=raw,file=/tmp/${name}.vars.fd" + ) + fi + + # `-boot order=nc`: network first, then the disk. **That ordering is the fallthrough + # being tested** — a machine that gets no answer must reach the disk, not stop. + timeout "${limit}" qemu-system-x86_64 \ + -machine "${machine}" \ + -m 1024 \ + -nographic \ + -no-reboot \ + -boot order=nc \ + "${firmware[@]}" \ + -netdev tap,id=n0,ifname=tap0,script=no,downscript=no \ + -device e1000,netdev=n0,mac="${mac}" \ + -drive file="/tmp/${name}.img",format=raw,if=ide \ + > "/out/${name}.serial.log" 2>&1 + echo "--- ${name} ended after at most ${limit}s ---" >> "/out/${name}.serial.log" +} + +boot 52:54:00:aa:aa:aa unclaimed "${UNCLAIMED_SECONDS:-240}" +boot 98:fa:9b:50:d8:10 claimed "${CLAIMED_SECONDS:-240}" + +kill "${SERVER_PID}" "${DHCP_PID}" "${TCPDUMP_PID}" 2>/dev/null +wait 2>/dev/null + +# --------------------------------------------------------------------------- +say "results" +fail=0 +check() { + local what="$1" file="$2" needle="$3" + if grep -qF -- "${needle}" "${file}" 2>/dev/null; then + echo " ok ${what}" + else + echo " FAIL ${what} — ${needle} not in $(basename "${file}")" + fail=$((fail + 1)) + fi +} + +check "the DHCP handoff answered, from our own generated snippet" \ + /out/dhcp.log "DHCPACK" +check "a loader was fetched over TFTP" \ + /out/server.log "tftp:" +check "the unclaimed machine fell through to its local disk" \ + /out/unclaimed.serial.log "RESCRIPTUM-RIG-LOCAL-DISK-REACHED" +check "the claimed machine fetched its sentinel" \ + /out/server.log "/rig/claimed" + +echo +if [ "${fail}" = "0" ]; then + echo "rig: all markers reached" + exit 0 +fi +echo "rig: ${fail} marker(s) missing" +echo +echo "--- what was on the wire ---" +head -20 /out/wire.log +exit 1 diff --git a/packaging/boot-rig/run-compose.sh b/packaging/boot-rig/run-compose.sh new file mode 100755 index 0000000..680c691 --- /dev/null +++ b/packaging/boot-rig/run-compose.sh @@ -0,0 +1,148 @@ +#!/usr/bin/env bash +# Drive the boot rig and assert on its two markers. +# +# ./run.sh # both clients, BIOS +# ./run.sh --uefi # both clients, OVMF +# ./run.sh --keep # leave the stack up afterwards, to poke at it +# +# **This is the contract, and CI runs a subset of it.** GitHub's runners are somebody +# else's machines with somebody else's limits, so the dev rig is what decides and CI is +# the tripwire — sized so it cannot fail for capacity reasons. + +set -euo pipefail +SELF="$(cd "$(dirname "$0")" && pwd)/$(basename "$0")" +cd "$(dirname "$SELF")" + +FIRMWARE=bios +KEEP=0 +while [ $# -gt 0 ]; do + case "$1" in + --uefi) FIRMWARE=uefi; shift ;; + --keep) KEEP=1; shift ;; + -h|--help) sed -n '2,9p' "$SELF" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) echo "unexpected argument: $1" >&2; exit 2 ;; + esac +done + +COMPOSE=(docker compose -f docker-compose.yml) + +cleanup() { + if [ "$KEEP" = "0" ]; then + "${COMPOSE[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true + else + echo "stack left up; 'docker compose -f $PWD/docker-compose.yml down -v' when done" + fi +} +trap cleanup EXIT + +# --------------------------------------------------------------------------- +# The DHCP configuration comes from the server itself, which is what makes the rig a +# test of `boot dhcp-snippet` too. If what we tell operators to paste is wrong, nothing +# here boots — and that is exactly the failure worth catching before they meet it. +# --------------------------------------------------------------------------- +echo "==> generating the DHCP configuration from the server's own snippet" +mkdir -p generated +cargo run --quiet --manifest-path ../../Cargo.toml -- boot dhcp-snippet --format dnsmasq \ + > generated/dnsmasq.conf.snippet 2>/dev/null || { + echo "could not generate the snippet" >&2; exit 1; } + +# Everything above the snippet is the rig's own scaffolding: a range to hand out, an +# interface to listen on, and no upstream DNS — this network has no internet. +{ + echo "# --- rig scaffolding (not generated) ---" + echo "port=0" + echo "interface=eth0" + echo "bind-interfaces" + echo "log-dhcp" + echo "dhcp-range=10.99.0.100,10.99.0.200,1h" + # No `enable-tftp` at all: this dnsmasq only answers DHCP, and the server under + # test is the one that hands out loaders. (`enable-tftp=no` does not disable it — it + # names an interface called "no", which dnsmasq then enables TFTP on.) + echo + echo '# --- everything below is: rescriptum boot dhcp-snippet --format dnsmasq ---' + # The generated snippet names RESCRIPTUM_PUBLIC_HOST, which outside a container is + # this machine. Inside the rig the server is 10.99.0.2, and that substitution is the + # only edit the rig makes. + sed 's/[0-9]\{1,3\}\.[0-9]\{1,3\}\.[0-9]\{1,3\}\.[0-9]\{1,3\}/10.99.0.2/g' \ + generated/dnsmasq.conf.snippet +} > generated/dnsmasq.conf + +echo "==> building the images (no KVM anywhere: the rig must pass under TCG)" +# **The client is built explicitly.** `docker compose run` reuses whatever image is +# already there, so a client image that is never built is one that never changes — and +# a fix made to its Dockerfile would silently not apply. +"${COMPOSE[@]}" build loaders server dhcp client + +echo "==> bringing the stack up" +"${COMPOSE[@]}" up -d loaders server dhcp + +# The loaders service exits when it has copied; the others have to be listening. +for _ in $(seq 1 60); do + if "${COMPOSE[@]}" exec -T server /usr/local/bin/rescriptum boot check >/dev/null 2>&1; then + break + fi + sleep 2 +done + +# **Every service has to still be running.** A container that died looks exactly like +# one still starting, and the first run of this rig spent four minutes booting a client +# at a DHCP server that had exited 127 a minute earlier. +for service in server dhcp; do + state=$("${COMPOSE[@]}" ps --format '{{.State}}' "$service" 2>/dev/null | head -1) + if [ "$state" != "running" ]; then + echo "the $service container is '$state', not running. Its log:" >&2 + "${COMPOSE[@]}" logs "$service" 2>&1 | tail -20 >&2 + exit 1 + fi +done + +echo "==> what the server thinks of its own boot assets" +"${COMPOSE[@]}" exec -T server /usr/local/bin/rescriptum boot check + +# --------------------------------------------------------------------------- +# Marker one: a machine nothing claims must reach its own disk. +# --------------------------------------------------------------------------- +echo "==> booting an UNCLAIMED machine (${FIRMWARE})" +"${COMPOSE[@]}" run --rm -T client 52:54:00:aa:aa:aa unclaimed 240 "${FIRMWARE}" || true + +# --------------------------------------------------------------------------- +# Marker two: a machine something claims must reach its own answer. +# --------------------------------------------------------------------------- +echo "==> booting a CLAIMED machine (${FIRMWARE})" +"${COMPOSE[@]}" run --rm -T client 98:fa:9b:50:d8:10 claimed 240 "${FIRMWARE}" || true + +echo "==> results" +mkdir -p results +"${COMPOSE[@]}" run --rm -T --entrypoint sh client -c 'cat /out/*.serial.log' > results/serial.log 2>&1 || true +"${COMPOSE[@]}" logs server > results/server.log 2>&1 || true +"${COMPOSE[@]}" logs dhcp > results/dhcp.log 2>&1 || true + +fail=0 +check() { + local what="$1" file="$2" needle="$3" + if grep -qF -- "${needle}" "${file}" 2>/dev/null; then + echo " ok ${what}" + else + echo " FAIL ${what} — ${needle} not in ${file}" + fail=$((fail + 1)) + fi +} + +# An unclaimed machine reached its own disk rather than sitting at a menu or stopping. +check "unclaimed machine fell through to its local disk" \ + results/serial.log "RESCRIPTUM-RIG-LOCAL-DISK-REACHED" +# A claimed machine reached its own answer — asserted in the *server's* log, which +# proves the request arrived rather than that the client printed something. +check "claimed machine fetched its sentinel" \ + results/server.log "/rig/claimed" +# And the DHCP handoff itself worked, which is the snippet under test. +check "dnsmasq answered a PXE client from the generated snippet" \ + results/dhcp.log "DHCPACK" + +echo +if [ "${fail}" = "0" ]; then + echo "rig: all markers reached" +else + echo "rig: ${fail} marker(s) missing — see packaging/boot-rig/results/" + exit 1 +fi diff --git a/packaging/boot-rig/run.sh b/packaging/boot-rig/run.sh new file mode 100755 index 0000000..9d681ab --- /dev/null +++ b/packaging/boot-rig/run.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# The boot rig: everything from a DHCP offer to a machine on its own disk, in one +# command, on a network that exists nowhere else. +# +# ./run.sh # BIOS +# ./run.sh --uefi # OVMF +# ./run.sh --rebuild # force the image to be rebuilt first +# +# One container, holding the loaders, the server, dnsmasq and a QEMU machine on a +# **private bridge with no uplink**. Nothing crosses Docker's network, which means +# nothing can be filtered by it — a stronger guarantee than the `internal: true` the +# four-service variant asks for, and the reason this works on a Mac. +# +# `run-compose.sh` is that four-service variant: the same markers, split across +# containers on an isolated Docker network. It is the honest shape and it works on a +# Linux host; on Docker Desktop the QEMU guest's frames never reach the next container. +# See Dockerfile.rig for the measurement. +# +# **No /dev/kvm.** KVM would make this fast; the rig has to pass without it, because the +# development machine is a Mac. + +set -euo pipefail +# Resolved before the `cd`: `$0` is relative when the script is invoked that way, and +# `--help` below reads the script itself. +SELF="$(cd "$(dirname "$0")" && pwd)/$(basename "$0")" +cd "$(dirname "$SELF")" + +FIRMWARE=bios +REBUILD=0 +while [ $# -gt 0 ]; do + case "$1" in + --uefi) FIRMWARE=uefi; shift ;; + --bios) FIRMWARE=bios; shift ;; + --rebuild) REBUILD=1; shift ;; + -h|--help) sed -n '2,20p' "$SELF" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) echo "unexpected argument: $1" >&2; exit 2 ;; + esac +done + +IMAGE=rescriptum-rig:one +if [ "$REBUILD" = "1" ] || ! docker image inspect "$IMAGE" >/dev/null 2>&1; then + echo "==> building $IMAGE (iPXE and the server; the first one takes a while)" + docker build -f Dockerfile.rig -t "$IMAGE" ../.. +fi + +# NET_ADMIN to build the bridge, /dev/net/tun for the tap the guest sits on. Nothing +# else: no host network, no published port, no KVM. +exec docker run --rm \ + --cap-add NET_ADMIN \ + --device /dev/net/tun \ + -e "UNCLAIMED_SECONDS=${UNCLAIMED_SECONDS:-240}" \ + -e "CLAIMED_SECONDS=${CLAIMED_SECONDS:-240}" \ + "$IMAGE" "$FIRMWARE" diff --git a/packaging/dsm/CLAUDE.md b/packaging/dsm/CLAUDE.md new file mode 100644 index 0000000..483da30 --- /dev/null +++ b/packaging/dsm/CLAUDE.md @@ -0,0 +1,218 @@ +# CLAUDE.md — packaging/dsm + +Guidance for working on the Synology package. It loads only when Claude touches files under +`packaging/dsm/`; the root `CLAUDE.md` keeps the one rule that has to be visible everywhere — +**changing anything here means running the machine**, not just the local harness. + +## The DSM package + +`packaging/dsm/` wraps an already-built binary as a DSM 7 `.spk`. It is a **release +format**, exactly like the `.tar.gz` archives — no DSM-specific build, no feature flag, +nothing in `src/`. The **four** places DSM pressed back are answered in packaging: log +rotation by a `copytruncate` stanza, a CLI that cannot find its configuration by a +three-line wrapper (`rescriptum-cli`, which names `RESCRIPTUM_ENV_FILE`), no settings panel +by the desktop application below, and **a privileged port by one root command**. DSM 7 +does not let an unsigned package run as root — measured, four routes, in +`docs/development/traps.md` with the error codes — but `setcap cap_net_bind_service=+ep` +on the installed binary works, after which the package binds `udp/69` as its own +unprivileged user alongside 8000 and 8001. All three are registered with the firewall. +**The package ships the loaders**, so the share's `boot` folder arrives filled and `start` +refreshes it when the stamp does not name this version; a TFTP server with nothing to hand +out boots nothing, and a second download is how a working appliance becomes a support +thread. Verified on the machine by fetching `ipxe-undionly.kpxe` over TFTP with an +independent client and comparing it byte for byte. +**The capability belongs to the file, so an upgrade drops it**; the env file says so and +points at a Task Scheduler boot-up task. `RESCRIPTUM_TFTP_ADDR` is therefore left unset — +its default *is* port 69, which is what every loader and every generated snippet expects. +An earlier version shipped `off` and sent operators to DSM's own TFTP server: that traded +the product's first principle for a packaging constraint that turned out not to exist, and +it is not a precedent. `RESCRIPTUM_USER`/`_GROUP` stay documented as unusable — the package +already is its own unprivileged user. If this ever seems to need a `#[cfg]`, the design has gone wrong. + +```bash +./build.sh --spk x86_64-unknown-linux-musl # build, then wrap +packaging/dsm/make-spk.sh armv7 # wrap an existing build +packaging/dsm/check-spk.sh # structural check ⎫ both run +packaging/dsm/lifecycle-test.sh # drive the scripts ⎭ by ci.yml +packaging/dsm/vm/on-dsm.sh admin@nas # what only DSM can answer +``` + +**The package is tested in three places, and none of it is Rust** — `cargo test` does not +touch it. `check-spk.sh` asserts the archive's shape; `lifecycle-test.sh` unpacks an `.spk` +into a fake `/var/packages` tree and drives the real scripts through install (with a wizard +and without), start, `/health`, the exit codes, an upgrade over a hand-edited env file and +a canary — with `etc/` surviving and with it wiped — and an uninstall; both run on every +push. `vm/on-dsm.sh` runs the rest on a DSM 7 VM and then on the DS416j: `data-share`'s +ACL, `port-config`, the generated unit, `logrotate -f` against a live descriptor, and +whether Package Center accepts the archive at all. **Nothing ships on VM evidence alone**, +and `lifecycle-test.sh` was watched failing — reintroducing one defect turns 54 green into +46 green and 8 red. **It earns its keep:** its first run over the boot-media package caught +a live `RESCRIPTUM_MEDIA_ADDR` with `RESCRIPTUM_MEDIA_DIR` still commented, which is a +startup error — the package would not have started at all. + +### The desktop application + +`packaging/dsm/payload/ui/` is a **real DSM application** — `SYNO.SDS.AppWindow`, +`syno_formpanel`, `syno_textfield`, `syno_combobox`, `syno_button` — not a page of ours in a +frame. `dsmuidir="ui"` makes DSM symlink it into +`/usr/syno/synoman/webman/3rdparty/rescriptum`, and `dsmappname` names the class `ui/config` +declares. It manages the server's configuration, shows its status and tails its log. + +**ExtJS, not Vue, and the machine decided that.** DSM 7.2 ships a Vue framework and +Synology's current guide documents only that one — the first version of this was written +against it. The DS416j is capped at **DSM 7.1.1**, where `Vue` is undefined. ExtJS is on both +(7.1.1 and 7.2.2, measured), so one application covers every DSM this package supports; +`os_min_ver` is **7.1**, and 7.0 is not claimed because nothing has run there. The API is +documented in the ExtJS reference Synology generated for DSM, mirrored at + as `docs/synoextjsdocs.tar.gz`. + +The design rule holds: nothing in `src/` knows any of this exists. What the server gained is +a *generic* `config` subcommand, and the application's backend — `ui/api.cgi` — is a hundred +lines of shell that authenticate and then shell out to `rescriptum-cli config` and `media`. **The panel never grows a rule of its own**: it starts a download by calling `media add`, which is where the digest rules are tested, and it follows one by watching the `.part` file that command already writes — a CGI cannot hold a request open for 1.5 GB, and nothing about progress had to be invented for the browser. The env-file +semantics stay in Rust where they are tested rather than being written a second time in `sh`. + +**Four things were measured on the machine and every one of them is load-bearing. None is in +the developer guide** (they are in `docs/development/traps.md` at length): + +- **A CGI there runs as the owner of the script**, which for a package tree is the package + user. Not `http`, not root. That is what lets it read the `0600` env file it owns, and why + it cannot start or stop anything — restarting goes through DSM's own + `SYNO.Core.Package.Control`, from the application, with the administrator's session. +- **DSM does not authenticate that path.** An unauthenticated request gets `200`. So + `authenticate.cgi` plus an `administrators` check *is* the door, and a write additionally + needs a header a cross-origin page cannot make a browser send. Losing any of them would be + silent, which is why `check-spk.sh` greps for them **with the comments stripped** — the + first version of that check passed because the word appeared in a comment. +- **No `su`, ever.** It hangs a CGI outright without ``**, so the package root is the + fixed `/var/packages/`, never `dirname "$SYNOPKG_PKGDEST"`. `RESCRIPTUM_PKG_ROOT` is + the seam that lets `lifecycle-test.sh` drive the scripts against a writable tree. +- **`etc/` and `var/` survive an uninstall** (they are symlinks into `@appconf`/`@appdata`), + so the env file and its tokens outlive the package — said plainly in the Synology page. +- **`$SYNOPKG_TEMP_UPGRADE_FOLDER` outlives its upgrade**, so restoring from it requires + `SYNOPKG_PKG_STATUS = UPGRADE` or a fresh install resurrects a removed configuration. +- **The firewall directory is `/usr/local/etc/services.d/`** (plural; the guide is wrong), + and `port-config` acquires *after* `postinst` — the wizard's port does reach it. Both + `port-config` and `usr-local-linker` acquire when the package is **enabled**, not at + `postinst`. +- **The generated unit has no `Restart=`**: DSM does not restart the process if it dies. + +**Changing anything under `packaging/dsm/` means running the machine**, not just the local +harness — the procedure is in `packaging/dsm/vm/README.md` (*Changing the package? This is +the procedure*), and `AGENTS.md` points at it. A DSM 7.2.2 VM already exists in Docker on +the maintainer's machine with a `clean` snapshot; `bootstrap.sh` sets one up from scratch, +`on-dsm.sh` drives it, and the run is destructive on purpose. It asks the server for a real +answer — a machine file merged over the group that claims it — rather than settling for +`/health`. + +The harnesses catch a broken archive and broken scripts; only Package Center catches a +broken package. **A tag must not be the first time an `.spk` meets a DSM machine** — the +rig is `packaging/dsm/vm/`: `docker-compose.yml` runs Synology's own Virtual DSM (DSM 7.2, +close to the DS416j's 7.2.1). KVM makes it fast, not possible — without `/dev/kvm` the image +falls back to emulation on its own, about ten times slower, which is what +`docker-compose.emulated.yml` is for. What does stop a host is **14 GiB free**, hardcoded in +the image and not derived from `DISK_SIZE`. `run-vm.sh` is the loader-image fallback. +## Traps already hit (do not re-discover these) + +These are the DSM-specific half of the root file's trap list. The long form of all of them is +in `docs/development/traps.md`. + +- **There is exactly one route to port 69 on DSM 7, and it is `setcap`.** `run-as: root` + in `conf/privilege` is refused with `synopkg` error **319**, `invalid package privilege + content` — in `defaults` *and* as a per-action `ctrl-script`, the shape Synology's own + packages use. A `security.capability` xattr in `package.tgz` installs and **Package + Center strips it**. `setcap cap_net_bind_service=+ep` after install works; + `net.ipv4.ip_unprivileged_port_start` does not exist on that kernel. Measured on a 7.2.2 + machine, all four. +- **Root on DSM 7 is gated on the *signature*, and `libsynopkg.so.1` says so.** Its + strings carry the whole rule: a package failing `verifyPackageSignature` may not have a + `ctrl-script` or `executable` section, must have `defaults.run-as` = `package`, and + — the line that matters — `tool capabilities should not exist`. DSM's privilege format + has a native `capabilities` field (documented since 7.0-40656), so a **signed** package + declares `cap_net_bind_service` and never needs `setcap`. Synology's guide states it + plainly — *"you are not able to install that package unless it is signed by synology"* — + so it is their signature, not a trusted publisher's. The one documented bypass, a + *development token*, is valid only on the NAS that generated its `debug.dat`, so it is + not a distribution path. **The manual `setcap` is settled, not provisional**; no + packaging change removes it. +- **`setcap` holds on the DS416j's volume, measured there.** The four routes to port 69 + were measured on an x86_64 VM whose `/volume1` is btrfs, `nodev` but not `nosuid`; a + `nosuid` mount makes the kernel ignore file capabilities outright, which would have + closed the last open route on the one machine this exists for. On the DS416j (ARMv7, + `armada38x`) the package binds `udp/69` and `boot check` says + `0.0.0.0:69 handed over ipxe-arm64.efi`. +- **A file capability does not survive an upgrade** — the new binary is a different file. + That is why a failed TFTP bind is the **one** listener failure here that is not fatal: + when it was, an upgrade took the answer endpoint down with it, failing every install in + flight to report that a second port could not be opened. It warns, `boot check` exits + non-zero, and the DSM panel shows a `tftp:` line. +- **A default computed at runtime must be computed in `settings()` too.** The DSM panel + renders a variable's default as the field's value, so a default living only where the + server consumes it shows as an empty box while the server runs on a value it derived. + `RESCRIPTUM_PUBLIC_HOST` shipped that way. Two `KNOWN` entries are special-cased there + — the worker count and the public host — and nothing in the type system says a third + would need it. diff --git a/packaging/dsm/check-spk.sh b/packaging/dsm/check-spk.sh index 27a8ace..12558c8 100755 --- a/packaging/dsm/check-spk.sh +++ b/packaging/dsm/check-spk.sh @@ -198,6 +198,19 @@ check_one() { [ -x "$work/target/bin/rescriptum" ] || bad "the payload's binary is not executable" ok "the payload carries the binary, the wrapper, the .sc file and the logrotate stanza" + # **The loaders, because a TFTP server with nothing to hand out boots nothing.** They + # are iPXE, GPLv2, separate files never linked into our binary — mere aggregation — + # and the NOTICE naming the upstream commit is the written offer that has to travel + # with them. A package without it would be a licence problem, not just an omission. + if [ -f "$work/target/boot/ipxe-undionly.kpxe" ]; then + ok "and the loaders it hands out ($(ls "$work/target/boot" | wc -l | tr -d ' ') files)" + [ -s "$work/target/boot/NOTICE" ] && + ok "with the GPLv2 written offer beside them" || + bad "the loaders ship without a NOTICE — that is the written offer GPLv2 requires" + else + bad "no loaders in the payload — the package would install a TFTP server with nothing to hand out" + fi + # The stanza's whole point: log::init opens the file once and never reopens it, so a # rotation without copytruncate silently ends logging. grep -q '^[[:space:]]*copytruncate' "$work/target/logrotate/rescriptum" && @@ -210,6 +223,13 @@ check_one() { ok "port_conf/rescriptum.sc declares [rescriptum]" || bad "port_conf/rescriptum.sc does not declare [rescriptum]" + # Both listeners. postinst rewrites this line with the wizard's port, but a template + # that lost the media one would produce a firewall entry an operator cannot find + # rescriptum in once they enable boot media — and nothing else would say so. + grep -q '^dst.ports=.*8001/tcp' "$work/target/port_conf/rescriptum.sc" && + ok "and registers the media port beside the answer one" || + bad "port_conf/rescriptum.sc does not register the media port" + # ── the desktop application ──────────────────────────────────────────────── local uidir appname uidir=$(info_value dsmuidir "$work/INFO") diff --git a/packaging/dsm/lifecycle-test.sh b/packaging/dsm/lifecycle-test.sh index 3215fb5..a860dd6 100755 --- a/packaging/dsm/lifecycle-test.sh +++ b/packaging/dsm/lifecycle-test.sh @@ -32,6 +32,7 @@ bad() { echo " ✗ $*" fails=$((fails + 1)) } +note() { echo " · $*"; } section() { echo; echo "$*"; } # ── the package under test ───────────────────────────────────────────────────── @@ -107,7 +108,86 @@ mode=$(file_mode "$ENV_FILE") [ "$(value_of RESCRIPTUM_LISTEN_ADDR)" = "0.0.0.0:$PORT" ] && ok "the wizard's port reached the env file" || bad "listen addr is $(value_of RESCRIPTUM_LISTEN_ADDR)" [ "$(value_of RESCRIPTUM_ANSWERS_DIR)" = "$SHARE/answers" ] && ok "the answers default to the share" || bad "answers dir is $(value_of RESCRIPTUM_ANSWERS_DIR)" grep -q "^RESCRIPTUM_DB_PATH=$SHARE/answers.db\$" "$ENV_FILE" && ok "the database path is pre-set in the share" || bad "RESCRIPTUM_DB_PATH is not pre-set — switching stores would be a fatal start" -grep -q "dst.ports=\"$PORT/tcp\"" "$ROOT/target/port_conf/rescriptum.sc" && ok "the .sc file carries the chosen port" || bad ".sc file: $(tail -1 "$ROOT/target/port_conf/rescriptum.sc")" +# All three listeners, registered whether or not each is currently enabled: registering +# a port does not open it, and the alternative is an operator who turns media or TFTP on +# and then cannot find rescriptum in the firewall list. **69/udp is the one that matters +# most** — a PXE ROM asks over UDP, and a firewall that drops it produces a client which +# retries and times out with nothing in any log on this side. +grep -q "dst.ports=\"$PORT/tcp 8001/tcp 69/udp 30000:30063/udp\"" "$ROOT/target/port_conf/rescriptum.sc" && ok "the .sc file carries the answer port, the media one, TFTP and its data range" || bad ".sc file: $(tail -1 "$ROOT/target/port_conf/rescriptum.sc")" +# **The data range is not decoration.** A TFTP transfer leaves port 69 at once and answers +# from a fresh port; a firewall that allows only 69 drops the acknowledgement, and the +# machine looks like it lost interest. Pinned so it can be opened, and opened above. +grep -q "^RESCRIPTUM_TFTP_PORT_RANGE=30000-30063\$" "$ENV_FILE" && ok "and the server is pinned to that same range" || bad "RESCRIPTUM_TFTP_PORT_RANGE is $(value_of RESCRIPTUM_TFTP_PORT_RANGE), which the firewall entry does not cover" + +# **The package must not ship a configuration that refuses to start.** Naming a media +# address with no media directory is a startup error, and the first version of this +# wrote exactly that — the harness caught a package that could not start at all. +# The folders the start script creates are *named* in the file, not left blank for an +# operator to guess at in a settings panel with no hint of what to type. +grep -q "^RESCRIPTUM_MEDIA_DIR=$SHARE/media\$" "$ENV_FILE" && ok "the media folder is named, not left to be guessed" || bad "RESCRIPTUM_MEDIA_DIR is not set to $SHARE/media" +grep -q "^RESCRIPTUM_BOOT_DIR=$SHARE/boot\$" "$ENV_FILE" && ok "and the boot folder too" || bad "RESCRIPTUM_BOOT_DIR is not set to $SHARE/boot" + +# **rescriptum is the TFTP server, and the package must not ship a file that says +# otherwise.** A previous version wrote `RESCRIPTUM_TFTP_ADDR=off` here, trading the +# product's first principle for a packaging constraint; port 69 is reachable on DSM with +# one `setcap`, measured on a 7.2.2 machine. Left unset, the default is 0.0.0.0:69 — +# which is what the generated DHCP snippet and every loader we ship expect. +# **A secret nobody has to invent.** The webhook only works when the token in a machine's +# answer and the one the server checks are the same string; two blank fields that must +# agree is a thing people get wrong once and then debug as "the machine reinstalls +# itself". Generated here so the server half is already right. +tok=$(value_of RESCRIPTUM_INSTALLED_TOKEN) +[ -n "$tok" ] && ok "an install-finished token was generated" || bad "RESCRIPTUM_INSTALLED_TOKEN is empty — the webhook would need one invented by hand" +[ "${#tok}" -ge 32 ] && ok "and it is long enough to be worth having" || bad "the generated token is only ${#tok} characters" +case "$tok" in *[!0-9a-f]*) bad "the token is not the hex it should be: $tok" ;; *) ok "and is hex, so it survives being pasted into TOML" ;; esac + +grep -q "^RESCRIPTUM_TFTP_ADDR=" "$ENV_FILE" && bad "RESCRIPTUM_TFTP_ADDR is live in the file — the default 0.0.0.0:69 is what the snippet and the loaders expect" || ok "TFTP is left at its default, so the package is the TFTP server" +grep -q "setcap cap_net_bind_service" "$ENV_FILE" && ok "and the file says what one root command makes it bind" || bad "nothing in the file explains how port 69 gets bound" +grep -q "Task Scheduler" "$ENV_FILE" && ok "and how to survive an upgrade, which drops the capability" || bad "nothing says the capability does not survive an upgrade" + +section "a setting this version introduced must reach a file that predates it" +# **The trap this closes, found on a real DS416j.** The live env file is written only when +# absent — right, because an upgrade must never replace somebody's port and tokens with +# defaults — but taken alone it means a new feature is invisible to every installation that +# predates it. Boot media shipped with the folders created, the loaders seeded and the +# firewall port registered, and RESCRIPTUM_BOOT_DIR never arriving: `boot check` answered +# "boot assets are off" on a NAS that had everything else in place. +saved=$(cat "$ENV_FILE") + +# A file from before boot media existed: the four settings that version wrote, and no more. +grep -E '^(RESCRIPTUM_LISTEN_ADDR|RESCRIPTUM_ANSWERS_DIR|RESCRIPTUM_DB_PATH|RESCRIPTUM_LOG_FILE)=' "$ENV_FILE" >"$ENV_FILE.old" +echo "RESCRIPTUM_ANSWER_TOKEN=keep-me-untouched-0123456789" >>"$ENV_FILE.old" +# And one the operator turned off on purpose. Commented is a decision, not an absence. +echo "# RESCRIPTUM_MEDIA_DIR=$SHARE/media" >>"$ENV_FILE.old" +mv "$ENV_FILE.old" "$ENV_FILE" +chmod 600 "$ENV_FILE" +before=$(cat "$ENV_FILE") + +SYNOPKG_PKG_STATUS=UPGRADE SYNOPKG_PKGVER=9.9.9-9 sh "$ROOT/scripts/postinst" >/dev/null 2>&1 + +grep -q "^RESCRIPTUM_BOOT_DIR=$SHARE/boot\$" "$ENV_FILE" && ok "a setting the file had never heard of was added" || bad "RESCRIPTUM_BOOT_DIR never reached the upgraded file — the feature would be invisible" +# **And a token already in the file is never replaced.** Regenerating it on upgrade would +# silently orphan every answer document carrying the old one, and the symptom would be a +# fleet quietly reinstalling itself. +grep -q "^RESCRIPTUM_ANSWER_TOKEN=keep-me-untouched-0123456789\$" "$ENV_FILE" && ok "and a secret already present is left alone" || bad "the top-up replaced a token that was already there" +grep -q "^RESCRIPTUM_ANSWER_TOKEN=keep-me-untouched-0123456789\$" "$ENV_FILE" && ok "and everything already there is untouched" || bad "the top-up changed a setting that was already present" +# The safety property: commenting a key out is how an operator says no, and it has to hold. +[ "$(grep -c "^RESCRIPTUM_MEDIA_DIR=" "$ENV_FILE")" = 0 ] && ok "a commented-out setting is respected rather than re-enabled" || bad "the top-up re-enabled a setting the operator had commented out" +# Every line the file had before must still be there, in order, at the top. The top-up +# only ever appends, so anything else means it rewrote somebody's configuration. +printf '%s\n' "$before" >"$WORK/before.txt" +head -n "$(wc -l <"$WORK/before.txt")" "$ENV_FILE" >"$WORK/after-head.txt" +cmp -s "$WORK/before.txt" "$WORK/after-head.txt" && ok "the original lines survive verbatim, in order" || bad "the top-up rewrote the existing content: $(diff "$WORK/before.txt" "$WORK/after-head.txt" | head -3)" +mode=$(stat -c '%a' "$ENV_FILE" 2>/dev/null || stat -f '%Lp' "$ENV_FILE") +[ "$mode" = "600" ] && ok "and it is still mode 600 afterwards" || bad "the top-up left it mode $mode" + +# Running it twice must not append a second copy — postinst runs on every upgrade. +SYNOPKG_PKG_STATUS=UPGRADE sh "$ROOT/scripts/postinst" >/dev/null 2>&1 +n=$(grep -c "^RESCRIPTUM_BOOT_DIR=" "$ENV_FILE") +[ "$n" = 1 ] && ok "and a second upgrade does not append it again" || bad "RESCRIPTUM_BOOT_DIR appears $n times after two upgrades, not once" + +printf '%s\n' "$saved" >"$ENV_FILE" +chmod 600 "$ENV_FILE" section "install without a wizard (silent_install, or a reinstall that shows none)" saved=$(cat "$ENV_FILE") @@ -145,6 +225,29 @@ out=$(sss start 2>&1) rc=$? [ $rc -eq 0 ] && ok "start returns 0" || bad "start returned $rc: $out" [ -d "$SHARE/answers" ] && ok "start created the answers directory inside the share" || bad "no answers directory — DSM creates the share, not this" +# Made whether or not the env file names them yet: a folder that only appears once a +# setting is enabled is one nobody discovers. +[ -d "$SHARE/media" ] && ok "and the media folder, ready for an ISO" || bad "no $SHARE/media" +[ -d "$SHARE/boot" ] && ok "and the boot folder, which is what TFTP hands loaders out of" || bad "no $SHARE/boot" + +# **The package has to arrive with loaders in it.** A TFTP server with nothing to hand out +# boots nothing, and an install that leaves this folder empty is an appliance that does not +# work until somebody finds a second download. They are iPXE, GPLv2, separate files never +# linked into our binary — mere aggregation, with the NOTICE as the written offer. +if [ -f "$ROOT/target/boot/ipxe-undionly.kpxe" ]; then + ok "the package carries the loaders" + [ -f "$SHARE/boot/ipxe-undionly.kpxe" ] && ok "and start put them where TFTP serves from" || bad "start did not seed $SHARE/boot" + [ -f "$SHARE/boot/NOTICE" ] && ok "with the GPLv2 notice beside them" || bad "no NOTICE in $SHARE/boot — the written offer has to travel with the binaries" + # The server's own opinion, which is the one that matters: does this directory satisfy + # the table `boot dhcp-snippet` writes into somebody's DHCP server? + if RESCRIPTUM_BOOT_DIR="$SHARE/boot" RESCRIPTUM_TFTP_ADDR=off "$ROOT/target/bin/rescriptum" boot check >/dev/null 2>&1; then + ok "and the server agrees the set is complete" + else + bad "boot check refuses the seeded folder: $(RESCRIPTUM_BOOT_DIR="$SHARE/boot" RESCRIPTUM_TFTP_ADDR=off "$ROOT/target/bin/rescriptum" boot check 2>&1 | grep -E 'MISSING|BROKEN' | head -2)" + fi +else + note "this .spk carries no loaders — build them with packaging/ipxe/build.sh and repack to test the seeding" +fi answered=no for _ in 1 2 3 4 5 6 7 8 9 10; do @@ -191,7 +294,8 @@ cp "$WORK/env.good" "$ENV_FILE" section "an upgrade must not touch a hand-edited configuration" printf 'RESCRIPTUM_LOG=problems\nRESCRIPTUM_ANSWER_TOKEN=a-token-nobody-should-lose\n' >>"$ENV_FILE" cp "$ENV_FILE" "$WORK/env.handedited" -echo "do not delete me" >"$SHARE/answers/canary.toml" +mkdir -p "$SHARE/answers/canary" +echo "do not delete me" >"$SHARE/answers/canary/proxmox.toml" UPG="$WORK/upgrade" export SYNOPKG_TEMP_UPGRADE_FOLDER="$UPG" @@ -211,7 +315,7 @@ upgrade() { # upgrade no diff -q "$WORK/env.handedited" "$ENV_FILE" >/dev/null && ok "etc/ survives: the file is untouched, wizard values and all" || bad "the upgrade rewrote the user's env file" -[ -f "$SHARE/answers/canary.toml" ] && ok "the canary in the share survived" || bad "the upgrade destroyed a file in the share" +[ -f "$SHARE/answers/canary/proxmox.toml" ] && ok "the canary in the share survived" || bad "the upgrade destroyed a file in the share" upgrade yes diff -q "$WORK/env.handedited" "$ENV_FILE" >/dev/null && ok "etc/ wiped: postinst restored it from the upgrade folder rather than writing defaults" || bad "the user's configuration was replaced by defaults — postinst runs BEFORE postupgrade" @@ -309,6 +413,48 @@ out=$(cgi POST "action=save" "RESCRIPTUM_ADMIN_ADDR=127.0.0.1:8001" "1") [ "$(http_status "$out")" = "409" ] && ok "a write that would stop the server starting is refused" || bad "an unauthenticated admin API was accepted with $(http_status "$out")" [ "$(cat "$ENV_FILE")" = "$before" ] && ok "and the file is untouched" || bad "the refused write still changed the file" +# ── the images tab ───────────────────────────────────────────────────────────── +# **Everything here is a door, not a feature.** These actions start downloads and write +# into the media directory, so each one has to refuse what it should refuse. The +# catalogue itself is not exercised over the network — that would make this harness +# depend on five vendors being up — but the local half is, and so is every guard. +out=$(cgi GET "action=media" "" "") +[ "$(http_status "$out")" = "200" ] && ok "the images tab can read what is held" || bad "action=media got $(http_status "$out")" + +out=$(cgi GET "action=sources" "" "") +grep -q "proxmox-ve" <<<"$out" && ok "and the catalogues it can fetch from" || bad "action=sources listed nothing: $out" + +# A download writes to disk, so it is a write and needs the header a cross-origin page +# cannot make a browser send. +out=$(cgi GET "action=fetch&source=proxmox-ve&name=x.iso" "" "") +[ "$(http_status "$out")" = "405" ] && ok "a download refuses to be a GET" || bad "action=fetch as GET got $(http_status "$out")" +out=$(cgi POST "action=fetch&source=proxmox-ve&name=x.iso" "" "") +[ "$(http_status "$out")" = "403" ] && ok "and refuses a POST without X-Rescriptum" || bad "action=fetch without intent got $(http_status "$out")" + +# **A URL without its digest is refused before anything is downloaded.** That rule is the +# reason `media add` exists in the shape it does: this decides what every machine on the +# network installs. +out=$(cgi POST "action=fetch&url=https://example.invalid/x.iso" "" "1") +[ "$(http_status "$out")" = "400" ] && ok "a URL with no SHA-256 is refused" || bad "a URL with no digest got $(http_status "$out")" +out=$(cgi POST "action=fetch&url=https://example.invalid/x.iso&sha256=nothex" "" "1") +[ "$(http_status "$out")" = "400" ] && ok "and so is one that is not a digest" || bad "a bad digest got $(http_status "$out")" +out=$(cgi POST "action=fetch&url=ftp://example.invalid/x.iso&sha256=$(printf 'a%.0s' $(seq 64))" "" "1") +[ "$(http_status "$out")" = "400" ] && ok "and a scheme nothing here can fetch" || bad "an ftp URL got $(http_status "$out")" +# Names become part of a command line and of a path. +out=$(cgi POST "action=fetch&source=proxmox-ve&name=../../etc/passwd" "" "1") +[ "$(http_status "$out")" = "400" ] && ok "an image name that is a path is refused" || bad "a traversing name got $(http_status "$out")" + +out=$(cgi GET "action=progress" "" "") +grep -qE "^state: (idle|running)$" <<<"$out" && ok "progress answers even when nothing is running" || bad "action=progress said: $out" + +out=$(cgi POST "action=prepare&id=../escape" "" "1") +[ "$(http_status "$out")" = "400" ] && ok "prepare refuses an id that is a path" || bad "prepare took a traversing id: $(http_status "$out")" +# A well-formed id for an image that is not here: the action has to reach the CLI and +# report its refusal, rather than crashing or claiming success. That is the whole wiring. +out=$(cgi POST "action=prepare&id=not-here" "" "1") +[ "$(http_status "$out")" = "200" ] && grep -q -- "--- exit" <<<"$out" && ok "prepare reaches the CLI and reports what it said" || bad "prepare did not reach the CLI: $out" +grep -q -- "--- exit 0" <<<"$out" && bad "prepare claimed success for an image that is not there" || ok "and does not claim success for an image that is not there" + out=$(cgi GET "action=nonsense" "" "") [ "$(http_status "$out")" = "400" ] && ok "an unknown action is refused" || bad "an unknown action got $(http_status "$out")" @@ -318,13 +464,19 @@ grep -q '^version: rescriptum' <<<"$out" && ok "status reports the version" || b # service's user, which read the CGI's stdin and waited on it forever. The CGI already # *is* that user, so a plain test is both possible and correct. grep -q '^answers_readable: yes' <<<"$out" && ok "and can tell that the answers folder is readable" || bad "status says the answers folder is unreadable: $out" +# **A TFTP port that cannot be bound does not stop the server**, so this line is the only +# place an operator sees it after the startup warning has scrolled away. In this harness +# nothing has bound port 69 and nothing could, so the honest answer is one of the two +# not-working states — what must never happen is silence or a claim that it is fine. +grep -qE '^tftp: (serving|broken|silent|off)$' <<<"$out" && ok "and says whether a loader can actually be handed over" || bad "status has no usable tftp line: $out" +grep -q '^tftp: serving' <<<"$out" && bad "status claims TFTP is serving, with nothing bound to port 69" || ok "and does not claim to be serving when nothing is bound" # ── 5. uninstall ─────────────────────────────────────────────────────────────── section "uninstall must leave the answers alone" unset SYNOPKG_TEMP_UPGRADE_FOLDER SYNOPKG_PKG_STATUS=UNINSTALL sh "$ROOT/scripts/preuninst" >/dev/null 2>&1 SYNOPKG_PKG_STATUS=UNINSTALL sh "$ROOT/scripts/postuninst" >/dev/null 2>&1 -[ -f "$SHARE/answers/canary.toml" ] && ok "the share and everything in it survived the uninstall" || bad "the uninstall took the user's answers with it" +[ -f "$SHARE/answers/canary/proxmox.toml" ] && ok "the share and everything in it survived the uninstall" || bad "the uninstall took the user's answers with it" [ -d "$SHARE" ] && ok "the shared folder itself is still there" || bad "the shared folder was removed" echo diff --git a/packaging/dsm/make-spk.sh b/packaging/dsm/make-spk.sh index c33c627..dc28410 100755 --- a/packaging/dsm/make-spk.sh +++ b/packaging/dsm/make-spk.sh @@ -66,6 +66,17 @@ abi_target() { ABI="" BIN="" +# The branded iPXE loaders, which **ship inside the package**. A TFTP server with nothing +# to hand out boots nothing, and telling somebody to go and download a second archive is +# how a working appliance becomes a support thread. They are iPXE and iPXE is GPLv2: they +# are separate files here, never linked into our binary, which is mere aggregation — and +# the NOTICE beside them, naming the pinned commit and packaging/ipxe/, is the written +# offer that has to travel with them. +# +# Empty is allowed and only means a package whose boot folder starts empty; check-spk.sh +# says so rather than failing, because a developer wrapping a local build should not need +# a C toolchain first. +LOADERS="${RESCRIPTUM_LOADERS:-$REPO/packaging/ipxe/out}" VERSION="" SPK_BUILD=1 OUT="$REPO/dist" @@ -74,6 +85,7 @@ while [ $# -gt 0 ]; do case "$1" in -h | --help) usage 0 ;; --bin) BIN="$2"; shift 2 ;; + --loaders) LOADERS="$2"; shift 2 ;; --version) VERSION="$2"; shift 2 ;; --spk-build) SPK_BUILD="$2"; shift 2 ;; --out) OUT="$2"; shift 2 ;; @@ -119,6 +131,17 @@ mkdir -p "$PAYLOAD/bin" cp "$BIN" "$PAYLOAD/bin/rescriptum" chmod 755 "$PAYLOAD/bin/rescriptum" cp -R "$HERE/payload/." "$PAYLOAD/" + +# The loaders, if there are any to carry. `start` seeds the share's boot folder from here. +if [ -d "$LOADERS" ] && [ -f "$LOADERS/ipxe-undionly.kpxe" ]; then + mkdir -p "$PAYLOAD/boot" + cp "$LOADERS"/* "$PAYLOAD/boot/" + chmod 644 "$PAYLOAD/boot"/* + echo " loaders: $(ls "$PAYLOAD/boot" | wc -l | tr -d ' ') file(s) from $LOADERS" +else + echo " loaders: none in $LOADERS — the package will install with an empty boot folder" >&2 + echo " build them with packaging/ipxe/build.sh --out $REPO/packaging/ipxe/out" >&2 +fi chmod 755 "$PAYLOAD/bin/rescriptum-cli" # The desktop application's backend. Said explicitly rather than trusted to survive a # checkout, an archive and a copy: a CGI that arrives without its execute bit is served to diff --git a/packaging/dsm/payload/port_conf/rescriptum.sc b/packaging/dsm/payload/port_conf/rescriptum.sc index 775e91c..ff38fc8 100644 --- a/packaging/dsm/payload/port_conf/rescriptum.sc +++ b/packaging/dsm/payload/port_conf/rescriptum.sc @@ -1,5 +1,5 @@ [rescriptum] title="rescriptum" -desc="Unattended-installation answer server" +desc="Unattended-installation answer server, the installer media it serves, and the loader it hands out" port_forward="no" -dst.ports="8000/tcp" +dst.ports="8000/tcp 8001/tcp 69/udp 30000:30063/udp" diff --git a/packaging/dsm/payload/ui/api.cgi b/packaging/dsm/payload/ui/api.cgi index 3d617d2..002f26c 100755 --- a/packaging/dsm/payload/ui/api.cgi +++ b/packaging/dsm/payload/ui/api.cgi @@ -194,6 +194,142 @@ status) else echo "answers_readable: no" fi + + # **The one state this panel has to surface that nothing else does.** A TFTP port + # that cannot be bound deliberately does not stop the server — port 69 needs a + # `setcap` that an upgrade silently drops, and answers must not go down to report + # that — so the only trace an operator would otherwise have is a startup warning that + # scrolled past hours ago. `boot check` asks the port for a real loader rather than + # trying to bind it, because binding proves the opposite of what it looks like: a + # bind that succeeds means nothing is listening. + case "$("$CLI" boot check 2>/dev/null)" in + *"handed over"*) echo "tftp: serving" ;; + *"BROKEN"*) echo "tftp: broken" ;; + *"TFTP is off"*) echo "tftp: off" ;; + *"nothing is listening"*) echo "tftp: silent" ;; + *) echo "tftp: unknown" ;; + esac + ;; + +media) + # Everything the `media` tab reads, as the CLI's own text. Passing it through rather + # than reformatting means the panel and the command line can never disagree about + # what is held — the same reason `config` passes `--json` through. + reply 200 "text/plain; charset=utf-8" + "$CLI" media list 2>&1 + ;; + +sources) + # With no `source` parameter this lists the catalogues, which is local and instant. + # With one it fetches that vendor's index over the network, which is not: the browser + # is told to expect a wait, and a failure here is the vendor's or the uplink's rather + # than ours. + which=$(param source) + reply 200 "text/plain; charset=utf-8" + if [ -n "$which" ]; then + "$CLI" media sources "$which" 2>&1 + else + "$CLI" media sources 2>&1 + fi + ;; + +fetch) + require_write_intent + # **A CGI cannot hold a request open for 1.5 GB**, so this starts the download and + # returns immediately; `progress` below is how the page follows it. Three details are + # each a trap already paid for elsewhere in this package: + # + # * `/dev/null)" 2>/dev/null; then + fail 409 "A download is already running." + fi + src=$(param source) + name=$(param name) + url=$(param url) + digest=$(param sha256) + : >"$VAR/fetch.log" + if [ -n "$url" ]; then + case "$url" in + https://* | http://*) ;; + *) fail 400 "That is not an http or https URL." ;; + esac + # 64 hex characters, checked here so an obvious typo is answered now rather than + # after a gigabyte. The CLI checks it again, which is where it counts. + case "$digest" in + '') fail 400 "A URL needs its SHA-256 — that decides what every machine installs." ;; + *[!0-9a-fA-F]*) fail 400 "That is not a SHA-256." ;; + esac + [ "${#digest}" -eq 64 ] || fail 400 "A SHA-256 is 64 hexadecimal characters." + setsid "$CLI" media add "$url" --sha256 "$digest" >"$VAR/fetch.log" 2>&1 "$VAR/fetch.pid" + reply 200 "text/plain; charset=utf-8" + echo "started $url" + else + case "$src" in + '' | *[!a-zA-Z0-9._-]*) fail 400 "That is not a source." ;; + esac + case "$name" in + '' | *[!a-zA-Z0-9._-]*) fail 400 "That is not an image name." ;; + esac + setsid "$CLI" media add --from "$src" "$name" >"$VAR/fetch.log" 2>&1 "$VAR/fetch.pid" + reply 200 "text/plain; charset=utf-8" + echo "started $name" + fi + ;; + +progress) + # **Progress without a progress protocol.** `media add` writes into `.part` and + # renames it atomically when the digest checks out, so the partial file's size *is* + # the progress and its disappearance *is* the completion. Nothing had to be invented + # for the browser, and nothing can disagree with what the CLI actually did. + reply 200 "text/plain; charset=utf-8" + pid=$(cat "$VAR/fetch.pid" 2>/dev/null) + if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then + echo "state: running" + else + echo "state: idle" + fi + dir=$("$CLI" config --value RESCRIPTUM_MEDIA_DIR 2>/dev/null) + if [ -n "$dir" ]; then + for part in "$dir"/*.part; do + [ -f "$part" ] || continue + echo "partial: $(basename "$part" .part) $(wc -c <"$part" | tr -d " ")" + done + fi + # The tail rather than the whole thing: curl writes a progress bar, and the last of it + # is the part that says what went wrong. + echo "--- log" + tail -c 2000 "$VAR/fetch.log" 2>/dev/null | tr "\r" "\n" | grep -v "^ *$" | tail -6 + ;; + +prepare) + require_write_intent + # Proxmox only, and the CLI is what refuses the rest — with the reason, which the + # panel shows verbatim. Duplicating that rule here would be a second implementation + # to keep honest. + id=$(param id) + case "$id" in + '' | *[!a-zA-Z0-9._:-]*) fail 400 "That is not an image id." ;; + esac + out=$("$CLI" media prepare "$id" 2>&1) + code=$? + reply 200 "text/plain; charset=utf-8" + echo "$out" + echo "--- exit $code" ;; check) diff --git a/packaging/dsm/payload/ui/rescriptum.js b/packaging/dsm/payload/ui/rescriptum.js index cdcbe0a..90121f0 100644 --- a/packaging/dsm/payload/ui/rescriptum.js +++ b/packaging/dsm/payload/ui/rescriptum.js @@ -122,10 +122,19 @@ Ext.ns('SYNO.SDS.App.Rescriptum'); bodyStyle: 'padding: 12px 16px', html: '' }); + /* Plain, not a form: this one is a list with buttons rather than labelled + * fields, so the form layout that `statusPanel` needs would buy nothing. */ + this.mediaPanel = new Ext.Panel({ + border: false, + autoScroll: true, + bodyStyle: 'padding: 12px 16px', + items: [] + }); this.tabButtons = { settings: new SYNO.ux.Button({ text: 'Settings', toggleGroup: 'rescriptum-tabs', allowDepress: false, pressed: true, handler: function () { self.showView('settings'); } }), status: new SYNO.ux.Button({ text: 'Status', toggleGroup: 'rescriptum-tabs', allowDepress: false, handler: function () { self.showView('status'); } }), + media: new SYNO.ux.Button({ text: 'Images', toggleGroup: 'rescriptum-tabs', allowDepress: false, handler: function () { self.showView('media'); } }), log: new SYNO.ux.Button({ text: 'Log', toggleGroup: 'rescriptum-tabs', allowDepress: false, handler: function () { self.showView('log'); } }) }; this.saveButton = new SYNO.ux.Button({ text: 'Save', handler: function () { self.save(); } }); @@ -141,7 +150,7 @@ Ext.ns('SYNO.SDS.App.Rescriptum'); border: false, layout: 'card', activeItem: 0, - items: [this.settingsPanel, this.statusPanel, this.logPanel] + items: [this.settingsPanel, this.statusPanel, this.mediaPanel, this.logPanel] }); config = Ext.apply({ @@ -156,7 +165,7 @@ Ext.ns('SYNO.SDS.App.Rescriptum'); maximizable: true, minimizable: true, layout: 'fit', - tbar: [this.tabButtons.settings, this.tabButtons.status, this.tabButtons.log], + tbar: [this.tabButtons.settings, this.tabButtons.status, this.tabButtons.media, this.tabButtons.log], items: [this.deck], buttons: [this.saveButton, this.reloadButton, this.closeButton] }, config); @@ -220,6 +229,7 @@ Ext.ns('SYNO.SDS.App.Rescriptum'); relabel: function () { this.tabButtons.settings.setText(this.t('settings')); this.tabButtons.status.setText(this.t('status')); + this.tabButtons.media.setText(this.t('media')); this.tabButtons.log.setText(this.t('log')); this.saveButton.setText(this.t('save')); this.reloadButton.setText(this.t('reload')); @@ -492,6 +502,242 @@ Ext.ns('SYNO.SDS.App.Rescriptum'); // ---- the three views --------------------------------------------------- + // ---- images ---------------------------------------------------------- + + /* Three sections, and the order is the order somebody works in: what is held, + * what can be fetched, and the manual way in that must never disappear. */ + loadMedia: function () { + var self = this; + var panel = this.mediaPanel; + panel.removeAll(true); + panel.add(new Ext.Panel({ border: false, html: '
' + self.t('loading') + '
' })); + panel.doLayout(); + + this.call('media', { + success: function (text) { + panel.removeAll(true); + panel.add(self.section(self.t('held'), text)); + + /* **Preparing is what turns an image into an unattended install**, and + * it is the step nobody guesses at — so it sits directly under the list + * it acts on rather than behind a menu. The ids come from that same + * listing, parsed here for the same reason the source list is: the + * panel and the command line must not disagree about what is held. */ + var ids = []; + Ext.each(String(text).split('\n'), function (line) { + var m = /^([A-Za-z0-9][A-Za-z0-9._:-]*)\s\s+\S/.exec(line); + if (m && m[1] !== 'ID') { ids.push([m[1]]); } + }); + self.prepareCombo = new SYNO.ux.ComboBox({ + fieldLabel: self.t('image'), + width: 420, + editable: false, + triggerAction: 'all', + mode: 'local', + valueField: 'id', + displayField: 'id', + store: new Ext.data.ArrayStore({ fields: ['id'], data: ids }) + }); + panel.add(new SYNO.ux.FormPanel({ + border: false, labelWidth: 120, bodyStyle: 'padding: 4px 0 12px 0', + items: [ + new Ext.Panel({ border: false, html: '' + Ext.util.Format.htmlEncode(self.t('prepare')) + '
' + Ext.util.Format.htmlEncode(self.t('prepare_hint')) + '
' }), + self.prepareCombo, + new SYNO.ux.Button({ text: self.t('prepare_do'), handler: function () { self.prepareImage(); } }) + ] + })); + self.prepareResult = new Ext.Panel({ border: false, html: '' }); + panel.add(self.prepareResult); + + /* The catalogue picker. The source list is local and instant; asking + * one what it offers goes over the network to the vendor, so it is a + * second click rather than something done for every source on open. */ + self.sourceCombo = new SYNO.ux.ComboBox({ + fieldLabel: self.t('source'), + width: 260, + editable: false, + triggerAction: 'all', + mode: 'local', + valueField: 'id', + displayField: 'label', + store: new Ext.data.ArrayStore({ fields: ['id', 'label'], data: [] }) + }); + self.offerCombo = new SYNO.ux.ComboBox({ + fieldLabel: self.t('image'), + width: 420, + editable: false, + triggerAction: 'all', + mode: 'local', + valueField: 'name', + displayField: 'name', + store: new Ext.data.ArrayStore({ fields: ['name'], data: [] }) + }); + self.sourceCombo.on('select', function (c, rec) { self.loadOffers(rec.get('id')); }); + + var fetchBtn = new SYNO.ux.Button({ + text: self.t('fetch'), + handler: function () { self.fetchImage(); } + }); + var urlField = new SYNO.ux.TextField({ fieldLabel: self.t('url'), width: 420 }); + var digestField = new SYNO.ux.TextField({ fieldLabel: self.t('digest'), width: 420 }); + self.urlField = urlField; + self.digestField = digestField; + + panel.add(new SYNO.ux.FormPanel({ + border: false, labelWidth: 120, bodyStyle: 'padding: 4px 0 12px 0', + items: [ + new Ext.Panel({ border: false, html: '' + Ext.util.Format.htmlEncode(self.t('catalogue')) + '
' + Ext.util.Format.htmlEncode(self.t('catalogue_hint')) + '
' }), + self.sourceCombo, self.offerCombo, fetchBtn + ] + })); + + /* **The manual way in, and it is not a lesser path.** A digest somebody + * obtained out of band is stronger evidence than one read from the same + * host as the image — so this stays in front of people rather than being + * documented as an escape hatch for the command line. */ + panel.add(new SYNO.ux.FormPanel({ + border: false, labelWidth: 120, bodyStyle: 'padding: 4px 0 12px 0', + items: [ + new Ext.Panel({ border: false, html: '' + Ext.util.Format.htmlEncode(self.t('manual')) + '
' + Ext.util.Format.htmlEncode(self.t('manual_hint')) + '
' }), + urlField, digestField, + new SYNO.ux.Button({ text: self.t('add'), handler: function () { self.addByUrl(); } }) + ] + })); + + self.progressPanel = new Ext.Panel({ border: false, html: '' }); + panel.add(self.progressPanel); + panel.doLayout(); + self.fillSources(); + self.pollProgress(); + } + }); + }, + + section: function (title, text) { + return new Ext.Panel({ + border: false, + html: '' + Ext.util.Format.htmlEncode(title) + '' + + '
' + Ext.util.Format.htmlEncode(text) + '
' + }); + }, + + /* `media sources` prints a table; the ids are its first column. Parsed here + * rather than served as JSON so the panel and the command line stay the same + * text — the rule the `config` action already follows. */ + fillSources: function () { + var self = this; + this.call('sources', { + success: function (text) { + var rows = []; + Ext.each(String(text).split('\n'), function (line) { + var m = /^([a-z0-9][a-z0-9._-]*)\s\s+(\S.*?)\s\s+/.exec(line); + if (m && m[1] !== 'SOURCE') { rows.push([m[1], m[2]]); } + }); + if (self.sourceCombo) { self.sourceCombo.getStore().loadData(rows); } + } + }); + }, + + loadOffers: function (id) { + var self = this; + this.offerCombo.getStore().loadData([]); + this.offerCombo.setValue(''); + this.banner(this.t('reading_index')); + this.call('sources', { + query: '&source=' + encodeURIComponent(id), + success: function (text) { + var rows = []; + Ext.each(String(text).split('\n'), function (line) { + var m = /^ {2}(\S+\.(?:iso|img))\s*$/i.exec(line); + if (m) { rows.push([m[1]]); } + }); + self.offerCombo.getStore().loadData(rows); + if (rows.length) { self.offerCombo.setValue(rows[0][0]); self.banner(''); } + else { self.banner(text); } + } + }); + }, + + fetchImage: function () { + var self = this; + var src = this.sourceCombo && this.sourceCombo.getValue(); + var name = this.offerCombo && this.offerCombo.getValue(); + if (!src || !name) { this.banner(this.t('pick_one')); return; } + this.call('fetch', { + query: '&source=' + encodeURIComponent(src) + '&name=' + encodeURIComponent(name), + body: '', + success: function () { self.banner(''); self.pollProgress(); } + }); + }, + + /* Proxmox only, and **the CLI is what refuses the rest** — with the sentence that + * says why, shown here 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 here as + * well would be a second implementation to keep honest. */ + prepareImage: function () { + var self = this; + var id = this.prepareCombo && this.prepareCombo.getValue(); + if (!id) { this.banner(this.t('pick_image')); return; } + this.prepareResult.update('
' + Ext.util.Format.htmlEncode(this.t('loading')) + '
'); + this.call('prepare', { + query: '&id=' + encodeURIComponent(id), + body: '', + success: function (text) { + self.prepareResult.update('
' + Ext.util.Format.htmlEncode(text) + '
'); + /* A prepared entry is a new row in the listing above, so re-read it — + * leaving a stale table in front of somebody who just changed it is + * how a working step looks like it did nothing. */ + if (/--- exit 0\s*$/.test(text)) { self.loadMedia(); } + } + }); + }, + + addByUrl: function () { + var self = this; + var url = (this.urlField && this.urlField.getValue() || '').replace(/^\s+|\s+$/g, ''); + var digest = (this.digestField && this.digestField.getValue() || '').replace(/^\s+|\s+$/g, ''); + if (!url) { this.banner(this.t('need_url')); return; } + this.call('fetch', { + query: '&url=' + encodeURIComponent(url) + '&sha256=' + encodeURIComponent(digest), + body: '', + success: function () { self.banner(''); self.pollProgress(); } + }); + }, + + /* Progress is the partial file's size, which `media add` is already writing — + * nothing had to be invented for the browser, and nothing here can disagree with + * what the CLI actually did. */ + pollProgress: function () { + var self = this; + this.stopPolling(); + var tick = function () { + self.call('progress', { + success: function (text) { + if (!self.progressPanel) { return; } + self.progressPanel.update('
' + Ext.util.Format.htmlEncode(text) + '
'); + if (/^state: running/m.test(text)) { + self.pollTimer = setTimeout(tick, 2000); + } else { + self.pollTimer = null; + /* Finished: the held list has changed, so re-read it once + * rather than leaving a stale table in front of somebody. */ + if (self.active === 'media' && self.sawRunning) { + self.sawRunning = false; + self.loadMedia(); + } + } + if (/^state: running/m.test(text)) { self.sawRunning = true; } + } + }); + }; + tick(); + }, + + stopPolling: function () { + if (this.pollTimer) { clearTimeout(this.pollTimer); this.pollTimer = null; } + }, + /* **Not `show`.** `Ext.Window.prototype.show()` is what DSM calls to display the * window, and defining a method of that name here silently overrode it: the window * was built, laid out and even rendered its taskbar preview, and then never @@ -499,17 +745,25 @@ Ext.ns('SYNO.SDS.App.Rescriptum'); * either DSM version. Anything added to this prototype shares a namespace with * every method of `Ext.Window`, and that is a large namespace. */ showView: function (which) { - var panel = which === 'status' ? this.statusPanel : (which === 'log' ? this.logPanel : this.settingsPanel); + var panel = this.settingsPanel; + if (which === 'status') { panel = this.statusPanel; } + if (which === 'log') { panel = this.logPanel; } + if (which === 'media') { panel = this.mediaPanel; } this.deck.getLayout().setActiveItem(panel); this.active = which; this.saveButton.setDisabled(which !== 'settings' || !this.writable); if (which === 'status') { this.loadStatus(); } if (which === 'log') { this.loadLog(); } + if (which === 'media') { this.loadMedia(); } + /* Polling only while the tab is in front. A timer left running behind a + * closed window is a request every two seconds, forever, on a NAS. */ + if (which !== 'media') { this.stopPolling(); } }, reload: function () { if (this.active === 'status') { this.loadStatus(); return; } if (this.active === 'log') { this.loadLog(); return; } + if (this.active === 'media') { this.loadMedia(); return; } this.loadConfig(); } }); diff --git a/packaging/dsm/payload/ui/texts/enu/strings b/packaging/dsm/payload/ui/texts/enu/strings index 4ee457a..a74d640 100644 --- a/packaging/dsm/payload/ui/texts/enu/strings +++ b/packaging/dsm/payload/ui/texts/enu/strings @@ -14,6 +14,26 @@ description = "Serve unattended-installation answers, composed per machine." settings = "Settings" status = "Status" log = "Log" +media = "Images" +held = "Images held" +catalogue = "From a catalogue" +catalogue_hint = "The list comes from each vendor's own checksum index, so it is current and the digests are theirs." +source = "Catalogue" +image = "Image" +fetch = "Download" +manual = "By URL" +manual_hint = "A digest you obtained yourself is stronger evidence than one read from the same host as the image." +url = "URL" +digest = "SHA-256" +add = "Add" +loading = "Reading…" +reading_index = "Reading the vendor's index…" +pick_one = "Choose a catalogue and an image first." +need_url = "A URL is needed." +prepare = "Prepare for unattended install" +prepare_hint = "Proxmox only: it is the one family that reads its answer's location from inside the image. Every other family takes it on the kernel command line and needs nothing here. Nothing is copied — this writes a few hundred bytes beside the image and applies them as it is served, so the file on disk stays exactly what the vendor published." +prepare_do = "Prepare" +pick_image = "Choose an image first." save = "Save" reload = "Reload" close = "Close" @@ -45,6 +65,20 @@ RESCRIPTUM_CAPTURE_DIR = "Capture folder" RESCRIPTUM_ANSWER_TOKEN = "Installer token" RESCRIPTUM_ADMIN_ADDR = "Write API address" RESCRIPTUM_ADMIN_TOKEN = "Write API token" +RESCRIPTUM_PUBLIC_HOST = "This server's address" +RESCRIPTUM_MEDIA_DIR = "Installer images folder" +RESCRIPTUM_MEDIA_ADDR = "Media listen address" +RESCRIPTUM_MEDIA_TIMEOUT_SECS = "Transfer deadline (seconds)" +RESCRIPTUM_MEDIA_MAX_CONNECTIONS = "Simultaneous downloads" +RESCRIPTUM_BOOT_ALLOW = "Allowed client networks" +RESCRIPTUM_INSTALLED_TOKEN = "Install-finished token" +RESCRIPTUM_BOOT_DIR = "Loaders folder" +RESCRIPTUM_TFTP_ADDR = "TFTP listen address" +RESCRIPTUM_BOOT_TIMEOUT_SECS = "Menu timeout (seconds)" +RESCRIPTUM_BOOT_LOGO = "Menu logo" +RESCRIPTUM_BOOT_TITLE = "Menu title" +RESCRIPTUM_USER = "Run as user" +RESCRIPTUM_GROUP = "Run as group" [help] RESCRIPTUM_STORE = "Where answers come from: a folder of documents, or a database." @@ -60,17 +94,36 @@ RESCRIPTUM_CAPTURE_DIR = "Record what installers actually send, for when nothing RESCRIPTUM_ANSWER_TOKEN = "Required of installers that have one to offer. Off by default." RESCRIPTUM_ADMIN_ADDR = "The write API's own listener. Keep it on loopback and reach it over SSH." RESCRIPTUM_ADMIN_TOKEN = "At least 16 characters, and required whenever the write API is on." +RESCRIPTUM_PUBLIC_HOST = "The address this NAS is reachable at, written into every generated script. A host, never a URL. Shown here is the one detected on the interface that reaches the network; set it yourself if the machines reach this NAS on another." +RESCRIPTUM_MEDIA_DIR = "Installer images. Empty means no media and no second listener. Nothing here ever modifies an image: this folder is the archive." +RESCRIPTUM_MEDIA_ADDR = "Where machines fetch kernels, initrds and images. Its own listener, because a download holds a connection for minutes and answers must not queue behind one." +RESCRIPTUM_MEDIA_TIMEOUT_SECS = "How long one image transfer may take. Deliberately not the answer endpoint's ten seconds." +RESCRIPTUM_MEDIA_MAX_CONNECTIONS = "Downloads at once. Low on purpose: each holds its slot for minutes, and this NAS has one disk." +RESCRIPTUM_BOOT_ALLOW = "Client networks allowed to fetch boot media, as CIDRs. Empty means anyone who can reach the port." +RESCRIPTUM_INSTALLED_TOKEN = "Generated when this file was written. Put the same string in a machine's answer as the post-installation-webhook auth-token, and the machine stops being claimed once it reports a successful install — instead of reinstalling itself on every reboot. Empty means no such endpoint at all." +RESCRIPTUM_BOOT_DIR = "Where the loaders live. This package ships them, so the folder arrives filled and an upgrade refreshes them. Served over TFTP and over HTTP at /boot/, which is what UEFI HTTP Boot fetches. Point this elsewhere to manage loaders yourself." +RESCRIPTUM_TFTP_ADDR = "Empty means 0.0.0.0:69, which is what every loader and every generated DHCP snippet expects. Port 69 is privileged, so binding it takes one root command once: setcap cap_net_bind_service=+ep on the binary. An upgrade drops it — a boot-up task in Task Scheduler makes it durable. Without it the server warns, keeps answering and keeps serving media, and only TFTP is down. Set 'off' if another daemon on this NAS hands loaders out instead." +RESCRIPTUM_BOOT_TIMEOUT_SECS = "How long the boot menu waits before a machine falls through to its own disk." +RESCRIPTUM_BOOT_LOGO = "A PNG shown behind the boot menu, replacing the built-in one." +RESCRIPTUM_BOOT_TITLE = "The boot menu's title bar, replacing the built-in one." +RESCRIPTUM_USER = "Not usable in this package: it already runs as its own unprivileged user and cannot change identity." +RESCRIPTUM_GROUP = "Not usable in this package, for the same reason as the user above." [status] version = "Version" package = "Package" answers = "Answers folder" answers_readable = "Readable by the service" +tftp = "Loader handover (TFTP)" [value] running = "running" stopped = "stopped" crashed = "crashed, and it left its pidfile behind" +serving = "serving loaders" +broken = "not serving — port 69 needs setcap, see the TFTP setting" +silent = "nothing listening — the server is stopped, or its bind failed" +off = "off — another daemon hands the loader over" unknown = "unknown" yes = "yes" no = "no" diff --git a/packaging/dsm/payload/ui/texts/fre/strings b/packaging/dsm/payload/ui/texts/fre/strings index 3838dbd..5ee369c 100644 --- a/packaging/dsm/payload/ui/texts/fre/strings +++ b/packaging/dsm/payload/ui/texts/fre/strings @@ -8,6 +8,26 @@ description = "Sert à chaque machine sa réponse d'installation, composée pour settings = "Réglages" status = "État" log = "Journal" +media = "Images" +held = "Images présentes" +catalogue = "Depuis un catalogue" +catalogue_hint = "La liste vient de l'index de sommes que chaque éditeur publie, elle est donc à jour et les empreintes sont les siennes." +source = "Catalogue" +image = "Image" +fetch = "Télécharger" +manual = "Par URL" +manual_hint = "Une empreinte que vous avez obtenue vous-même vaut mieux qu'une empreinte lue sur le même serveur que l'image." +url = "URL" +digest = "SHA-256" +add = "Ajouter" +loading = "Lecture…" +reading_index = "Lecture de l'index de l'éditeur…" +pick_one = "Choisissez d'abord un catalogue et une image." +need_url = "Il faut une URL." +prepare = "Préparer pour l'installation sans surveillance" +prepare_hint = "Proxmox uniquement : c'est la seule famille qui lit l'emplacement de sa réponse depuis l'intérieur de l'image. Toutes les autres la prennent sur la ligne de commande du noyau et n'ont besoin de rien ici. Rien n'est copié — quelques centaines d'octets sont écrits à côté de l'image et appliqués au fil de l'eau, donc le fichier sur disque reste exactement ce que l'éditeur a publié." +prepare_do = "Préparer" +pick_image = "Choisissez d'abord une image." save = "Enregistrer" reload = "Recharger" close = "Fermer" @@ -39,6 +59,20 @@ RESCRIPTUM_CAPTURE_DIR = "Dossier de capture" RESCRIPTUM_ANSWER_TOKEN = "Jeton des installateurs" RESCRIPTUM_ADMIN_ADDR = "Adresse de l'API d'écriture" RESCRIPTUM_ADMIN_TOKEN = "Jeton de l'API d'écriture" +RESCRIPTUM_PUBLIC_HOST = "Adresse de ce serveur" +RESCRIPTUM_MEDIA_DIR = "Dossier des images d'installation" +RESCRIPTUM_MEDIA_ADDR = "Adresse d'écoute des médias" +RESCRIPTUM_MEDIA_TIMEOUT_SECS = "Échéance de transfert (secondes)" +RESCRIPTUM_MEDIA_MAX_CONNECTIONS = "Téléchargements simultanés" +RESCRIPTUM_BOOT_ALLOW = "Réseaux clients autorisés" +RESCRIPTUM_INSTALLED_TOKEN = "Jeton de fin d'installation" +RESCRIPTUM_BOOT_DIR = "Dossier des chargeurs" +RESCRIPTUM_TFTP_ADDR = "Adresse d'écoute TFTP" +RESCRIPTUM_BOOT_TIMEOUT_SECS = "Délai du menu (secondes)" +RESCRIPTUM_BOOT_LOGO = "Logo du menu" +RESCRIPTUM_BOOT_TITLE = "Titre du menu" +RESCRIPTUM_USER = "Utilisateur d'exécution" +RESCRIPTUM_GROUP = "Groupe d'exécution" [help] RESCRIPTUM_STORE = "D'où viennent les réponses : un dossier de documents, ou une base de données." @@ -54,17 +88,36 @@ RESCRIPTUM_CAPTURE_DIR = "Enregistre ce que les installateurs envoient vraiment, RESCRIPTUM_ANSWER_TOKEN = "Exigé des installateurs qui en présentent un. Désactivé par défaut." RESCRIPTUM_ADMIN_ADDR = "L'écouteur propre à l'API d'écriture. À garder en loopback, joignable par SSH." RESCRIPTUM_ADMIN_TOKEN = "Au moins 16 caractères, et obligatoire dès que l'API d'écriture est active." +RESCRIPTUM_PUBLIC_HOST = "L'adresse à laquelle ce NAS est joignable, écrite dans chaque script généré. Un hôte, jamais une URL. Celle affichée ici est détectée sur l'interface qui atteint le réseau ; indiquez-la vous-même si les machines joignent ce NAS par une autre." +RESCRIPTUM_MEDIA_DIR = "Les images d'installation. Vide, pas de média ni de second listener. Rien ici ne modifie jamais une image : ce dossier est l'archive." +RESCRIPTUM_MEDIA_ADDR = "Là où les machines récupèrent noyaux, initrds et images. Son propre listener, car un téléchargement retient une connexion des minutes durant et les réponses ne doivent pas faire la queue derrière." +RESCRIPTUM_MEDIA_TIMEOUT_SECS = "Durée maximale d'un transfert d'image. Volontairement pas les dix secondes du point de réponse." +RESCRIPTUM_MEDIA_MAX_CONNECTIONS = "Téléchargements à la fois. Bas exprès : chacun retient sa place des minutes durant, et ce NAS a un disque." +RESCRIPTUM_BOOT_ALLOW = "Réseaux clients autorisés à récupérer les médias, en CIDR. Vide, quiconque atteint le port." +RESCRIPTUM_INSTALLED_TOKEN = "Généré à l'écriture de ce fichier. Mettez la même chaîne dans la réponse d'une machine comme auth-token du post-installation-webhook, et la machine cesse d'être revendiquée dès qu'elle signale une installation réussie — au lieu de se réinstaller à chaque redémarrage. Vide, l'endpoint n'existe pas du tout." +RESCRIPTUM_BOOT_DIR = "Où vivent les chargeurs. Ce paquet les fournit : le dossier arrive rempli, et une mise à jour les rafraîchit. Servis en TFTP et en HTTP sur /boot/, ce que récupère l'amorçage HTTP UEFI. Pointez ailleurs pour gérer les chargeurs vous-même." +RESCRIPTUM_TFTP_ADDR = "Vide signifie 0.0.0.0:69, ce qu'attendent tous les chargeurs et tous les extraits DHCP générés. Le port 69 est privilégié : l'ouvrir demande une commande root, une fois — setcap cap_net_bind_service=+ep sur le binaire. Une mise à jour la perd ; une tâche au démarrage dans le Planificateur de tâches la rend durable. Sans elle le serveur avertit, continue de répondre et de servir les images, et seul le TFTP est coupé. Mettez « off » si un autre service de ce NAS livre les chargeurs à sa place." +RESCRIPTUM_BOOT_TIMEOUT_SECS = "Combien de temps le menu de démarrage attend avant qu'une machine retombe sur son propre disque." +RESCRIPTUM_BOOT_LOGO = "Un PNG affiché derrière le menu de démarrage, à la place de celui intégré." +RESCRIPTUM_BOOT_TITLE = "La barre de titre du menu de démarrage, à la place de celle intégrée." +RESCRIPTUM_USER = "Inutilisable dans ce package : il tourne déjà sous son propre utilisateur non privilégié et ne peut pas changer d'identité." +RESCRIPTUM_GROUP = "Inutilisable dans ce package, pour la même raison que l'utilisateur ci-dessus." [status] version = "Version" package = "Paquet" answers = "Dossier des réponses" answers_readable = "Lisible par le service" +tftp = "Livraison du chargeur (TFTP)" [value] running = "en cours d'exécution" stopped = "arrêté" crashed = "planté, en laissant son fichier de pid" +serving = "livre les chargeurs" +broken = "ne livre rien — le port 69 demande un setcap, voir le réglage TFTP" +silent = "personne n'écoute — serveur arrêté, ou liaison échouée" +off = "désactivé — un autre service livre le chargeur" unknown = "inconnu" yes = "oui" no = "non" diff --git a/packaging/dsm/scripts/postinst b/packaging/dsm/scripts/postinst index 31776f7..f51cb87 100755 --- a/packaging/dsm/scripts/postinst +++ b/packaging/dsm/scripts/postinst @@ -37,10 +37,50 @@ EXAMPLE="$DEST/etc/$PKG.env.example" SC_FILE="$DEST/port_conf/$PKG.sc" SHARE_ANSWERS="$ROOT/shares/$PKG/answers" +SHARE_MEDIA="$ROOT/shares/$PKG/media" +SHARE_BOOT="$ROOT/shares/$PKG/boot" DEFAULT_PORT=8000 +# Fixed, and a contract rather than a preference: the loader we ship embeds a script +# that chains to `${next-server}:8001` before any deployment exists, so a media listener +# anywhere else is one every already-shipped loader cannot reach. +MEDIA_PORT=8001 +# TFTP's, and not a preference either: a PXE ROM has 69 burned into it, so this is the +# one port in the design that cannot move without changing the client. +TFTP_PORT=69 +# **The ports transfers answer from, and they have to be pinned here.** A TFTP transfer +# leaves port 69 immediately — the server replies from a fresh port and the client +# acknowledges to *that* — so a firewall that allows only 69 lets the request in, lets the +# answer out, and drops the acknowledgement. The symptom is a machine that says +# "Downloading NBP file" and then nothing, which is as hard to read as this protocol gets. +# +# Chosen against a real DSM rather than picked: below the kernel's own ephemeral range +# (32768–60999, so nothing here can be handed to something else), clear of every UDP port +# in use on a stock install (68, 123, 137, 138, 161, 323, 1900, 3702, 5353, 9997–9999), +# claimed by no Synology .sc, and all 64 verified bindable. Sixty-four is MAX_TRANSFERS, +# so the range cannot be the thing that runs out first. +TFTP_DATA_FIRST=30000 +TFTP_DATA_LAST=30063 say() { echo "$PKG: $*"; } +# **A secret nobody has to invent.** The install-finished webhook only works when the +# token in the answer document and the one this server checks are the same string — and +# two places that must agree, both left blank, is a configuration people get wrong once +# and then debug as "the machine reinstalls itself". Generating one here means the server +# half is already right, and the answer half is a copy rather than a decision. +# +# `od` rather than `openssl rand` or `base64 /dev/urandom`: all three exist on DSM 7.2.2, +# but od is POSIX and is the one most likely to exist on the oldest thing this package is +# allowed to install on. 128 bits of hex, which is not guessable and survives being +# pasted into a TOML string without quoting questions. +# +# Empty output is possible in principle (no /dev/urandom, a stripped od), and an empty +# token would be a webhook endpoint anybody could call. So the caller checks, and writes +# the setting commented out rather than live if there is nothing to write. +generate_token() { + od -An -tx1 -N16 /dev/urandom 2>/dev/null | tr -d ' \n' +} + # Wizard values arrive as environment variables named after the component keys — and every # one of them has to be read as "possibly absent, with a default". silent_install exists, # and a reinstall may present no wizard at all; a package that only works when someone @@ -65,6 +105,20 @@ fi # The file, in full, for both the live copy and the example. Every path is explicit: # RESCRIPTUM_ANSWERS_DIR defaults to /srv/answers and RESCRIPTUM_DB_PATH to # /srv/answers.db, neither of which exists on DSM nor could be created by this user. +# One per run of this script, so the example file and the live file say the same thing. +# It only ever *reaches* the live file when that file is being created, or when the +# top-up finds the key absent — an existing token is never replaced. +INSTALLED_TOKEN=$(generate_token) +# **An empty token would be an endpoint anybody can call**, so nothing is better than +# something here: the setting goes in commented out, the feature stays off, and the +# comment above it still explains what to do. Only reachable if /dev/urandom or od is +# missing, which is not a thing this package should decide to ship a hole over. +if [ -n "$INSTALLED_TOKEN" ]; then + INSTALLED_LINE="RESCRIPTUM_INSTALLED_TOKEN=$INSTALLED_TOKEN" +else + INSTALLED_LINE="# RESCRIPTUM_INSTALLED_TOKEN= # could not generate one here; pick your own" +fi + env_body() { cat < Task Scheduler +# > Create > Triggered Task > User-defined script, user root, event Boot-up, with the +# setcap line as the script. Then run it once by hand from that page. +# +# Without the capability nothing breaks except TFTP itself: the server warns at startup, +# keeps answering, keeps serving media, and 'rescriptum-cli boot check' exits non-zero +# and says so. That is deliberate — an upgrade must not take a fleet's answers down to +# report that a second port could not be opened. +# +# RESCRIPTUM_TFTP_ADDR is left unset on purpose: the default is 0.0.0.0:69, which is +# what the generated DHCP snippet and every loader we ship expect. Set it to another +# port to avoid the capability entirely (your DHCP server must then be told), or to +# 'off' if some other daemon on this NAS is already handing out loaders — DSM has its +# own TFTP server under Control Panel > File Services > Advanced, and pointing it at the +# boot folder below is a working alternative rather than the intended one. +# RESCRIPTUM_TFTP_ADDR=0.0.0.0:69 + +# The ports a transfer answers from. **Set, not commented, and the firewall entry above +# covers them** — left to the kernel they would land somewhere no rule allows, and every +# transfer would die at the handshake with the client looking like it lost interest. +RESCRIPTUM_TFTP_PORT_RANGE=$TFTP_DATA_FIRST-$TFTP_DATA_LAST + +# Where the loaders live — **and this package ships them**, so the folder is already +# filled in when you first start it. They are iPXE, GPLv2, separate files never linked +# into our binary; the NOTICE beside them names the exact upstream commit they were built +# from. Served over TFTP from the address above *and* over HTTP at /boot/ on port +# $MEDIA_PORT, which is what UEFI HTTP Boot fetches and where the boot menu looks for its +# logo. 'rescriptum-cli boot check' says whether the set is complete and whether a loader +# can actually be handed over. +# +# An upgrade refreshes the files this package ships and touches nothing else in the +# folder. If you manage loaders yourself, point this somewhere else and nothing will ever +# write to it. +RESCRIPTUM_BOOT_DIR=$SHARE_BOOT + +# --------------------------------------------------------------------------- +# Installing a machine once, and only once. +# +# A machine claimed by a '.ipxe' answer boots the installer every time it starts — +# including the reboot right after it finished installing, which wipes the disk and does +# it again. Forever. +# +# The machine is what knows it is done. Proxmox calls a webhook after a successful +# install and *before* that reboot; put this in the machine's answer document: +# +# [post-installation-webhook] +# url = "http://:$port/installed" +# auth-token = "$INSTALLED_TOKEN" +# +# and rescriptum renames the claim out of the way, so the machine boots its own disk from +# then on. Re-arming it later is renaming that file back. Every other family reports the +# same thing from its own post-install script with one curl — see the guide. +# +# **The token below was generated when this file was written**, so the server half is +# already right and the answer document only has to copy it. Both must be the same string. +# Change it if you like; change it in both places. +$INSTALLED_LINE BODY } @@ -146,13 +303,67 @@ else say "wrote $ENV_FILE" fi +# **A setting this version introduced has to reach an installation that already exists.** +# The live file is written only when absent, which is right — an upgrade must never +# replace somebody's port and tokens with defaults. But taken alone it means a new feature +# is *invisible* to every installation that predates it: boot media shipped with the folders +# created, the loaders seeded and the firewall port registered, and RESCRIPTUM_BOOT_DIR +# never arriving, so `boot check` said "boot assets are off" on a NAS that had everything +# else in place. Found on a real DS416j upgrading from a version without boot media, and +# the .env.example was no help because nothing makes anybody read it. +# +# So: keys the live file has **never heard of** are appended. Nothing present is touched, +# in any way, ever. +# +# **A commented-out key counts as present**, and that is the whole safety property: it is +# how an operator says "I know about this one and I do not want it". Deleting a line means +# "never heard of it" and gets it back; commenting it out means "no" and is respected. +if [ -f "$ENV_FILE" ]; then + added="" + # Only live keys. The commented ones in the template are documentation, and appending + # a wall of them to somebody's working file would be noise rather than help. + env_body "$SHARE_ANSWERS" "$DEFAULT_PORT" | grep -E '^[A-Z_]+=' | while IFS= read -r line; do + key=${line%%=*} + # `#\?` so a deliberately commented setting is left alone. + if ! grep -qE "^[[:space:]]*#?[[:space:]]*$key=" "$ENV_FILE"; then + echo "$line" + fi + done >"$ENV_FILE.new" 2>/dev/null + if [ -s "$ENV_FILE.new" ]; then + added=$(cut -d= -f1 <"$ENV_FILE.new" | tr '\n' ' ') + { + echo + echo "# Added by the $PKG ${SYNOPKG_PKGVER:-upgrade} upgrade: settings this file had" + echo "# never heard of. Nothing above was touched. Comment one out to turn it off —" + echo "# a commented key is respected, a deleted one comes back." + cat "$ENV_FILE.new" + } >>"$ENV_FILE" + # **We just wrote to a file holding an admin token, so restate the mode.** The + # append itself preserves it, but a file that arrived from an older version — or + # from a hand-edit — may never have been 0600, and the moment to fix that is the + # moment we are already touching it. The harness asserts the outcome, not the + # append, precisely so this cannot regress unnoticed. + chmod 600 "$ENV_FILE" + say "added new settings to $ENV_FILE: $added" + fi + rm -f "$ENV_FILE.new" +fi + # The .sc file is static and the port is not, so write the chosen one in. Whether the # port-config worker acquires this before or after this script runs decides whether it # reaches the firewall entry on a fresh install; either way, changing the port later means # sudo /usr/syno/sbin/synopkghelper update rescriptum port-config # because Acquire skips a file that already exists in /usr/local/etc/service.d/. if [ -f "$SC_FILE" ]; then - sed "s|^dst.ports=.*|dst.ports=\"$port/tcp\"|" "$SC_FILE" >"$SC_FILE.new" && + # All three listeners, and every one of them is registered whether or not it is + # currently enabled: registering a port does not open it, and the alternative is an + # operator who turns boot media on and then cannot find rescriptum in the firewall + # list. **69/udp is the one worth naming here** — a machine PXE-booting asks over + # UDP broadcast, and a firewall that drops it produces a client that retries and + # times out with nothing in any log on this side. + # The protocol rides on the port, which is the shape the tcp entries already use — + # not a second dst.udp.ports key, which would be a guess. + sed "s|^dst.ports=.*|dst.ports=\"$port/tcp $MEDIA_PORT/tcp $TFTP_PORT/udp $TFTP_DATA_FIRST:$TFTP_DATA_LAST/udp\"|" "$SC_FILE" >"$SC_FILE.new" && mv "$SC_FILE.new" "$SC_FILE" fi diff --git a/packaging/dsm/scripts/start-stop-status b/packaging/dsm/scripts/start-stop-status index adf781e..33ed0f3 100755 --- a/packaging/dsm/scripts/start-stop-status +++ b/packaging/dsm/scripts/start-stop-status @@ -27,6 +27,9 @@ ROOT="${RESCRIPTUM_PKG_ROOT:-/var/packages/${SYNOPKG_PKGNAME:-$PKG}}" DEST="${SYNOPKG_PKGDEST:-$ROOT/target}" VAR="${SYNOPKG_PKGVAR:-$ROOT/var}" ETC="$ROOT/etc" +# The data share DSM created. `shares/` is a symlink into the volume; the package root +# is the fixed path, which is why it is derived from ROOT rather than from DEST. +SHARE="$ROOT/shares/$PKG" BIN="$DEST/bin/$PKG" ENV_FILE="$ETC/$PKG.env" @@ -96,6 +99,55 @@ start() { fi fi + # The media and boot folders, made whether or not the env file names them yet. An + # operator who uncomments RESCRIPTUM_MEDIA_DIR should find somewhere to drop an ISO + # already there — and a folder that only appears after a setting is enabled is one + # nobody discovers. Both are inside the share DSM created, so this costs nothing + # when they are unused, and neither may abort the start. + for extra in media boot; do + dir="$SHARE/$extra" + [ -d "$dir" ] || mkdir -p "$dir" 2>/dev/null || true + done + + # **Seed the boot folder with the loaders the package carries.** A TFTP server with + # nothing to hand out boots nothing, so an install that leaves this folder empty is an + # appliance that does not work until somebody finds a second download. The loaders come + # from $DEST/boot, which the payload put there. + # + # **The stamp is what makes an upgrade correct.** Copying only what is missing would + # freeze the loaders at whatever the first install shipped, and the pinned iPXE commit + # is exactly the kind of thing that has to be able to move. So: when the stamp does not + # name this version, refresh our own filenames and rewrite it. Anything else in the + # folder — a logo.png, a loader somebody added — is never touched. + # + # It follows that replacing one of *our* filenames does not survive an upgrade. That is + # deliberate and documented: an operator who manages loaders themselves points + # RESCRIPTUM_BOOT_DIR somewhere else, and then nothing here writes to it at all. + if [ -d "$DEST/boot" ] && [ -d "$SHARE/boot" ]; then + stamp="$SHARE/boot/.loaders" + # Read from the package's own INFO rather than trusted to an environment + # variable: SYNOPKG_PKGVER is set for the lifecycle scripts, and this script also + # runs at boot, where it is not. An unreadable INFO leaves this empty, which just + # means the stamp never matches and the loaders are refreshed every start — + # wasteful, never wrong. + want=$(sed -n 's/^version="\(.*\)"$/\1/p' "$ROOT/INFO" 2>/dev/null) + if [ "$(cat "$stamp" 2>/dev/null)" != "$want" ] || [ -z "$want" ]; then + copied=0 + for f in "$DEST"/boot/*; do + [ -f "$f" ] || continue + if cp "$f" "$SHARE/boot/$(basename "$f")" 2>/dev/null; then + copied=$((copied + 1)) + fi + done + if [ "$copied" -gt 0 ]; then + echo "$want" >"$stamp" 2>/dev/null || true + say "put $copied boot file(s) into $SHARE/boot" + else + say "could not write the loaders into $SHARE/boot — TFTP will have nothing to hand out" + fi + fi + fi + RESCRIPTUM_ENV_FILE="$ENV_FILE" export RESCRIPTUM_ENV_FILE diff --git a/packaging/dsm/vm/README.md b/packaging/dsm/vm/README.md index 0d1151d..021e1e1 100644 --- a/packaging/dsm/vm/README.md +++ b/packaging/dsm/vm/README.md @@ -119,6 +119,91 @@ $ ./build.sh armv7-unknown-linux-gnueabihf $ packaging/dsm/vm/on-dsm.sh admin@nas ``` +## Testing the real package on a real NAS + +The VM is for iterating; this is for the verdict. Written for the DS416j (ARMv7, +`armada38x`, DSM 7.1.1) — swap `armv7` for `x86_64` on an Intel NAS. + +**Build the loaders once.** The package carries them, and `check-spk.sh` refuses one that +does not. iPXE needs a Linux C toolchain, so on a Mac that is a container: + +```console +$ docker run --rm --platform linux/amd64 -v "$PWD:/w" -w /w debian:bookworm-slim sh -c ' + apt-get update -qq && + apt-get install -y --no-install-recommends build-essential liblzma-dev mtools \ + xorriso isolinux gcc-aarch64-linux-gnu git ca-certificates perl && + packaging/ipxe/build.sh --out /w/packaging/ipxe/out' +``` + +**Build the package.** + +```console +$ ./build.sh armv7-unknown-linux-gnueabihf +$ packaging/dsm/make-spk.sh armv7 + loaders: 13 file(s) from …/packaging/ipxe/out + rescriptum-0.2.0-1-armv7.spk 8084 KB installed arch=armada38x +$ packaging/dsm/check-spk.sh dist/rescriptum-0.2.0-1-armv7.spk +``` + +**Install it.** Package Center → *Manual Install* → the `.spk`. DSM warns that the +publisher is unknown; that is expected, nothing here is signed by Synology. The wizard +asks for the answer port. + +**Then the one root command.** Port 69 is privileged and DSM 7 will not let an unsigned +package run as root — [and cannot be made +to](../../../docs/development/traps.md#packaging-for-dsm), so this is the step that has no +way around it. Over SSH, as an administrator: + +```console +$ sudo setcap cap_net_bind_service=+ep "$(readlink -f /var/packages/rescriptum/target)/bin/rescriptum" +$ sudo synopkg restart rescriptum +``` + +`readlink -f` rather than a literal `/volume1/…`: `target` is a symlink into +`@appstore` on whichever volume the package landed on. + +**Check it from the NAS itself.** + +```console +$ rescriptum-cli boot check + ok ipxe-undionly.kpxe (96.6K) + … + ok 0.0.0.0:69 handed over ipxe-undionly.kpxe +$ curl -fsS http://127.0.0.1:8000/health +OK +``` + +The `handed over` line is the one that matters: it is a real TFTP read request answered +with real data, not a port that merely opened. If it says `BROKEN`, the `setcap` did not +take — and note the package is still serving answers and media, deliberately. + +**Make it survive an upgrade.** A new version is a new file, and a file capability goes +with the old one. Control Panel → **Task Scheduler** → Create → Triggered Task → +User-defined script, user `root`, event **Boot-up**, with the `setcap` line as the script. +Run it once from that page after each upgrade, or reboot. + +**Then boot a machine**, which is the only thing that proves the chain: + +```console +$ rescriptum-cli boot dhcp-snippet --format dnsmasq +``` + +Put that in the DHCP server, point a machine at the network, and watch it reach the menu. +If the firewall is on, allow *rescriptum* — the entry now covers `8000/tcp 8001/tcp +69/udp`, and a dropped UDP 69 looks exactly like a NAS that is not there. + +### The automated pass, which is destructive + +`on-dsm.sh` does install → start → answer a machine → upgrade → **uninstall**, so run it +*before* setting a NAS up for real, never after: + +```console +$ packaging/dsm/vm/on-dsm.sh admin@nas --abi armv7 +``` + +It leaves the shared folder and its contents behind on purpose. Green on the VM is not +green: the VM is x86_64 and says nothing about the ARMv7 binary. + ## Changing the package? This is the procedure Anything under `packaging/dsm/` — a lifecycle script, `conf/resource`, the wizard, the env diff --git a/packaging/dsm/vm/remote-check.sh b/packaging/dsm/vm/remote-check.sh index 34d3b6a..7b9ed18 100755 --- a/packaging/dsm/vm/remote-check.sh +++ b/packaging/dsm/vm/remote-check.sh @@ -68,7 +68,8 @@ esac # anything else from the share. Without it a stale canary or test answer makes the next # run fail for a reason that has nothing to do with the package. rm -rf /volume1/*/answers/canary.txt /volume1/*/answers/canary.toml \ - /volume1/*/answers/default.toml /volume1/*/answers/98-fa-9b-50-d8-10.toml \ + /volume1/*/answers/canary \ + /volume1/*/answers/default /volume1/*/answers/98-fa-9b-50-d8-10 \ /volume1/*/answers/groups 2>/dev/null # **etc/ and var/ survive an uninstall.** /var/packages//etc and /var/packages//var @@ -115,6 +116,15 @@ PORT=$(sed -n 's/^RESCRIPTUM_LISTEN_ADDR=.*:\([0-9]*\)$/\1/p' "$ROOT/etc/$PKG.en [ -n "$PORT" ] || PORT=8000 [ -d "$SHARE/answers" ] && ok "start created the answers directory inside the share" || bad "no $SHARE/answers" +# The media and boot folders, made whether or not the env file names them yet: a folder +# that only appears once a setting is enabled is one nobody discovers. The boot one is +# what this package's own TFTP server hands loaders out of — see the setcap section below. +for extra in media boot; do + [ -d "$SHARE/$extra" ] && ok "and the $extra folder, ready to be filled" || bad "no $SHARE/$extra" + sudo -u "$PKG" test -w "$SHARE/$extra" 2>/dev/null && + ok " which the package user can write" || + bad " but the package user cannot write it" +done if sudo -u "$PKG" test -w "$SHARE/answers" 2>/dev/null; then ok "the package user can write it — the ACL landed on the right name" else @@ -150,11 +160,154 @@ if [ -n "$SC" ]; then note "→ the worker acquired BEFORE postinst: the .sc ships 8000, and only" note " 'synopkghelper update $PKG port-config' moves it afterwards" fi + # **Did DSM keep 69/udp?** The protocol suffix on `dst.ports` is inferred from the + # form the tcp entries already use, not from documentation — so the question is + # whether the worker copies it through or quietly drops what it does not parse. A + # firewall entry missing the TFTP port produces a PXE client that retries and times + # out with nothing in any log on this side, which is the worst kind of failure here. + if grep -q '69/udp' "$SC"; then + ok " and it kept 69/udp, so the TFTP port can be allowed by name" + else + bad " but 69/udp did not survive into it — the suffix form is wrong for this DSM" + fi else bad "no $PKG.sc in /usr/local/etc/services.d or service.d — the firewall entry will never appear" note "what is there: $(ls /usr/local/etc/services.d 2>/dev/null | tr '\n' ' ')" fi +# ── TFTP, and the one root command it takes ──────────────────────────────────── +section "TFTP: the port the package cannot grant itself" +# **This is the section the whole `off` reversal rests on.** DSM 7 refuses `run-as: root` +# (synopkg 319) and Package Center strips a security.capability xattr out of package.tgz, +# so the only route to port 69 is a setcap applied after install — and the package cannot +# apply it, because its lifecycle scripts are not root either. What is asserted here is +# both halves: that the package is *useful* without it, and that it *works* with it. +BIN="$ROOT/target/bin/$PKG" +note "getcap before: $(getcap "$BIN" 2>/dev/null || echo '(none)')" + +# Half one. A fresh install has no capability, so the TFTP bind fails — and that must not +# stop anything else. This is the measured reason a failed TFTP bind is a warning rather +# than fatal: when it was fatal the whole package went to start_failed, taking answers and +# media with it, and an upgrade drops the capability silently. +[ "$(curl -fsS "http://127.0.0.1:$PORT/health" 2>/dev/null)" = OK ] && + ok "without the capability the package still answers — a failed TFTP bind is not fatal" || + bad "the package is down without the capability; a failed TFTP bind must never cost the answer endpoint" +grep -qi "cannot bind TFTP" "$ROOT/var/$PKG.log" "$ROOT/var/startup.log" 2>/dev/null && + ok "and said so in its log rather than failing silently" || + note "no 'cannot bind TFTP' line — check whether something already holds 69 on this machine" + +# Half two. The one root command, and whether the port is really bound afterwards by the +# unprivileged package user. +if [ -x /usr/bin/setcap ]; then + run /usr/bin/setcap cap_net_bind_service=+ep "$BIN" + note "getcap after: $(getcap "$BIN" 2>/dev/null || echo '(none)')" + run synopkg restart "$PKG" + sleep 4 + if netstat -lnup 2>/dev/null | grep -q ':69 '; then + ok "with cap_net_bind_service the package binds udp/69" + note "$(netstat -lnup 2>/dev/null | grep ':69 ')" + else + bad "udp/69 is still not bound after setcap and a restart" + tail -n 5 "$ROOT/var/$PKG.log" 2>/dev/null | sed 's/^/ /' + fi + [ "$(curl -fsS "http://127.0.0.1:$PORT/health" 2>/dev/null)" = OK ] && + ok "and answers are unaffected by gaining it" || + bad "the package stopped answering after setcap" + + # ── the only question that matters ───────────────────────────────────────── + # Binding a port proves nothing to a machine that is trying to boot. **Fetch the + # loader the way a PXE ROM does** — a real TFTP read of the whole file — and compare + # it byte for byte with what the package shipped. Everything above this line is + # scaffolding for this one check. + LOADER=ipxe-undionly.kpxe + if [ -f "$SHARE/boot/$LOADER" ]; then + ok "the loader is in the folder TFTP serves from" + # **The whole file, with a client that is not ours.** The server's probe reads one + # block, which proves the port, the root and the permissions — but a TFTP transfer + # answers from a *fresh source port*, so everything after block 1 depends on the + # machine's own firewall and routing. That is the half `tests/tftp.rs` cannot see, + # and it is a real DSM risk rather than a hypothetical one. + # + # curl on DSM is built without the tftp protocol, and there is no tftp, atftp, + # busybox or nc on the box — python3 is what is actually there. If it is missing + # too (the DS416j may not have it), this degrades to the probe rather than lying. + if command -v python3 >/dev/null 2>&1; then + cat >/tmp/tftpget.py <<'PYEOF' +import socket, sys +host, name, out = sys.argv[1], sys.argv[2], sys.argv[3] +s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) +s.settimeout(5) +s.sendto(b"\x00\x01" + name.encode() + b"\x00octet\x00", (host, 69)) +data, expect, peer = b"", 1, None +while True: + packet, addr = s.recvfrom(2048) + peer = peer or addr + op = int.from_bytes(packet[0:2], "big") + if op == 5: + sys.exit("tftp error: " + packet[4:].decode("utf-8", "replace")) + if op != 3: + sys.exit("unexpected opcode %d" % op) + block = int.from_bytes(packet[2:4], "big") + if block != expect: + sys.exit("out of order: wanted %d, got %d" % (expect, block)) + body = packet[4:] + data += body + s.sendto(b"\x00\x04" + block.to_bytes(2, "big"), peer) + expect = (expect + 1) & 0xFFFF + # A transfer ends on a short block, and short includes empty. + if len(body) < 512: + break +open(out, "wb").write(data) +PYEOF + rm -f /tmp/fetched.bin + if python3 /tmp/tftpget.py 127.0.0.1 "$LOADER" /tmp/fetched.bin 2>/tmp/tftpget.err; then + want=$(md5sum "$SHARE/boot/$LOADER" | cut -d' ' -f1) + got=$(md5sum /tmp/fetched.bin 2>/dev/null | cut -d' ' -f1) + if [ "$got" = "$want" ]; then + ok "**a full TFTP fetch of $LOADER returns it byte for byte** — the package serves iPXE" + note "$(wc -c /dev/null)" + fi + rm -f /tmp/tftpget.py /tmp/tftpget.err /tmp/fetched.bin + else + note "no python3 here to fetch with; falling back to the server's own probe" + fi + # The server's own probe, which is what `boot check` reports and the panel shows. + if "$ROOT/target/bin/$PKG-cli" boot check 2>/dev/null | grep -q "handed over"; then + ok "and boot check agrees a loader is handed over" + else + bad "boot check does not see a loader being handed over" + "$ROOT/target/bin/$PKG-cli" boot check 2>&1 | grep -E "BROKEN|MISSING" | sed 's/^/ /' + fi + else + bad "no $LOADER in $SHARE/boot — the package shipped none, so TFTP has nothing to hand out" + fi +else + note "no /usr/bin/setcap on this machine — the only route to port 69 is closed here" +fi + +# ── the image catalogue ──────────────────────────────────────────────────────── +section "the images tab, and whether this NAS can reach a vendor" +# The catalogue is the one part of this package that talks to the internet, and it does it +# by shelling out to curl because there is no TLS in the binary. Whether that works is a +# property of the *machine* — its resolver, its uplink, its curl — so it cannot be settled +# anywhere but here. +if "$ROOT/target/bin/$PKG-cli" media sources 2>/dev/null | grep -q proxmox-ve; then + ok "the catalogues are listed" +else + bad "media sources listed nothing" +fi +if out=$("$ROOT/target/bin/$PKG-cli" media sources proxmox-ve 2>&1) && echo "$out" | grep -q "proxmox-ve_"; then + ok "and this NAS can read a vendor's index over the network" + note "newest offered: $(echo "$out" | grep -m1 '^ proxmox-ve_')" +else + bad "could not read the Proxmox index from this machine: $(echo "$out" | tail -2)" +fi + if [ -e /usr/local/bin/$PKG-cli ]; then ok "rescriptum-cli is on PATH" else @@ -167,18 +320,20 @@ section "answering a machine, which is what the package exists to do" # configuration gets one — selection, group membership, merging and the format/endpoint # binding all sit between the two, and all of them read files from the share as the package # user. This is the assertion that covers the actual product on the actual machine. -mkdir -p "$SHARE/answers/groups" -cat >"$SHARE/answers/groups/rack.toml" <<'GROUP' +# One directory per identity: the directory names the machine or the group, and the +# extension inside it names the format. +mkdir -p "$SHARE/answers/groups/rack" "$SHARE/answers/98-fa-9b-50-d8-10" "$SHARE/answers/default" +cat >"$SHARE/answers/groups/rack/proxmox.toml" <<'GROUP' members = ["98:fa:9b:50:d8:10"] [global] keyboard = "fr" GROUP -cat >"$SHARE/answers/98-fa-9b-50-d8-10.toml" <<'MACHINE' +cat >"$SHARE/answers/98-fa-9b-50-d8-10/proxmox.toml" <<'MACHINE' [global] fqdn = "rig-machine.example.com" MACHINE -cat >"$SHARE/answers/default.toml" <<'DEFAULT' +cat >"$SHARE/answers/default/proxmox.toml" <<'DEFAULT' [global] fqdn = "should-not-be-served.example.com" DEFAULT @@ -194,7 +349,7 @@ if [ -z "$ANSWER" ]; then else echo "$ANSWER" | sed 's/^/ | /' case "$ANSWER" in - *rig-machine.example.com*) ok "the machine's own file was chosen over default.toml" ;; + *rig-machine.example.com*) ok "the machine's own document was chosen over the default" ;; *) bad "the answer is not this machine's — selection did not work" ;; esac case "$ANSWER" in @@ -202,7 +357,7 @@ else *) bad "the group's value is missing — members/merge did not work" ;; esac case "$ANSWER" in - *should-not-be-served*) bad "default.toml leaked into a machine's answer" ;; + *should-not-be-served*) bad "the default leaked into a machine's answer" ;; *members*) bad "the control key 'members' was served to the installer" ;; *) ok "no default fallback and no control keys in what the installer receives" ;; esac @@ -216,10 +371,10 @@ case "$GET" in *) bad "the query-string route did not resolve the machine" ;; esac -# An identity nobody claims must fall back to default.toml, not to nothing. +# An identity nobody claims must fall back to the default, not to nothing. UNKNOWN=$(curl -fsS -m 20 "http://127.0.0.1:$PORT/answer?mac=00-00-00-00-00-01" 2>/dev/null) case "$UNKNOWN" in -*should-not-be-served*) ok "an unknown machine falls back to default.toml" ;; +*should-not-be-served*) ok "an unknown machine falls back to the default" ;; *) bad "an unknown machine got no default" ;; esac @@ -277,8 +432,9 @@ fi # ── 4. the CLI, as the package user ──────────────────────────────────────────── section "the CLI on PATH" -echo 'global.keyboard = "fr"' >"$SHARE/answers/default.toml" -chown "$PKG" "$SHARE/answers/default.toml" 2>/dev/null +mkdir -p "$SHARE/answers/default" +echo 'global.keyboard = "fr"' >"$SHARE/answers/default/proxmox.toml" +chown -R "$PKG" "$SHARE/answers/default" 2>/dev/null run sudo -u "$PKG" /usr/local/bin/$PKG-cli check # Run as root it succeeds whatever the ACL says, which is what makes the sudo -u form the # real test. @@ -288,6 +444,30 @@ else bad "the package user cannot check its own answers" fi +# **What the settings panel will show.** `api.cgi` answers `action=config` by shelling out +# to exactly this, so the JSON here is the panel's data — asking the CGI itself would need +# a DSM session, and would test the same values through a login. +# +# The address is the one field with no constant behind it: it is derived at startup from +# this machine's own routing table. A blank one shipped once, because the table of known +# variables had no default and the derivation lived only in the server — so the panel +# showed an empty box while the server used an address, and the operator had no way to +# see which. Only a real machine has interfaces to get this wrong on. +json=$(sudo -u "$PKG" /usr/local/bin/$PKG-cli config --json 2>/dev/null) +host=$(printf '%s' "$json" | tr '{' '\n' | + grep '"key":"RESCRIPTUM_PUBLIC_HOST"' | + sed -n 's/.*"value":"\([^"]*\)".*/\1/p') +if [ -n "$host" ]; then + ok "the panel would show an address for this NAS: $host" + if ip -4 -o addr show 2>/dev/null | grep -qw "$host"; then + ok " and it is one this machine actually has" + else + bad " but no interface here has it — the derivation picked something imaginary" + fi +else + bad "RESCRIPTUM_PUBLIC_HOST came back empty — the panel shows a blank the server does not have" +fi + # ── 5. logrotate, and the descriptor that must not move ──────────────────────── section "logrotate" STANZA=$(find /usr/local/etc/logrotate.d /etc/logrotate.d /usr/syno/etc/logrotate.d -name "*$PKG*" 2>/dev/null | head -n 1) diff --git a/packaging/ipxe/PINNED b/packaging/ipxe/PINNED new file mode 100644 index 0000000..513010c --- /dev/null +++ b/packaging/ipxe/PINNED @@ -0,0 +1,26 @@ +# The upstream iPXE commit every rescriptum release builds from, and the GPLv2 written +# offer this repository makes: the source for the loaders we ship is this commit, plus +# branding.h and embed.ipxe beside this file. Nothing else is patched. +# +# **Pinned by SHA, not by tag.** A tag is a pointer somebody can move; a release that +# quietly changed what every machine boots is not an appliance. The tag is recorded +# alongside only so a human can tell at a glance how old the pin is. +# +# Bump it deliberately, and re-run the rig afterwards: what a loader does before our +# embedded script runs is upstream's code, and it is the half no test in this repository +# covers. +# +# BUILT AND VERIFIED (2026-08-27, Debian bookworm, amd64 under emulation): this commit +# produces all eight loaders, ARM64 included. The binaries carry PRODUCT_NAME +# "rescriptum boot", PRODUCT_URI, and `embed.ipxe` verbatim — checked with `strings` on +# the EFI builds, which are uncompressed enough to read. The BIOS `.kpxe` shows nothing +# to `strings` because it is a compressed image, which is expected rather than a failure. +# +# `rescriptum boot check` agrees the set satisfies the loader table. What remains +# unproven is what firmware does with them: that is the rig and the bench, not a build. +# +# If a future bump does not build cleanly, the answer is v1.21.1 +# (988d2c13cdf0f0b4140685af35ced70ac5b3283c), the release before this one. +IPXE_COMMIT=12798ec29aa8a64d8675c4378b99f5fe28447afb +IPXE_TAG=v2.0.0 +IPXE_REPO=https://github.com/ipxe/ipxe.git diff --git a/packaging/ipxe/README.md b/packaging/ipxe/README.md new file mode 100644 index 0000000..9a0f71e --- /dev/null +++ b/packaging/ipxe/README.md @@ -0,0 +1,105 @@ +# The branded iPXE loaders + +What TFTP hands out, and the first thing a machine executes that we wrote. + +## Status: built and verified; not yet booted + +`build.sh` has been run against the pinned commit on Debian bookworm and produces **all +eight loaders**, ARM64 included. Three things were checked on the output rather than +assumed: + +- the EFI binaries carry `PRODUCT_NAME "rescriptum boot"` and `PRODUCT_URI`; +- they carry `embed.ipxe` **verbatim**, `chain http://${next-server}:8001/ipxe/bootstrap` + and all — which is the entry point of the whole chain; +- `rescriptum boot check` agrees the set satisfies the loader table. + +The BIOS `.kpxe` shows none of that to `strings`, because it is a compressed image and +what is visible is the decompressor stub. Expected, not a failure. + +**What remains unproven is what firmware does with them.** A build says the bytes exist; +only a machine says they boot. That is the rig and then real hardware, and the project's +standing rule applies — nothing ships on harness evidence alone. + +Two things the first build taught, both now handled: + +- **The build must be amd64.** iPXE's BIOS targets are 32-bit x86; an ARM64 host's gcc + produces a wall of `unrecognized command-line option '-m32'` that reads like a broken + Makefile. +- **ARM64 needs `CROSS_COMPILE=aarch64-linux-gnu-`.** Without it the host compiler is + used and fails on `-mlittle-endian`. `build.sh` now names the missing package and + carries on with the x86 loaders rather than stopping. + +## Why we build it at all + +Three reasons converged, and any one would have settled it: + +1. **The entry point.** A stock `undionly.kpxe` from ipxe.org does DHCP, is told to load + iPXE, and loads itself forever — the documented chainloading loop. A stock + netboot.xyz binary chains to the *public* `boot.netboot.xyz`: no loop, but our menu + and our answers are never consulted. Only an embedded script gets a machine talking + to this server, and only a build we control can carry one. +2. **The name on the first line**, before anything else is on screen, plus a framebuffer + console that a stock binary may not have compiled in. +3. **The feature set.** We choose what is in — PNG, the menu commands, `sanboot`, the + console — rather than discovering at a customer site that a variant lacks one. + +## What is here + +| File | What it is | +|---|---| +| `PINNED` | The upstream commit, **by SHA**. A tag is a pointer somebody can move | +| `branding.h` | Our name, and the two URIs we deliberately do *not* change | +| `embed.ipxe` | The embedded script: the entry point of the whole chain | +| `build.sh` | Clones, pins, patches, builds, hashes, writes `NOTICE` | + +## The two URIs we leave alone + +`PRODUCT_ERROR_URI` and the command-help URI point at ipxe.org's database, which turns a +32-bit error code into a sentence, names the source file that produced it, and links the +line of code that raised it. Redirecting them at us would replace a working diagnostic +service with nothing — and the person staring at a hex code at 3am is exactly who that +database exists for. + +Keeping `PRODUCT_SHORT_NAME` as `iPXE` is upstream's own request, "to minimise end-user +confusion", and it is also the right way to use somebody's GPLv2 work. + +## The port in `embed.ipxe` is a contract + +The embedded script can read no configuration — it is baked in before any deployment +exists. It knows exactly two things: `${next-server}`, which DHCP supplies, and a port, +which nothing supplies. So **8001 is as fixed here as an answer URL baked into an ISO**. + +Moving `RESCRIPTUM_MEDIA_ADDR` is allowed and costs exactly this. Three things keep it +survivable, and `boot check` reports the first sign of trouble: + +- the generated `autoexec.ipxe` in the TFTP root carries the *configured* address, so + platforms that fetch it recover with no rebuild; +- the embedded script's own `||` turns the refused `chain` into a second chance — a + relative fetch that resolves, over HTTP, to whatever port actually served the loader; +- `boot check` warns when the configured port is not the one shipped loaders embed. + +## Licensing + +iPXE is **GPLv2** (with the UBDL exception); rescriptum is MIT. The loaders are separate +files, never linked into our binary — mere aggregation, obvious and auditable. **No +binaries in this repository, ever**: they are a release artifact carrying the upstream +licence texts, a `NOTICE` naming the exact commit and digests, and the written offer for +source, which is this directory. + +## Building + +```console +$ ./build.sh # into ./out +$ ./build.sh --out /srv/boot +``` + +Then point `RESCRIPTUM_BOOT_DIR` at the output and ask the server whether it agrees: + +```console +$ rescriptum boot check +``` + +That compares the directory against the same table the TFTP server serves from and +`boot dhcp-snippet` generates from. **A snippet naming a loader that is not on disk +fails silently, at the ROM**, with nothing on any console — it is the least diagnosable +failure in the whole chain, and this is what catches it. diff --git a/packaging/ipxe/branding.h b/packaging/ipxe/branding.h new file mode 100644 index 0000000..8098bb5 --- /dev/null +++ b/packaging/ipxe/branding.h @@ -0,0 +1,36 @@ +/* + * rescriptum's branding for iPXE. + * + * Copied over src/config/local/branding.h in a pinned iPXE checkout. See README.md + * beside this file for what is built and why we build it at all. + * + * Two rules, and both come from upstream's own comment in src/config/branding.h: + * + * - PRODUCT_SHORT_NAME should either be a substring of PRODUCT_NAME or stay "iPXE", + * "to minimise end-user confusion". It stays "iPXE": what appears in a BIOS boot + * selection menu should say what the thing actually is. + * + * - The error and command-help URIs are **deliberately left alone**. They point at + * ipxe.org's database, which turns a 32-bit error code into a sentence, names the + * source file that produced it, and links the line of code. Redirecting them at us + * would replace a working diagnostic service with nothing — the operator staring at + * a hex code at 3am is the person that database exists for. + * + * Keeping the iPXE attribution is also the right way to use somebody's GPLv2 work. + */ + +#ifndef CONFIG_LOCAL_BRANDING_H +#define CONFIG_LOCAL_BRANDING_H + +#undef PRODUCT_NAME +#undef PRODUCT_SHORT_NAME +#undef PRODUCT_URI +#undef PRODUCT_TAG_LINE + +/* Shown before any iPXE branding, which is the first line a machine puts on screen. */ +#define PRODUCT_NAME "rescriptum boot" +#define PRODUCT_SHORT_NAME "iPXE" +#define PRODUCT_URI "https://github.com/z29k/rescriptum" +#define PRODUCT_TAG_LINE "Every machine its own install" + +#endif /* CONFIG_LOCAL_BRANDING_H */ diff --git a/packaging/ipxe/build.sh b/packaging/ipxe/build.sh new file mode 100755 index 0000000..93c9377 --- /dev/null +++ b/packaging/ipxe/build.sh @@ -0,0 +1,199 @@ +#!/usr/bin/env bash +# Build the branded iPXE loaders rescriptum ships. +# +# ./build.sh # every loader the table names, into ./out +# ./build.sh --out DIR +# +# Needs a C toolchain, GNU make, perl, and — for the EFI targets — the cross binutils +# for that architecture. On Debian: +# +# apt install build-essential liblzma-dev mtools xorriso isolinux gcc-aarch64-linux-gnu +# +# ## Why we build iPXE at all +# +# Three independent reasons converged on it, and any one would have been enough: +# +# 1. **The entry point.** A stock loader either re-loads itself forever (the +# documented chainloading loop) or chains to the public boot.netboot.xyz. Only an +# embedded script gets a machine talking to *this* server, and only a build we +# control can carry one. +# 2. **The name on the first line**, before anything else is on screen, and a +# framebuffer console that a stock binary may not have compiled in at all. +# 3. **The feature set.** We choose what is compiled in — PNG, the menu commands, +# sanboot, the console — rather than discovering at a customer site that a variant +# lacks one. +# +# ## The GPL obligation, met by construction +# +# iPXE is GPLv2 (with the UBDL exception) and rescriptum is MIT. What makes that a +# non-conversation is that the loaders are **separate files, never linked into our +# binary**: mere aggregation, obvious and auditable. This script, `branding.h`, +# `embed.ipxe` and `PINNED` are the written offer — everything needed to reproduce what +# we ship, in the same repository as the thing that serves it. + +set -euo pipefail +# Resolved before the `cd`, so `--help` can read the script itself however it was called. +SELF="$(cd "$(dirname "$0")" && pwd)/$(basename "$0")" +cd "$(dirname "$SELF")" + +OUT="$PWD/out" +while [ $# -gt 0 ]; do + case "$1" in + --out) OUT="$2"; shift 2 ;; + -h|--help) sed -n '2,12p' "$SELF" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) echo "unexpected argument: $1" >&2; exit 2 ;; + esac +done + +# shellcheck disable=SC1091 +. ./PINNED + +WORK="${WORK:-$PWD/.work}" +mkdir -p "$OUT" "$WORK" + +if [ ! -d "$WORK/ipxe/.git" ]; then + echo "cloning iPXE into $WORK/ipxe" + git clone --quiet "$IPXE_REPO" "$WORK/ipxe" +fi + +# Pinned by SHA. A fetch first, because a shallow or stale clone may not have it yet. +git -C "$WORK/ipxe" fetch --quiet --tags origin +git -C "$WORK/ipxe" checkout --quiet "$IPXE_COMMIT" +echo "iPXE at $IPXE_COMMIT (${IPXE_TAG:-no tag})" + +cp branding.h "$WORK/ipxe/src/config/local/branding.h" + +# What has to be compiled in, and why each one is here rather than a default: +# IMAGE_PNG the logo behind the menu +# CONSOLE_FRAMEBUFFER the console that can show it +# IMAGE_TRUST_CMD so a site that wants signed images can have them +# PARAM_CMD/NSLOOKUP_CMD/PING_CMD/REBOOT_CMD/POWEROFF_CMD the diagnostics menu +# VLAN_CMD a boot VLAN is the recommendation that actually works +cat > "$WORK/ipxe/src/config/local/general.h" <<'CONFIG' +/* rescriptum: what the menu and the diagnostics entries need. */ +#define IMAGE_PNG +#define CONSOLE_FRAMEBUFFER +#define IMAGE_TRUST_CMD +#define PARAM_CMD +#define NSLOOKUP_CMD +#define PING_CMD +#define REBOOT_CMD +#define POWEROFF_CMD +#define VLAN_CMD +#define NTP_CMD +#define CONSOLE_CMD +CONFIG + +build() { + local target="$1" output="$2" + echo "building $target" + make -C "$WORK/ipxe/src" -j"$(getconf _NPROCESSORS_ONLN 2>/dev/null || echo 2)" \ + "bin/$target" EMBED="$PWD/embed.ipxe" >/dev/null 2>&1 || + make -C "$WORK/ipxe/src" "bin/$target" EMBED="$PWD/embed.ipxe" + cp "$WORK/ipxe/src/bin/$target" "$OUT/$output" +} + +# ARM64 needs its own toolchain, and the failure without one is a wall of +# `unrecognized command-line option '-mlittle-endian'` from the *host* gcc — which +# reads like a broken Makefile rather than a missing cross-compiler. Naming it here is +# what turns that into "install gcc-aarch64-linux-gnu". +cross_for() { + case "$1" in + arm64) echo "aarch64-linux-gnu-" ;; + *) echo "" ;; + esac +} + +build_efi() { + local arch="$1" target="$2" output="$3" + local cross; cross="$(cross_for "$arch")" + if [ -n "$cross" ] && ! command -v "${cross}gcc" >/dev/null 2>&1; then + echo "skipping $arch/$target: ${cross}gcc is not installed" >&2 + return 0 + fi + echo "building $arch/$target" + make -C "$WORK/ipxe/src" -j"$(getconf _NPROCESSORS_ONLN 2>/dev/null || echo 2)" \ + ARCH="$arch" CROSS_COMPILE="$cross" "bin-$arch-efi/$target" \ + EMBED="$PWD/embed.ipxe" >/dev/null 2>&1 || + make -C "$WORK/ipxe/src" ARCH="$arch" CROSS_COMPILE="$cross" \ + "bin-$arch-efi/$target" EMBED="$PWD/embed.ipxe" + cp "$WORK/ipxe/src/bin-$arch-efi/$target" "$OUT/$output" +} + +# The names here are the ones `src/boot/loaders.rs` hands out and +# `boot dhcp-snippet` writes into somebody's DHCP server. **They must not drift**: +# `boot check` compares this directory against that table, and a snippet naming a file +# that is not here fails silently at the ROM. +build undionly.kpxe ipxe-undionly.kpxe +build ipxe.pxe ipxe.kpxe + +build_efi x86_64 ipxe.efi ipxe-x86_64.efi +build_efi x86_64 snp.efi ipxe-x86_64-snp.efi +build_efi x86_64 snponly.efi ipxe-x86_64-snponly.efi + +build_efi arm64 ipxe.efi ipxe-arm64.efi +build_efi arm64 snp.efi ipxe-arm64-snp.efi +build_efi arm64 snponly.efi ipxe-arm64-snponly.efi + +# The media a machine with no usable PXE ROM can still boot from: an ISO for IPMI virtual +# media, and a USB image for a stick. **These live in the BIOS build directory**, not the +# EFI one — `bin/ipxe.iso`, not `bin-x86_64-efi/ipxe.iso` — which is the mistake the first +# version of this script made, and it failed into the `||` below rather than saying so. +# They are Phase 5 of the plan and nothing depends on them yet, so a failure here is a +# note rather than an error. +# **The ISO needs `isolinux.bin`, not an ISO writer.** The first version of this note +# said "xorriso or mkisofs", and with xorriso installed the target still failed with +# `util/genfsimg: could not find isolinux.bin` — which sends you after the wrong package. +# On Debian it is `isolinux`; the USB target is the one that wants mtools. +build ipxe.iso ipxe.iso || echo "note: the ISO target needs isolinux (for isolinux.bin) and xorriso" +build ipxe.usb ipxe.usb || echo "note: the USB target needs mtools" + +( cd "$OUT" && sha256sum ./* > SHA256SUMS 2>/dev/null || shasum -a 256 ./* > SHA256SUMS ) + +cat > "$OUT/NOTICE" < "$OUT/README" <, + pub probed: Probed, + /// Where the kernel and initrd are, when they sit beside the image rather than + /// inside it — `prepare-iso --pxe` output, which is a directory of three files. + pub beside: Option, + /// What to inject, when this entry is a **prepared** one: a sidecar naming another + /// entry's image plus an answer URL. About two hundred bytes standing in for 1.5 GB + /// — nothing is copied, and the same image backs every entry derived from it. + pub prepared: Option, +} + +/// A prepared entry's instructions. The image itself is the source entry's. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Prepared { + pub source_id: String, + pub url: String, + pub fingerprint: Option, + pub token: Option, + /// The source's length when the entry was written. **A stale prepared entry is + /// invisible otherwise**: the plan's offsets are computed against one image, and an + /// image that changed underneath would be patched in the wrong place. + pub source_len: u64, + /// The source's digest at the same moment, for `media check` to re-verify. The + /// catalogue compares only the length, because hashing 1.5 GB per directory read is + /// exactly the work no request may ever do. + pub source_digest: Option, +} + +impl Entry { + pub fn family(&self) -> Family { + self.probed.family.unwrap_or(Family::Unknown) + } + + pub fn arch(&self) -> Option { + self.probed.arch + } + + /// A short human label for a listing: the version if a vendor left one, else the id. + pub fn describe(&self) -> String { + self.probed + .version + .clone() + .unwrap_or_else(|| self.id.clone()) + } + + /// Whether this entry can offer a kernel and an initrd at all. An image that cannot + /// is still served whole — `sanboot` and virtual media both take one. + pub fn bootable(&self) -> bool { + self.probed.kernel.is_some() && self.probed.initrd.is_some() + } +} + +#[derive(Debug, Clone, Default)] +pub struct Listing { + pub entries: Vec, + /// Everything wrong that is not worth refusing to serve over. A fleet must never be + /// unable to install because one image is odd. + pub problems: Vec, +} + +impl Listing { + pub fn get(&self, id: &str) -> Option<&Entry> { + self.entries.iter().find(|e| e.id == id) + } +} + +struct Cached { + version: Option, + loaded_at: Instant, + listing: Arc, +} + +pub struct Catalog { + dir: PathBuf, + cache: Mutex>, +} + +impl Catalog { + pub fn new(dir: impl Into) -> Catalog { + Catalog { + dir: dir.into(), + cache: Mutex::new(None), + } + } + + pub fn dir(&self) -> &Path { + &self.dir + } + + pub fn describe(&self) -> String { + format!("media in {}", self.dir.display()) + } + + /// The catalogue as it currently stands, from cache when nothing has moved. + pub fn listing(&self) -> io::Result> { + let version = self.version(); + // A poisoned lock means another request panicked mid-refresh. The data is still + // structurally sound, so carry on rather than failing an install over it. + let mut guard = self.cache.lock().unwrap_or_else(|e| e.into_inner()); + + if let Some(cached) = guard.as_ref() + && cached.version == version + && version.is_some() + && cached.loaded_at.elapsed() < RELOAD_BACKSTOP + { + return Ok(Arc::clone(&cached.listing)); + } + + let listing = Arc::new(self.build()); + *guard = Some(Cached { + version, + loaded_at: Instant::now(), + listing: Arc::clone(&listing), + }); + Ok(listing) + } + + pub fn get(&self, id: &str) -> io::Result> { + Ok(self.listing()?.get(id).cloned()) + } + + pub fn problems(&self) -> io::Result> { + Ok(self.listing()?.problems.clone()) + } + + /// One `stat`, standing in for the whole walk. A new file moves the directory's + /// mtime; a rewritten one does not, which is what the backstop above is for. + fn version(&self) -> Option { + let meta = std::fs::metadata(&self.dir).ok()?; + let mtime = meta.modified().ok()?; + let since = mtime.duration_since(SystemTime::UNIX_EPOCH).ok()?; + Some(format!("{}.{}", since.as_secs(), since.subsec_nanos())) + } + + fn build(&self) -> Listing { + let mut listing = Listing::default(); + + let entries = match std::fs::read_dir(&self.dir) { + Ok(entries) => entries, + Err(e) => { + // Not fatal, on purpose: the directory may appear, or have its + // permissions fixed, and this is re-read as it changes. + listing.problems.push(format!( + "{} cannot be listed: {e} — no images will be served until that is fixed", + self.dir.display() + )); + return listing; + } + }; + + let mut images: BTreeMap = BTreeMap::new(); + let mut sidecars: BTreeMap = BTreeMap::new(); + + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().into_owned(); + // A hidden file is never an image, and one kind is actively dangerous: a Mac + // editing this share over SMB drops AppleDouble `._` files beside every + // real one. The answers listing already skips them for the same reason. + if name.starts_with('.') { + continue; + } + // `file_type` comes back free with the readdir on Unix; only a symlink needs + // the extra stat to resolve. + let Ok(kind) = entry.file_type() else { + continue; + }; + let is_file = if kind.is_file() { + true + } else if kind.is_symlink() { + entry.path().is_file() + } else { + false + }; + if !is_file { + continue; + } + + let path = entry.path(); + let Some(extension) = path + .extension() + .map(|e| e.to_string_lossy().to_ascii_lowercase()) + else { + continue; + }; + let stem = path + .file_stem() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_default(); + + if extension == SIDECAR_EXTENSION { + sidecars.insert(stem, path); + } else if IMAGE_EXTENSIONS.contains(&extension.as_str()) { + images.insert(stem, path); + } + } + + for (id, path) in images { + match self.entry(&id, &path, sidecars.get(&id).map(PathBuf::as_path)) { + Ok(entry) => listing.entries.push(entry), + Err(problem) => listing.problems.push(problem), + } + } + + // Then the prepared entries, which need every source to exist first. A sidecar + // with no image of its own and no `source` is a leftover, and a silent one: the + // entry simply stops existing and the menu shrinks with no explanation. + let sources = listing.entries.clone(); + for (id, path) in &sidecars { + if listing.entries.iter().any(|e| &e.id == id) { + continue; + } + let recorded = match Sidecar::load(path) { + Ok(recorded) => recorded, + Err(problem) => { + listing.problems.push(problem); + continue; + } + }; + if recorded.source.is_none() { + listing.problems.push(format!( + "{}: no image named {id} — the sidecar describes something that is not here", + path.display() + )); + continue; + } + match self.prepared(id, path, &recorded, &sources) { + Ok(entry) => listing.entries.push(entry), + Err(problem) => listing.problems.push(problem), + } + } + + listing.entries.sort_by(|a, b| a.id.cmp(&b.id)); + listing + } + + fn entry(&self, id: &str, path: &Path, sidecar: Option<&Path>) -> Result { + if !crate::store::valid_id(id) { + return Err(format!( + "{}: {id:?} is not a usable identifier — it becomes part of a URL", + path.display() + )); + } + if RESERVED_IDS.contains(&id) { + return Err(format!( + "{}: {id:?} is a reserved name — the listener answers that path itself", + path.display() + )); + } + + let size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0); + let recorded = sidecar.map(Sidecar::load).transpose()?.unwrap_or_default(); + + // A sidecar that already carries the probe's answers saves opening the image. + // Without one, probe now: it is a few kilobytes of reads, not a pass over the + // file, and the listing is cached behind an mtime. + let probed = match recorded.probed() { + Some(probed) => probed, + None => probe::probe(path).unwrap_or_else(|e| { + // An image that will not parse is still an image somebody can `sanboot` + // or write to a stick. Serve it; describe it as unknown. + crate::log::server(&format!("warning: cannot probe {}: {e}", path.display())); + Probed::default() + }), + }; + + let beside = probed + .external + .then(|| path.parent().unwrap_or(Path::new(".")).to_path_buf()); + + Ok(Entry { + id: id.to_string(), + path: path.to_path_buf(), + size, + digest: recorded.digest, + probed, + beside, + prepared: None, + }) + } + + /// A **prepared** entry: a sidecar naming another entry's image plus what to inject. + /// + /// The image is never copied — this entry serves the source's bytes with a few + /// hundred substituted on the way out. Two hundred bytes of sidecar stand in for + /// 1.5 GB, and changing the answer URL is a matter of rewriting them. + fn prepared( + &self, + id: &str, + sidecar: &Path, + recorded: &Sidecar, + sources: &[Entry], + ) -> Result { + if !crate::store::valid_id(id) || RESERVED_IDS.contains(&id) { + return Err(format!( + "{}: {id:?} is not a usable identifier for a prepared entry", + sidecar.display() + )); + } + let source_id = recorded.source.clone().unwrap_or_default(); + let Some(source) = sources.iter().find(|e| e.id == source_id) else { + return Err(format!( + "{}: names source {source_id:?}, which is not in this directory", + sidecar.display() + )); + }; + let Some(url) = recorded.prepare_url.clone() else { + return Err(format!( + "{}: names a source but no `prepare-url`, so there is nothing to inject", + sidecar.display() + )); + }; + + // **A stale prepared entry is invisible**, so this is loud: the plan's offsets + // are computed against one image, and a source that changed underneath would be + // patched in the wrong place — producing an image that mounts and is wrong. + if let Some(was) = recorded.source_len + && was != source.size + { + return Err(format!( + "{}: {source_id} was {was} bytes when this was prepared and is {} now. The injection offsets no longer apply — re-run `media prepare`.", + sidecar.display(), + source.size + )); + } + + let mut entry = source.clone(); + entry.id = id.to_string(); + // A prepared image is longer than its source by whatever was injected, and the + // exact figure comes from the plan rather than from an estimate. + entry.prepared = Some(Prepared { + source_id, + url, + fingerprint: recorded.prepare_fingerprint.clone(), + token: recorded.prepare_token.clone(), + source_len: source.size, + source_digest: source.digest.clone(), + }); + // Its own digest is the source's no longer: nobody has pinned the derived image. + entry.digest = None; + Ok(entry) + } +} + +/// What `media add` recorded about an image, so the server never re-learns it. +/// +/// Plain `key = value` lines, because this is a note beside a file rather than a +/// document anybody composes: no merging, no layering, no format negotiation. A key +/// nothing understands is ignored rather than refused — a newer rescriptum writing one +/// must not stop an older one from serving the image. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Sidecar { + pub digest: Option, + pub family: Option, + pub version: Option, + pub arch: Option, + pub kernel: Option, + pub initrd: Option, + pub external: bool, + pub zstd_initrd: bool, + /// Set on a **prepared** entry: the id of the image this derives from, and what to + /// inject into it. + pub source: Option, + pub prepare_url: Option, + pub prepare_fingerprint: Option, + pub prepare_token: Option, + pub source_len: Option, +} + +impl Sidecar { + pub fn load(path: &Path) -> Result { + let text = std::fs::read_to_string(path) + .map_err(|e| format!("{}: cannot be read: {e}", path.display()))?; + Ok(Sidecar::parse(&text)) + } + + pub fn parse(text: &str) -> Sidecar { + let mut out = Sidecar::default(); + for line in text.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let Some((key, value)) = line.split_once('=') else { + continue; + }; + let value = value.trim().to_string(); + if value.is_empty() { + continue; + } + match key.trim() { + "sha256" => out.digest = Some(value), + "family" => out.family = Some(value), + "version" => out.version = Some(value), + "arch" => out.arch = Some(value), + "kernel" => out.kernel = Some(value), + "initrd" => out.initrd = Some(value), + "external" => out.external = value == "true", + "zstd-initrd" => out.zstd_initrd = value == "true", + "source" => out.source = Some(value), + "prepare-url" => out.prepare_url = Some(value), + "prepare-cert-fingerprint" => out.prepare_fingerprint = Some(value), + "prepare-token" => out.prepare_token = Some(value), + "source-bytes" => out.source_len = value.parse().ok(), + _ => {} + } + } + out + } + + /// The probe's answers, when the sidecar carries enough of them to skip the image. + /// A sidecar with only a digest does not: the family is what the menu needs. + fn probed(&self) -> Option { + let family = self.family.as_deref().and_then(Family::parse)?; + Some(Probed { + family: Some(family), + version: self.version.clone(), + arch: self.arch.as_deref().and_then(Arch::parse), + kernel: self.kernel.clone(), + initrd: self.initrd.clone(), + external: self.external, + zstd_initrd: self.zstd_initrd, + }) + } + + pub fn render(digest: &str, probed: &Probed) -> String { + let mut out = String::from( + "# rescriptum media entry — written by `media add`.\n\ + # Delete it and the image is probed again on sight; nothing is lost but the\n\ + # recorded digest, which is the one thing this server will not re-compute\n\ + # inside a request.\n", + ); + out.push_str(&format!("sha256 = {digest}\n")); + if let Some(family) = probed.family { + out.push_str(&format!("family = {}\n", family.label())); + } + if let Some(version) = &probed.version { + out.push_str(&format!("version = {version}\n")); + } + if let Some(arch) = probed.arch { + out.push_str(&format!("arch = {}\n", arch.label())); + } + if let Some(kernel) = &probed.kernel { + out.push_str(&format!("kernel = {kernel}\n")); + } + if let Some(initrd) = &probed.initrd { + out.push_str(&format!("initrd = {initrd}\n")); + } + if probed.external { + out.push_str("external = true\n"); + } + if probed.zstd_initrd { + out.push_str("zstd-initrd = true\n"); + } + out + } + + /// Where the sidecar for an image lives. + pub fn path_for(image: &Path) -> PathBuf { + image.with_extension(SIDECAR_EXTENSION) + } +} + +#[cfg(test)] +mod tests { + use super::super::iso::build; + use super::*; + + struct Dir(PathBuf); + + impl Dir { + fn new(name: &str) -> Dir { + let path = std::env::temp_dir() + .join(format!("rescriptum-catalog-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&path); + std::fs::create_dir_all(&path).expect("temp dir"); + Dir(path) + } + + fn image(&self, name: &str, builder: &build::Builder) -> PathBuf { + let path = self.0.join(name); + std::fs::write(&path, builder.build()).expect("write"); + path + } + + fn write(&self, name: &str, body: &[u8]) -> PathBuf { + let path = self.0.join(name); + std::fs::write(&path, body).expect("write"); + path + } + } + + impl Drop for Dir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + fn bzimage() -> Vec { + let mut k = vec![0u8; 0x400]; + k[0x202..0x206].copy_from_slice(b"HdrS"); + k + } + + fn pve() -> build::Builder { + build::Builder::new() + .volume("PVE") + .file("/boot/linux26", &bzimage()) + .file("/boot/initrd.img", &[0x1f, 0x8b, 0, 0]) + } + + #[test] + fn an_image_dropped_in_the_directory_is_in_the_catalogue() { + // Discovered, not declared: the whole point. No registration step, no restart. + let dir = Dir::new("discovery"); + dir.image("pve-8.4.iso", &pve()); + + let catalog = Catalog::new(&dir.0); + let listing = catalog.listing().expect("lists"); + assert_eq!(listing.entries.len(), 1); + let entry = &listing.entries[0]; + assert_eq!(entry.id, "pve-8.4"); + assert_eq!(entry.family(), Family::Proxmox); + assert!(entry.bootable()); + assert_eq!(entry.digest, None, "nobody has pinned it"); + } + + #[test] + fn a_new_image_appears_without_a_restart() { + // The cache must not be a way to miss an image. One `Catalog`, as the cache + // invalidation tests for answers already insist: a fresh one per call would + // bypass the cache entirely and prove nothing. + let dir = Dir::new("appears"); + let catalog = Catalog::new(&dir.0); + assert_eq!(catalog.listing().expect("lists").entries.len(), 0); + + dir.image("pve-8.4.iso", &pve()); + std::thread::sleep(RELOAD_BACKSTOP + Duration::from_millis(50)); + assert_eq!(catalog.listing().expect("lists").entries.len(), 1); + } + + #[test] + fn hidden_and_appledouble_entries_are_skipped() { + // A Mac editing this share over SMB drops `._pve-8.4.iso` beside the real file. + // On the answers side that hijacked a machine's answer; here it would be a + // second, broken catalogue entry for every image. + let dir = Dir::new("appledouble"); + dir.image("pve-8.4.iso", &pve()); + dir.write("._pve-8.4.iso", b"AppleDouble junk"); + dir.write(".hidden.iso", b"junk"); + + let listing = Catalog::new(&dir.0).listing().expect("lists"); + assert_eq!(listing.entries.len(), 1); + assert_eq!(listing.entries[0].id, "pve-8.4"); + } + + #[test] + fn a_file_that_is_not_an_image_is_not_an_entry() { + // A media directory is also where notes and checksum files end up. + let dir = Dir::new("clutter"); + dir.image("pve-8.4.iso", &pve()); + dir.write("SHA256SUMS", b"9f86d0 pve-8.4.iso\n"); + dir.write("notes.txt", b"remember to update this"); + dir.write("vmlinuz", &bzimage()); + + let listing = Catalog::new(&dir.0).listing().expect("lists"); + assert_eq!(listing.entries.len(), 1, "{:?}", listing.entries); + } + + #[test] + fn a_reserved_name_is_refused_rather_than_shadowing_a_route() { + // `valid_id` accepts dots, so `netboot.xyz.iso` produces a *valid* identifier + // that would shadow the listener's own root — and a shadowed route fails as a + // mysterious 404 rather than as anything anybody can act on. + let dir = Dir::new("reserved"); + dir.image("netboot.xyz.iso", &pve()); + dir.image("boot.iso", &pve()); + + let listing = Catalog::new(&dir.0).listing().expect("lists"); + assert!(listing.entries.is_empty(), "{:?}", listing.entries); + assert_eq!(listing.problems.len(), 2); + assert!( + listing.problems.iter().all(|p| p.contains("reserved")), + "{:?}", + listing.problems + ); + } + + #[test] + fn a_sidecar_supplies_the_digest_and_spares_the_probe() { + let dir = Dir::new("sidecar"); + dir.image("pve-8.4.iso", &pve()); + dir.write( + "pve-8.4.media", + b"sha256 = 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08\n\ + family = proxmox\nversion = Proxmox VE 8.4-1\narch = x86_64\n\ + kernel = /boot/linux26\ninitrd = /boot/initrd.img\n", + ); + + let listing = Catalog::new(&dir.0).listing().expect("lists"); + let entry = listing.get("pve-8.4").expect("present"); + assert_eq!( + entry.digest.as_deref(), + Some("9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08") + ); + assert_eq!(entry.describe(), "Proxmox VE 8.4-1"); + assert_eq!(entry.family(), Family::Proxmox); + } + + #[test] + fn a_sidecar_whose_image_is_gone_is_reported_rather_than_ignored() { + // Otherwise the entry simply stops existing and the menu shrinks silently. + let dir = Dir::new("orphan"); + dir.write("gone.media", b"sha256 = deadbeef\nfamily = proxmox\n"); + + let listing = Catalog::new(&dir.0).listing().expect("lists"); + assert!(listing.entries.is_empty()); + assert_eq!(listing.problems.len(), 1); + assert!( + listing.problems[0].contains("gone"), + "{:?}", + listing.problems + ); + } + + #[test] + fn a_sidecar_round_trips_through_its_own_renderer() { + let probed = Probed { + family: Some(Family::Proxmox), + version: Some("Proxmox VE 8.4-1".to_string()), + arch: Some(Arch::X86_64), + kernel: Some("/boot/linux26".to_string()), + initrd: Some("/boot/initrd.img".to_string()), + external: false, + zstd_initrd: true, + }; + let text = Sidecar::render("9f86d0", &probed); + let back = Sidecar::parse(&text); + assert_eq!(back.digest.as_deref(), Some("9f86d0")); + assert_eq!(back.probed(), Some(probed)); + } + + #[test] + fn an_unknown_sidecar_key_is_ignored_rather_than_refused() { + // A newer rescriptum writing a key this one does not know must not stop it + // serving the image. + let back = Sidecar::parse("sha256 = abc\nfamily = proxmox\nfuture-thing = 42\n"); + assert_eq!(back.digest.as_deref(), Some("abc")); + assert!(back.probed().is_some()); + } + + #[test] + fn a_missing_directory_is_a_problem_and_not_an_error() { + // A fleet must never be unable to install because the media directory is not + // there yet — the answer endpoint is untouched by any of this. + let catalog = Catalog::new("/nonexistent/rescriptum/media"); + let listing = catalog.listing().expect("still lists"); + assert!(listing.entries.is_empty()); + assert_eq!(listing.problems.len(), 1); + } + + #[test] + fn an_image_no_probe_places_is_still_an_entry() { + let dir = Dir::new("mystery"); + dir.write("mystery.iso", &vec![0u8; 64 * 1024]); + + let listing = Catalog::new(&dir.0).listing().expect("lists"); + let entry = listing.get("mystery").expect("still listed"); + assert_eq!(entry.family(), Family::Unknown); + assert!(!entry.bootable()); + assert_eq!(entry.describe(), "mystery"); + } +} diff --git a/src/boot/cpio.rs b/src/boot/cpio.rs new file mode 100644 index 0000000..f06059f --- /dev/null +++ b/src/boot/cpio.rs @@ -0,0 +1,222 @@ +//! A cpio writer, for exactly one job: appending an ISO to an initrd as a named member. +//! +//! Proxmox over PXE wants its image visible at `/proxmox.iso` inside the initramfs. +//! Modern iPXE does that itself (`initrd proxmox.iso`); older loaders cannot, and +//! the community answer has always been to build a 1.5 GB initrd with the image cpio'd +//! into it. We synthesise the same bytes **on the wire** instead of storing them, which +//! is why this is a header generator rather than an archiver: nothing here ever holds a +//! file, it only says what bytes go around one. +//! +//! Two properties of the kernel's initramfs loader make it work with no compressor: +//! concatenated archives are all unpacked, and an **uncompressed** segment among +//! compressed ones is fine. +//! +//! The format is "newc" (SVR4, no CRC): a 110-byte header of ASCII hex fields, the NUL +//! terminated name, then the data, each padded to a four-byte boundary. + +/// `c_filesize` is eight hex digits. An image at or past 4 GiB cannot be described, and +/// saying so beats emitting a header that wraps. +pub const MAX_MEMBER: u64 = 0xffff_ffff; + +const MAGIC: &[u8; 6] = b"070701"; +const HEADER: usize = 110; +/// A regular file, mode 0644. +const MODE_FILE: u32 = 0o100_644; + +/// What surrounds one member's data, with the arithmetic already done — the media +/// listener needs an exact `Content-Length` before it has read a byte. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Member { + /// Header, name, and the padding that aligns the data that follows. + pub prefix: Vec, + /// Zero bytes after the data, aligning whatever comes next. + pub padding: usize, +} + +impl Member { + /// Total bytes this member contributes, data included. + pub fn len(&self, data: u64) -> u64 { + self.prefix.len() as u64 + data + self.padding as u64 + } +} + +/// Describe a regular file member. `size` is the data that will follow the prefix. +/// +/// The inode number is a caller's choice only because it must be unique within the +/// archive; a single appended member can safely be any non-zero value. +pub fn member(name: &str, size: u64, ino: u32) -> Result { + if size > MAX_MEMBER { + return Err(format!( + "{name} is {size} bytes; a cpio member cannot exceed {MAX_MEMBER} (4 GiB). \ + Serve the image directly and let the loader name it instead." + )); + } + // A leading slash would make it an absolute path inside the archive, which the + // kernel's unpacker refuses; the members it creates all sit at the root. + if name.is_empty() || name.starts_with('/') || name.contains('\0') { + return Err(format!("{name:?} is not a usable member name")); + } + + let mut prefix = Vec::with_capacity(HEADER + name.len() + 8); + prefix.extend_from_slice(MAGIC); + let fields = [ + ino, + MODE_FILE, + 0, // uid: root, because an initramfs member has no other sensible owner + 0, // gid + 1, // nlink + 0, // mtime: fixed, so the same request twice produces the same bytes + size as u32, + 0, // devmajor + 0, // devminor + 0, // rdevmajor + 0, // rdevminor + (name.len() + 1) as u32, + 0, // check: unused in newc, and zero is what everyone writes + ]; + for field in fields { + prefix.extend_from_slice(hex8(field).as_bytes()); + } + prefix.extend_from_slice(name.as_bytes()); + prefix.push(0); + // The name is padded so that the data begins on a four-byte boundary. + prefix.resize(align4(prefix.len()), 0); + + Ok(Member { + prefix, + padding: align4(size as usize) - size as usize, + }) +} + +/// The end-of-archive marker. Every reader stops here, so anything appended after it is +/// a separate archive — which is exactly how concatenation works. +pub fn trailer() -> Vec { + let mut end = member("TRAILER!!!", 0, 0) + .expect("the trailer name is fixed and valid") + .prefix; + // Archives are padded to 512 bytes at the end. The kernel does not require it, but + // every other cpio reader expects it and it costs a few hundred zeros. + let padded = end.len().div_ceil(512) * 512; + end.resize(padded, 0); + end +} + +fn align4(n: usize) -> usize { + n.div_ceil(4) * 4 +} + +fn hex8(value: u32) -> String { + format!("{value:08X}") +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Assemble what the listener would stream, so the assertions below are about real + /// archive bytes rather than about the generator's internals. + fn archive(members: &[(&str, &[u8])]) -> Vec { + let mut out = Vec::new(); + for (i, (name, data)) in members.iter().enumerate() { + let m = member(name, data.len() as u64, i as u32 + 1).expect("describable"); + assert_eq!( + m.len(data.len() as u64) as usize, + m.prefix.len() + data.len() + m.padding + ); + out.extend_from_slice(&m.prefix); + out.extend_from_slice(data); + out.resize(out.len() + m.padding, 0); + } + out.extend_from_slice(&trailer()); + out + } + + #[test] + fn a_member_header_is_the_shape_the_format_specifies() { + let m = member("proxmox.iso", 0x1234_5678, 1).expect("describable"); + assert_eq!(&m.prefix[..6], MAGIC); + // Every field is eight upper-case hex digits, so the header is fixed width. + let fields = std::str::from_utf8(&m.prefix[6..HEADER]).expect("ascii"); + assert_eq!(fields.len(), 104, "13 fields of 8"); + assert!( + fields + .bytes() + .all(|b| b.is_ascii_digit() || (b'A'..=b'F').contains(&b)), + "{fields}" + ); + // c_filesize is the seventh field, and it carries the size we were given. + assert_eq!(&fields[48..56], "12345678"); + // The name follows, NUL-terminated, and the data begins aligned. + assert!(m.prefix[HEADER..].starts_with(b"proxmox.iso\0")); + assert_eq!(m.prefix.len() % 4, 0); + } + + #[test] + fn data_and_the_member_after_it_both_start_aligned() { + // The alignment is the whole reason the padding exists: an unaligned member is + // the failure that unpacks as garbage rather than as an error. + for size in [0usize, 1, 2, 3, 4, 5, 1023, 1024, 1025] { + let m = member("x", size as u64, 1).expect("describable"); + assert_eq!(m.prefix.len() % 4, 0, "size {size}"); + assert_eq!((m.prefix.len() + size + m.padding) % 4, 0, "size {size}"); + assert!(m.padding < 4); + } + } + + #[test] + fn an_archive_reads_back_as_the_members_that_went_in() { + // A miniature reader, because asserting on bytes proves the generator agrees + // with itself and nothing more. + let built = archive(&[("proxmox.iso", b"an image, pretend"), ("second", b"!!")]); + + let mut at = 0usize; + let mut found: Vec<(String, Vec)> = Vec::new(); + loop { + assert_eq!(&built[at..at + 6], MAGIC, "member at {at}"); + let field = |n: usize| -> usize { + let text = std::str::from_utf8(&built[at + 6 + n * 8..at + 6 + n * 8 + 8]).unwrap(); + usize::from_str_radix(text, 16).unwrap() + }; + let size = field(6); + let namesize = field(11); + let name = String::from_utf8(built[at + HEADER..at + HEADER + namesize - 1].to_vec()) + .expect("utf8"); + if name == "TRAILER!!!" { + break; + } + let data_at = align4(at + HEADER + namesize); + found.push((name, built[data_at..data_at + size].to_vec())); + at = align4(data_at + size); + } + + assert_eq!(found.len(), 2); + assert_eq!(found[0].0, "proxmox.iso"); + assert_eq!(found[0].1, b"an image, pretend"); + assert_eq!(found[1].0, "second"); + assert_eq!(found[1].1, b"!!"); + } + + #[test] + fn an_image_too_large_to_describe_is_refused_by_name() { + // Eight hex digits cannot hold it, and a header that silently wrapped would + // produce an initrd the kernel unpacks as garbage. + let e = member("proxmox.iso", MAX_MEMBER + 1, 1).expect_err("must refuse"); + assert!(e.contains("4 GiB"), "{e}"); + assert!(member("proxmox.iso", MAX_MEMBER, 1).is_ok()); + } + + #[test] + fn a_name_the_kernel_would_refuse_is_refused_here() { + assert!(member("/proxmox.iso", 1, 1).is_err(), "absolute"); + assert!(member("", 1, 1).is_err(), "empty"); + assert!(member("pro\0mox", 1, 1).is_err(), "embedded NUL"); + } + + #[test] + fn the_trailer_ends_the_archive_on_a_block_boundary() { + let end = trailer(); + assert!(end.starts_with(MAGIC)); + assert!(end[HEADER..].starts_with(b"TRAILER!!!\0")); + assert_eq!(end.len() % 512, 0); + } +} diff --git a/src/boot/dhcp.rs b/src/boot/dhcp.rs new file mode 100644 index 0000000..c79378c --- /dev/null +++ b/src/boot/dhcp.rs @@ -0,0 +1,626 @@ +//! Generating somebody else's DHCP configuration. +//! +//! **There is no DHCP code in this project** — not a server, not a proxy, not behind a +//! flag. rescriptum deploys into an infrastructure that already has a DHCP server, and +//! pointing that server at a boot server is one of the oldest and most standard things +//! it does. Writing a responder to work around problems that are not DHCP problems is +//! how a provisioning server acquires a reputation for breaking networks. +//! +//! So the handoff is two options on a server that already exists, and our job is to +//! make setting them trivial. **The operator copies rather than composes**, and every +//! line comes from the same table `tftp` serves from — so a snippet naming a loader +//! that is not on disk cannot happen by drift. (It can still happen by nobody running +//! `boot sync`, which is what `boot check` is for. That failure is silent at the ROM, +//! and it is the most common way this goes wrong.) +//! +//! Five details are ours to get right in what we emit, and each is a way this fails +//! quietly on somebody else's network: +//! +//! 1. **Both the BOOTP `file` field and option 67.** Some ROMs read only one, and which +//! one is not predictable from the vendor. +//! 2. **The option 93 table comes from IANA plus one recorded exception** — see +//! `loaders`. +//! 3. **A next-server for the `HTTPClient` classes too.** An HTTP-boot deployment that +//! sets only option 67 leaves `${next-server}` empty inside the loader, and the +//! bootstrap's primary path with it. +//! 4. **Echo `HTTPClient` back in option 60 for those classes.** UEFI firmware filters +//! offers on it; a reply carrying only the URL is discarded, silently. +//! 5. **An untagged default at the end.** Every architecture line is tag-matched, so a +//! ROM that sends no option 93 would match nothing and get no boot file at all. The +//! only clients that old are BIOS, and a DHCP client that is not netbooting ignores +//! boot options entirely. + +use super::loaders::{self, Transport}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Format { + Dnsmasq, + Isc, + Kea, + /// Windows Server's `DhcpServer` module. **Not `netsh`**: branching there takes + /// policies, which `netsh dhcp` predates. + PowerShell, + PfSense, + Mikrotik, +} + +impl Format { + pub fn parse(name: &str) -> Option { + Some(match name.to_ascii_lowercase().as_str() { + "dnsmasq" => Format::Dnsmasq, + "isc" | "dhcpd" => Format::Isc, + "kea" => Format::Kea, + "powershell" | "windows" => Format::PowerShell, + "pfsense" | "opnsense" => Format::PfSense, + "mikrotik" | "routeros" => Format::Mikrotik, + _ => return None, + }) + } + + pub fn label(self) -> &'static str { + match self { + Format::Dnsmasq => "dnsmasq", + Format::Isc => "isc", + Format::Kea => "kea", + Format::PowerShell => "powershell", + Format::PfSense => "pfsense", + Format::Mikrotik => "mikrotik", + } + } + + pub const ALL: &'static [Format] = &[ + Format::Dnsmasq, + Format::Isc, + Format::Kea, + Format::PowerShell, + Format::PfSense, + Format::Mikrotik, + ]; +} + +pub struct Handoff { + /// The address a ROM is sent to, as it must appear on that network. + pub host: String, + /// The media listener's URL, for the clients that fetch their loader over HTTP. + pub media: String, + pub version: &'static str, + /// One line for a homogeneous fleet, rather than four for a mixed one. + pub one_loader: bool, +} + +/// The vendor-class string firmware announces in option 60, which is the only place the +/// architecture reaches a Windows DHCP policy. +fn vendor_class(arch: u16, transport: Transport) -> String { + let prefix = match transport { + Transport::Tftp => "PXEClient", + Transport::Http => "HTTPClient", + }; + format!("{prefix}:Arch:{arch:05}") +} + +/// A short tag name for a row, for the formats that use one. +fn tag(arch: u16, transport: Transport) -> String { + let base = match arch { + 0x0000 => "bios", + 0x0007 | 0x0009 | 0x0010 => "efi64", + 0x000b | 0x0013 => "efiarm64", + _ => "other", + }; + match transport { + Transport::Tftp => base.to_string(), + Transport::Http => format!("http{base}"), + } +} + +/// Every row that actually serves something, which is what a snippet is made of. +fn served() -> Vec<(&'static loaders::Client, &'static str)> { + loaders::TABLE + .iter() + .filter_map(|c| c.loader.map(|l| (c, l))) + .collect() +} + +pub fn snippet(format: Format, handoff: &Handoff) -> String { + let mut out = String::new(); + // Kea's configuration is JSON, and although its own parser accepts `#` comments + // locally, upstream says plainly that "most JSON tools detect them as errors" and + // recommends `user-context` instead. So the provenance goes *inside* the document + // there, and the output stays pasteable into anything that reads JSON. + if format != Format::Kea { + out.push_str(&format!( + "# rescriptum {} - boot handoff for {}\n\ + # Architecture values are IANA option 93 codes; see docs/guide/boot/dhcp.\n\ + # Generated from the same table the TFTP server serves from.\n", + handoff.version, handoff.host + )); + } + + if handoff.one_loader { + if format != Format::Kea { + out.push_str("# --one-loader: a homogeneous BIOS fleet, so no branching at all.\n"); + } + out.push_str(&one_loader(format, handoff)); + return out; + } + + out.push_str(&match format { + Format::Dnsmasq => dnsmasq(handoff), + Format::Isc => isc(handoff), + Format::Kea => kea(handoff), + Format::PowerShell => powershell(handoff), + Format::PfSense => pfsense(handoff), + Format::Mikrotik => mikrotik(handoff), + }); + out +} + +fn one_loader(format: Format, handoff: &Handoff) -> String { + let host = &handoff.host; + let loader = loaders::FALLBACK; + match format { + Format::Dnsmasq => format!("dhcp-boot={loader},,{host}\n"), + Format::Isc => format!("next-server {host};\nfilename \"{loader}\";\n"), + Format::Kea => format!( + "{{\n \"user-context\": {{ \"comment\": \"rescriptum {} boot handoff\" }},\n \ + \"next-server\": \"{host}\",\n \"boot-file-name\": \"{loader}\"\n}}\n", + handoff.version + ), + Format::PowerShell => format!( + "Set-DhcpServerv4OptionValue -OptionId 66 -Value '{host}'\n\ + Set-DhcpServerv4OptionValue -OptionId 67 -Value '{loader}'\n" + ), + Format::PfSense => format!( + "Services > DHCP Server > Network Booting\n \ + Next Server: {host}\n Default BIOS file name: {loader}\n" + ), + Format::Mikrotik => format!( + "/ip dhcp-server network set [find] next-server={host} boot-file-name={loader}\n" + ), + } +} + +fn dnsmasq(handoff: &Handoff) -> String { + let host = &handoff.host; + let media = handoff.media.trim_end_matches('/'); + let mut out = String::new(); + + for (client, _) in served() { + match client.transport { + // dnsmasq can match option 93 directly, which is the clean way. + Transport::Tftp => out.push_str(&format!( + "dhcp-match=set:{},option:client-arch,{}\n", + tag(client.arch, client.transport), + client.arch + )), + // HTTP-boot clients are told apart by their vendor class, because that is + // also what has to be echoed back at them. + Transport::Http => out.push_str(&format!( + "dhcp-vendorclass=set:{},{}\n", + tag(client.arch, client.transport), + vendor_class(client.arch, client.transport) + )), + } + } + out.push('\n'); + + // Two architectures can share a tag — 0x0007 and 0x0009 are both x64 — and dnsmasq + // would take the first `dhcp-boot` and ignore the rest. Emitting it twice is + // harmless and untidy, and an operator reads this file. + let mut emitted: Vec = Vec::new(); + for (client, loader) in served() { + let tag = tag(client.arch, client.transport); + if emitted.contains(&tag) { + continue; + } + emitted.push(tag.clone()); + match client.transport { + Transport::Tftp => { + out.push_str(&format!("dhcp-boot=tag:{tag},{loader},,{host}\n")); + } + Transport::Http => { + // The firmware filters offers on option 60 being HTTPClient. A reply + // carrying only the URL is discarded, silently, at the firmware. + out.push_str(&format!( + "dhcp-option-force=tag:{tag},60,HTTPClient\n\ + dhcp-boot=tag:{tag},{media}/boot/{loader},,{host}\n" + )); + } + } + } + + out.push_str(&format!( + "\n# A ROM that sends no option 93 matches no tag above and would get nothing.\n\ + # The only clients that old are BIOS, and a DHCP client that is not netbooting\n\ + # ignores boot options entirely.\n\ + dhcp-boot={},,{host}\n", + loaders::FALLBACK + )); + out +} + +fn isc(handoff: &Handoff) -> String { + let host = &handoff.host; + let media = handoff.media.trim_end_matches('/'); + let mut out = String::from( + "option arch code 93 = unsigned integer 16;\n\ + option vendor-class code 60 = string;\n\n", + ); + out.push_str(&format!("next-server {host};\n")); + // Both the BOOTP `file` field and option 67: some ROMs read only one, and which is + // not predictable from the vendor. `filename` sets the former; dhcpd copies it into + // the latter for a client that asked for it. + out.push_str(&format!("filename \"{}\";\n\n", loaders::FALLBACK)); + + let mut first = true; + for (client, loader) in served() { + let keyword = if first { "if" } else { "} elsif" }; + first = false; + match client.transport { + Transport::Tftp => out.push_str(&format!( + "{keyword} option arch = {:02x}:{:02x} {{\n filename \"{loader}\";\n", + client.arch >> 8, + client.arch & 0xff + )), + Transport::Http => out.push_str(&format!( + "{keyword} option arch = {:02x}:{:02x} {{\n \ + option vendor-class \"HTTPClient\";\n \ + filename \"{media}/boot/{loader}\";\n", + client.arch >> 8, + client.arch & 0xff + )), + } + } + if !first { + out.push_str("}\n"); + } + out +} + +fn kea(handoff: &Handoff) -> String { + let host = &handoff.host; + let media = handoff.media.trim_end_matches('/'); + let mut classes: Vec = Vec::new(); + + for (client, loader) in served() { + let name = format!( + "{}-{:#06x}", + tag(client.arch, client.transport), + client.arch + ); + let file = match client.transport { + Transport::Tftp => loader.to_string(), + Transport::Http => format!("{media}/boot/{loader}"), + }; + let mut body = format!( + " {{\n \"name\": \"{name}\",\n \ + \"test\": \"option[93].hex == {:#06x}\",\n \ + \"next-server\": \"{host}\",\n \ + \"boot-file-name\": \"{file}\"", + client.arch + ); + if client.transport == Transport::Http { + body.push_str( + ",\n \"option-data\": [ { \"name\": \"vendor-class-identifier\", \ + \"data\": \"HTTPClient\" } ]", + ); + } + body.push_str("\n }"); + classes.push(body); + } + + format!( + "{{\n \"Dhcp4\": {{\n \"user-context\": {{\n \ + \"comment\": \"rescriptum {} boot handoff for {host}; option 93 values are IANA \ + architecture types, generated from the same table the TFTP server serves from\"\n \ + }},\n \"client-classes\": [\n{}\n ],\n \ + \"next-server\": \"{host}\",\n \"boot-file-name\": \"{}\"\n }}\n}}\n", + handoff.version, + classes.join(",\n"), + loaders::FALLBACK + ) +} + +fn powershell(handoff: &Handoff) -> String { + let host = &handoff.host; + let media = handoff.media.trim_end_matches('/'); + let mut out = String::from( + "# Windows Server DHCP. **A policy cannot condition on option 93** — the policy\n\ + # condition types are vendor class, user class, MAC, client id, FQDN and relay\n\ + # information, and the architecture reaches a policy only inside the option 60\n\ + # string. Hence a vendor class per architecture, matched with a trailing\n\ + # wildcard because the real string continues (`PXEClient:Arch:00007:UNDI:003016`).\n\ + # Run per scope: pass -ScopeId, or omit it for the server-level policy.\n\n", + ); + + for (client, loader) in served() { + let class = vendor_class(client.arch, client.transport); + let name = format!("rescriptum-{}", class.replace(':', "-")); + let file = match client.transport { + Transport::Tftp => loader.to_string(), + Transport::Http => format!("{media}/boot/{loader}"), + }; + out.push_str(&format!( + "Add-DhcpServerv4Class -Name '{name}' -Type Vendor -Data '{class}'\n\ + Add-DhcpServerv4Policy -Name '{name}' -Condition OR -VendorClass EQ,'{class}*'\n\ + Set-DhcpServerv4OptionValue -PolicyName '{name}' -OptionId 66 -Value '{host}'\n\ + Set-DhcpServerv4OptionValue -PolicyName '{name}' -OptionId 67 -Value '{file}'\n" + )); + if client.transport == Transport::Http { + out.push_str(&format!( + "Set-DhcpServerv4OptionValue -PolicyName '{name}' -OptionId 60 -Value 'HTTPClient'\n" + )); + } + out.push('\n'); + } + + out.push_str(&format!( + "# The untagged default, for a ROM that announces no architecture.\n\ + Set-DhcpServerv4OptionValue -OptionId 66 -Value '{host}'\n\ + Set-DhcpServerv4OptionValue -OptionId 67 -Value '{}'\n", + loaders::FALLBACK + )); + out +} + +fn pfsense(handoff: &Handoff) -> String { + let host = &handoff.host; + let mut out = String::from( + "# pfSense and OPNsense configure this through the web interface, so this is\n\ + # what to type rather than a file to paste.\n\n\ + Services > DHCP Server > (interface) > Network Booting\n", + ); + out.push_str(&format!(" Next Server: {host}\n")); + out.push_str(&format!( + " Default BIOS file name: {}\n", + loaders::FALLBACK + )); + for (client, loader) in served() { + if client.transport == Transport::Http { + continue; + } + if client.arch == 0x0007 { + out.push_str(&format!(" UEFI 64-bit file name: {loader}\n")); + } + } + out.push_str( + "\n# Architectures the built-in fields do not cover — ARM64, and HTTP boot —\n\ + # go in Advanced > Additional BOOTP/DHCP Options, or in the ISC snippet:\n\ + # rescriptum boot dhcp-snippet --format isc\n", + ); + out +} + +fn mikrotik(handoff: &Handoff) -> String { + let host = &handoff.host; + let mut out = String::from( + "# RouterOS. Branching on option 93 needs matchers, which older versions do not\n\ + # have; the single-loader form below works everywhere and is right for a fleet\n\ + # that is all one architecture. For a mixed fleet, run dnsmasq beside it or use\n\ + # a DHCP server that can branch — see the guide.\n\n", + ); + out.push_str(&format!( + "/ip dhcp-server network set [find] next-server={host} boot-file-name={}\n", + loaders::FALLBACK + )); + out.push_str("\n# RouterOS 7 with matchers, for a mixed fleet:\n"); + for (client, loader) in served() { + if client.transport == Transport::Http { + continue; + } + out.push_str(&format!( + "# /ip dhcp-server matcher add server=[find] code=93 value=0x{:04x} \ + address-pool=static-only boot-file-name={loader}\n", + client.arch + )); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn handoff() -> Handoff { + Handoff { + host: "192.0.2.10".to_string(), + media: "http://192.0.2.10:8001".to_string(), + version: "0.3.0", + one_loader: false, + } + } + + #[test] + fn every_format_names_every_loader_the_table_serves() { + // **The two cannot drift**: what an operator pastes into their DHCP server and + // what this one hands out come from one table. A snippet naming a loader that + // is not there fails silently at the ROM, which is the least diagnosable + // failure in the whole chain. + for format in Format::ALL { + let text = snippet(*format, &handoff()); + for loader in loaders::loaders() { + // pfSense and Mikrotik are interfaces rather than files, and both say + // in the output which architectures they cannot express. + if matches!(format, Format::PfSense | Format::Mikrotik) && loader.contains("arm64") + { + continue; + } + assert!( + text.contains(loader), + "{} does not name {loader}:\n{text}", + format.label() + ); + } + } + } + + #[test] + fn every_format_names_the_server() { + for format in Format::ALL { + let text = snippet(*format, &handoff()); + assert!( + text.contains("192.0.2.10"), + "{} names no server:\n{text}", + format.label() + ); + } + } + + #[test] + fn a_rom_that_announces_no_architecture_still_gets_a_loader() { + // Every architecture line is tag-matched, so without an untagged default a ROM + // sending no option 93 matches nothing and simply stops. + for format in [ + Format::Dnsmasq, + Format::Isc, + Format::Kea, + Format::PowerShell, + ] { + let text = snippet(format, &handoff()); + assert!( + text.contains(loaders::FALLBACK), + "{} has no default:\n{text}", + format.label() + ); + } + // dnsmasq's is the untagged `dhcp-boot` at the end. + let text = snippet(Format::Dnsmasq, &handoff()); + assert!( + text.contains(&format!("dhcp-boot={},,192.0.2.10", loaders::FALLBACK)), + "{text}" + ); + } + + #[test] + fn http_boot_clients_are_told_to_expect_an_http_client_offer() { + // UEFI firmware filters offers on option 60 being HTTPClient. A reply carrying + // only the URL in option 67 is discarded, silently, at the firmware — which is + // indistinguishable from no DHCP server at all. + let text = snippet(Format::Dnsmasq, &handoff()); + assert!(text.contains("60,HTTPClient"), "{text}"); + + let text = snippet(Format::PowerShell, &handoff()); + assert!(text.contains("-OptionId 60 -Value 'HTTPClient'"), "{text}"); + + let text = snippet(Format::Kea, &handoff()); + assert!(text.contains("\"data\": \"HTTPClient\""), "{text}"); + } + + #[test] + fn http_boot_clients_are_still_given_a_next_server() { + // An HTTP-boot deployment names no next-server of its own — option 67 carries a + // URL there — so the bootstrap's primary path would read an empty + // `${next-server}` and chain into nowhere. + let text = snippet(Format::Dnsmasq, &handoff()); + for line in text.lines().filter(|l| l.contains("http://")) { + assert!( + line.ends_with(",,192.0.2.10"), + "an HTTP-boot line must still carry a next-server: {line}" + ); + } + } + + #[test] + fn a_windows_policy_conditions_on_the_vendor_class_not_on_option_93() { + // A policy cannot condition on option 93 at all: the condition types are vendor + // class, user class, MAC, client id, FQDN and relay information. The + // architecture reaches a policy only inside the option 60 string, and the real + // string continues past the arch — hence the trailing wildcard. + let text = snippet(Format::PowerShell, &handoff()); + assert!( + text.contains("-VendorClass EQ,'PXEClient:Arch:00007*'"), + "{text}" + ); + assert!(text.contains("Add-DhcpServerv4Class"), "{text}"); + assert!(!text.contains("netsh"), "{text}"); + assert!(text.contains("cannot condition on option 93"), "{text}"); + } + + #[test] + fn the_vendor_class_strings_are_the_ones_firmware_actually_sends() { + assert_eq!( + vendor_class(0x0000, Transport::Tftp), + "PXEClient:Arch:00000" + ); + assert_eq!( + vendor_class(0x0007, Transport::Tftp), + "PXEClient:Arch:00007" + ); + assert_eq!( + vendor_class(0x000b, Transport::Tftp), + "PXEClient:Arch:00011" + ); + // 0x0010 is sixteen, and it is an HTTP client rather than a PXE one. + assert_eq!( + vendor_class(0x0010, Transport::Http), + "HTTPClient:Arch:00016" + ); + assert_eq!( + vendor_class(0x0013, Transport::Http), + "HTTPClient:Arch:00019" + ); + } + + #[test] + fn the_recorded_exception_gets_its_own_line() { + // Real x64 firmware announces 0x0007 or 0x0009, so a snippet that covered only + // the one the registry blesses would leave half a fleet unbootable. + let text = snippet(Format::Dnsmasq, &handoff()); + assert!(text.contains("option:client-arch,7"), "{text}"); + assert!(text.contains("option:client-arch,9"), "{text}"); + } + + #[test] + fn a_tag_gets_exactly_one_boot_line() { + // 0x0007 and 0x0009 are both x64 and share a tag; dnsmasq would take the first + // `dhcp-boot` and ignore the rest. Emitting it twice is harmless and untidy, and + // an operator reads this file. + let text = snippet(Format::Dnsmasq, &handoff()); + let mut seen: Vec<&str> = text + .lines() + .filter(|l| l.starts_with("dhcp-boot=tag:")) + .collect(); + let before = seen.len(); + seen.sort_unstable(); + seen.dedup(); + assert_eq!(seen.len(), before, "a duplicated boot line: {text}"); + // Both architectures still get their *match* line, which is what actually has + // to cover them. + assert!(text.contains("option:client-arch,7"), "{text}"); + assert!(text.contains("option:client-arch,9"), "{text}"); + } + + #[test] + fn one_loader_is_a_single_line_for_a_homogeneous_fleet() { + let mut handoff = handoff(); + handoff.one_loader = true; + let text = snippet(Format::Dnsmasq, &handoff); + assert!( + text.contains("dhcp-boot=ipxe-undionly.kpxe,,192.0.2.10"), + "{text}" + ); + assert!(!text.contains("dhcp-match"), "no branching at all:\n{text}"); + } + + #[test] + fn a_format_name_nobody_uses_is_refused_rather_than_defaulted() { + assert_eq!(Format::parse("dnsmasq"), Some(Format::Dnsmasq)); + assert_eq!(Format::parse("DHCPD"), Some(Format::Isc)); + assert_eq!(Format::parse("opnsense"), Some(Format::PfSense)); + // `netsh` is deliberately not an alias: it cannot express this. + assert_eq!(Format::parse("netsh"), None); + assert_eq!(Format::parse("bind"), None); + } + + #[test] + fn the_kea_snippet_is_valid_json() { + // It is the one format that is a data document rather than a directive list, so + // it can be checked here rather than only by the real parser. + let text = snippet(Format::Kea, &handoff()); + let parsed: serde_json::Value = serde_json::from_str(&text) + .unwrap_or_else(|e| panic!("Kea output must parse as JSON: {e}\n{text}")); + let classes = parsed["Dhcp4"]["client-classes"] + .as_array() + .expect("client-classes"); + assert_eq!(classes.len(), served().len()); + } +} diff --git a/src/boot/fetch.rs b/src/boot/fetch.rs new file mode 100644 index 0000000..6c3caf6 --- /dev/null +++ b/src/boot/fetch.rs @@ -0,0 +1,444 @@ +//! Getting an installer image onto the server, over the network. +//! +//! **No base image is ever in this repository, and none is in a release.** An ISO is +//! somebody else's copyrighted artefact, it is one to four gigabytes, and it changes on +//! its own schedule — three separate reasons it belongs on the deployment's disk rather +//! than in ours. So the server fetches it, once, into the directory it serves from. +//! +//! ## Why this shells out +//! +//! **There is no TLS in this binary.** rustls plus a root store is forty-odd crates and +//! over a megabyte on armv7 — for a job every host already has a tool for. So this runs +//! `curl`, or `wget` if that is what is installed, and says plainly when it finds +//! neither. The precedent is `check` calling `proxmox-auto-install-assistant`: an +//! external tool used when it is there, never depended upon. Without one, the answer is +//! the one that always worked — download it yourself and drop it in the directory. +//! +//! ## What "the base ISO is the archive" means +//! +//! The file this writes is **never modified afterwards**. Preparing an image produces a +//! sidecar and an injection applied on the wire, so the bytes on disk stay exactly what +//! the vendor published and their digest stays verifiable against the vendor's own +//! checksum file. The media directory is the archive; everything derived from it is a +//! few hundred bytes. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +/// How the image got here, for a caller to report. +#[derive(Debug)] +pub struct Fetched { + pub path: PathBuf, + pub bytes: u64, + /// The tool that did it, so a log line says what to install if it is missing. + pub via: &'static str, +} + +/// Fetch `url` into `dir`, atomically, verifying it against `expected` before it counts. +/// +/// The download lands on a `.part` name and is renamed only once the digest matches. +/// **A partial download must never become a catalogue entry**: the catalogue probes +/// whatever it finds, and a truncated ISO probes as an unknown image that a machine +/// would then try to boot. +pub fn fetch( + url: &str, + dir: &Path, + name: Option<&str>, + expected: Option<&str>, +) -> Result { + let name = match name { + Some(name) => name.to_string(), + None => name_from(url)?, + }; + // Said here rather than left to curl, whose "Failed to open the file" names the + // partial path and not the setting that produced it. + if !dir.is_dir() { + return Err(format!( + "{} is not a directory. RESCRIPTUM_MEDIA_DIR names where images live, and \ + nothing creates it for you — a directory conjured by a download is one \ + nobody chose.", + dir.display() + )); + } + let target = dir.join(&name); + if target.exists() { + return Err(format!( + "{} already exists. Machines may be booting it right now, so this will not \ + overwrite it — remove it first, or use --as to fetch under another name.", + target.display() + )); + } + let partial = dir.join(format!("{name}.part")); + + let (program, args) = downloader(&partial, url).ok_or_else(|| { + format!( + "neither curl nor wget is installed, and there is no TLS in this binary — \ + 40-odd crates and a megabyte on armv7 for a job the host already does. \ + Install one, or download {url} yourself and drop it in {}.", + dir.display() + ) + })?; + + eprintln!("fetching {url}"); + eprintln!(" with {program}, into {}", partial.display()); + let status = Command::new(program) + .args(&args) + .status() + .map_err(|e| format!("cannot run {program}: {e}"))?; + if !status.success() { + let code = status.code().unwrap_or(-1); + // **curl exits 33 when the server ignored the Range header**, which is what a + // mirror without byte-range support does. Retrying would fail identically + // forever, so the answer is to say the one thing that fixes it rather than + // repeating "run it again". + if code == 33 { + return Err(format!( + "{program} exited 33: this server does not support resuming, and {} is a \ + partial download from an earlier attempt. Delete it and run this again \ + to start from the beginning.", + partial.display() + )); + } + // Otherwise leave the partial file: a 1.5 GB download that failed at 90% is + // worth resuming, and both tools resume onto it. + return Err(format!( + "{program} exited {code}. {} is left in place, and running this again resumes \ + it — delete it to start over.", + partial.display() + )); + } + + let bytes = std::fs::metadata(&partial) + .map_err(|e| format!("{}: {e}", partial.display()))? + .len(); + if bytes == 0 { + let _ = std::fs::remove_file(&partial); + return Err(format!("{url} produced an empty file")); + } + + if let Some(expected) = expected { + eprintln!("verifying {} …", human(bytes)); + let digest = crate::boot::sha256::file(&partial, |_, _| {}) + .map_err(|e| format!("{}: {e}", partial.display()))?; + if !digest.eq_ignore_ascii_case(expected) { + // **Loud, fatal, and the file is destroyed.** A mismatch is a corrupted + // transfer, the wrong file, or a mirror that is not what it claims — and + // leaving it on disk means somebody registers it tomorrow by hand. + let _ = std::fs::remove_file(&partial); + return Err(format!( + "digest mismatch — {} was deleted\n expected {expected}\n found {digest}", + partial.display() + )); + } + } + + // Only now does it become a file the catalogue can see. Rename within one directory + // is atomic on POSIX, which is the same guarantee the file store already relies on. + std::fs::rename(&partial, &target).map_err(|e| { + format!( + "cannot move {} to {}: {e}", + partial.display(), + target.display() + ) + })?; + + Ok(Fetched { + path: target, + bytes, + via: program, + }) +} + +/// The tool to use, and how to ask it. Both are told to resume, to follow redirects and +/// to fail on an HTTP error rather than writing the error page to disk under a name +/// ending in `.iso`. +/// Fetch a small text document and return it, rather than writing it to disk. +/// +/// This exists for checksum indexes — a few kilobytes that are read once and never kept. +/// Writing them into the media directory would put files there that are not images, and +/// the whole design of that directory is that everything in it is an image somebody can +/// serve. +/// +/// **Capped, because nothing checked what the URL points at.** A redirect to a DVD image +/// would otherwise be read into memory on a NAS with 512 MB of it. The cap is far above +/// any real index — Proxmox's is a few kilobytes, Debian's a few hundred lines — and far +/// below anything that could hurt. +pub fn fetch_text(url: &str) -> Result { + const CAP: usize = 4 * 1024 * 1024; + + if !looks_like_a_url(url) { + return Err(format!("{url:?} is not a URL")); + } + let (program, args) = text_downloader(url).ok_or_else(|| { + "neither curl nor wget is installed, and there is no TLS in this binary — \ + install one, or fetch the index yourself and pass --sha256 by hand" + .to_string() + })?; + + let out = std::process::Command::new(program) + .args(&args) + .output() + .map_err(|e| format!("cannot run {program}: {e}"))?; + if !out.status.success() { + // The downloader's own words: its 404 and its TLS failure read better than + // anything this could invent, and they are what a person searches for. + let said = String::from_utf8_lossy(&out.stderr); + let said = said.trim(); + return Err(format!( + "{program} could not fetch {url}{}", + if said.is_empty() { + String::new() + } else { + format!(": {said}") + } + )); + } + if out.stdout.len() > CAP { + return Err(format!( + "{url} returned more than {} — that is not a checksum index", + human(CAP as u64) + )); + } + String::from_utf8(out.stdout) + .map_err(|_| format!("{url} is not text, so it is not a checksum index")) +} + +fn text_downloader(url: &str) -> Option<(&'static str, Vec)> { + if on_path("curl") { + return Some(( + "curl", + vec![ + "--fail".into(), + "--location".into(), + "--silent".into(), + "--show-error".into(), + // An index that takes a minute is a mirror that is down. + "--max-time".into(), + "60".into(), + url.into(), + ], + )); + } + if on_path("wget") { + return Some(( + "wget", + vec![ + "--quiet".into(), + "--timeout=60".into(), + "--output-document".into(), + "-".into(), + url.into(), + ], + )); + } + None +} + +fn downloader(into: &Path, url: &str) -> Option<(&'static str, Vec)> { + let into = into.to_string_lossy().into_owned(); + if on_path("curl") { + return Some(( + "curl", + vec![ + // Without --fail a 404 is written out as a file, and the next command + // registers an HTML page as an installer image. + "--fail".into(), + // Mirrors redirect constantly. + "--location".into(), + // Resume onto a partial file, which is what makes a failed 1.5 GB + // download cost the remainder rather than the whole thing. + "--continue-at".into(), + "-".into(), + "--progress-bar".into(), + "--output".into(), + into, + url.into(), + ], + )); + } + if on_path("wget") { + return Some(( + "wget", + vec![ + "--continue".into(), + "--progress=bar:force".into(), + "--output-document".into(), + into, + url.into(), + ], + )); + } + None +} + +fn on_path(program: &str) -> bool { + let Ok(path) = std::env::var("PATH") else { + return false; + }; + std::env::split_paths(&path).any(|dir| dir.join(program).is_file()) +} + +/// The filename a URL implies, which is what the entry will be called. +/// +/// Refused rather than guessed at when the URL does not carry one: an image named after +/// a query string is an identifier nobody can type, and it becomes part of a URL that a +/// machine has to fetch. +pub fn name_from(url: &str) -> Result { + let without_query = url.split(['?', '#']).next().unwrap_or(url); + let last = without_query + .trim_end_matches('/') + .rsplit('/') + .next() + .unwrap_or(""); + let decoded = percent_decode(last); + if decoded.is_empty() || !decoded.contains('.') { + return Err(format!( + "cannot tell what to call the image from {url} — pass --as NAME.iso" + )); + } + let stem = decoded.rsplit_once('.').map(|(s, _)| s).unwrap_or(&decoded); + if !crate::store::valid_id(stem) { + return Err(format!( + "{url} implies the name {decoded:?}, whose stem is not a usable identifier — \ + it becomes part of a URL a machine has to fetch. Pass --as NAME.iso." + )); + } + Ok(decoded) +} + +/// Just enough to turn `%2B` back into `+`. A vendor's download path occasionally +/// carries one, and a file named `proxmox%2Dve.iso` is nobody's idea of an identifier. +fn percent_decode(text: &str) -> String { + let bytes = text.as_bytes(); + let mut out = String::with_capacity(text.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' && i + 2 < bytes.len() { + if let Ok(byte) = u8::from_str_radix(&text[i + 1..i + 3], 16) { + out.push(byte as char); + i += 3; + continue; + } + } + out.push(bytes[i] as char); + i += 1; + } + out +} + +fn human(bytes: u64) -> String { + const UNITS: [&str; 5] = ["B", "K", "M", "G", "T"]; + let mut size = bytes as f64; + let mut unit = 0; + while size >= 1024.0 && unit + 1 < UNITS.len() { + size /= 1024.0; + unit += 1; + } + if unit == 0 { + format!("{bytes}B") + } else { + format!("{size:.1}{}", UNITS[unit]) + } +} + +/// Whether an argument is a URL rather than a path, which is what decides whether +/// `media add` fetches or registers. +pub fn looks_like_a_url(argument: &str) -> bool { + argument.starts_with("http://") || argument.starts_with("https://") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_url_is_told_from_a_path() { + assert!(looks_like_a_url( + "https://enterprise.proxmox.com/iso/pve.iso" + )); + assert!(looks_like_a_url("http://mirror/pve.iso")); + assert!(!looks_like_a_url("/srv/media/pve.iso")); + assert!(!looks_like_a_url("pve.iso")); + // Deliberately not FTP or file: neither is a path this fetches, and treating one + // as a URL would hand it to curl and produce a confusing failure. + assert!(!looks_like_a_url("ftp://mirror/pve.iso")); + } + + #[test] + fn the_name_comes_from_the_last_path_segment() { + assert_eq!( + name_from("https://enterprise.proxmox.com/iso/proxmox-ve_8.4-1.iso").expect("named"), + "proxmox-ve_8.4-1.iso" + ); + // A query string is not part of the name; plenty of mirrors add one. + assert_eq!( + name_from("https://mirror/ubuntu-24.04.iso?mirror=de&x=1").expect("named"), + "ubuntu-24.04.iso" + ); + assert_eq!( + name_from("https://mirror/rocky.iso#sha256").expect("named"), + "rocky.iso" + ); + } + + #[test] + fn a_url_that_implies_no_usable_name_is_refused_rather_than_guessed_at() { + // The name becomes part of a URL a machine has to fetch, so an unusable one is + // worth stopping for rather than mangling into something nobody can type. + for url in [ + "https://mirror/download?id=42", + "https://mirror/", + "https://mirror/iso/", + ] { + assert!(name_from(url).is_err(), "{url} should be refused"); + } + let e = name_from("https://mirror/download?id=42").expect_err("refused"); + assert!(e.contains("--as"), "the way out has to be named: {e}"); + } + + #[test] + fn a_percent_escape_in_the_path_is_decoded() { + assert_eq!(percent_decode("proxmox%2Dve.iso"), "proxmox-ve.iso"); + assert_eq!(percent_decode("plain.iso"), "plain.iso"); + // A stray `%` is left alone rather than eating the next two characters. + assert_eq!(percent_decode("100%.iso"), "100%.iso"); + } + + #[test] + fn an_existing_image_is_never_overwritten() { + // Machines may be booting it right now, and a half-written image is one they + // would boot into something that does not work. + let dir = std::env::temp_dir().join(format!("rescriptum-fetch-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("temp dir"); + std::fs::write(dir.join("pve.iso"), b"an image somebody is using").expect("write"); + + let e = fetch("https://mirror/pve.iso", &dir, None, None).expect_err("must refuse"); + assert!(e.contains("already exists"), "{e}"); + assert!(e.contains("--as"), "{e}"); + // And it is untouched. + assert_eq!( + std::fs::read(dir.join("pve.iso")).expect("read"), + b"an image somebody is using" + ); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn the_downloader_refuses_to_write_an_error_page_to_disk() { + // Without `--fail` a 404 is written out, and the next command registers an HTML + // page as an installer image — which probes as unknown and boots as nothing. + let Some((program, args)) = downloader(Path::new("/tmp/x.part"), "https://m/x.iso") else { + // Neither tool installed: nothing to assert, and `fetch` says so itself. + return; + }; + match program { + "curl" => { + assert!(args.iter().any(|a| a == "--fail"), "{args:?}"); + assert!(args.iter().any(|a| a == "--location"), "{args:?}"); + assert!(args.iter().any(|a| a == "--continue-at"), "{args:?}"); + } + "wget" => assert!(args.iter().any(|a| a == "--continue"), "{args:?}"), + other => panic!("unexpected downloader {other}"), + } + } +} diff --git a/src/boot/iso.rs b/src/boot/iso.rs new file mode 100644 index 0000000..c2b9bf8 --- /dev/null +++ b/src/boot/iso.rs @@ -0,0 +1,1058 @@ +//! Reading ISO9660, far enough to find a file and say where it is. +//! +//! The property this is built on: **a file in an ISO9660 image is one contiguous +//! extent.** So "extract the kernel" is not an extraction at all — it is an offset and +//! a length, and the media listener streams those bytes straight out of the image with +//! a seek. Nothing is unpacked, nothing is copied, and a 1.5 GB image costs the same +//! few kilobytes of reads whether we want its `/boot/linux26` or its `/.disk/info`. +//! +//! Only the read half lives here. Writing — adding `auto-installer-mode.toml` to a +//! Proxmox image without rewriting 1.5 GB — is Phase 4, and it will build on the same +//! parsing. +//! +//! Three name spaces can coexist in one image and this matters more than it sounds: +//! +//! - **ISO9660** proper, whose identifiers are upper-case, dot-bearing and suffixed +//! with `;1`. `auto-installer-mode.toml` is not expressible in it at all. +//! - **Rock Ridge** (SUSP `NM` entries), which is what Linux actually shows when it +//! mounts one, and therefore what an installer looking for its own file will see. +//! - **Joliet**, a second directory tree entirely, with UCS-2 names. +//! +//! A lookup here tries the Rock Ridge name first and the ISO9660 identifier second, so +//! a marker path written the way a human writes it resolves either way. + +use std::fs::File; +use std::io::{self, Read, Seek, SeekFrom}; +use std::path::{Path, PathBuf}; + +/// ISO9660's logical sector. Not configurable in practice — the field exists in the +/// descriptor, and every image ever written puts 2048 in it. +pub const SECTOR: u64 = 2048; +/// The first sixteen sectors are the system area, reserved for boot code. The primary +/// volume descriptor is what follows. +const FIRST_DESCRIPTOR: u64 = 16 * SECTOR; +/// A directory record is at least this long before its name. +const RECORD_HEADER: usize = 33; +/// Guard against a malformed image sending the walk around forever. +const MAX_DEPTH: usize = 16; +/// A directory extent larger than this is not a directory, it is a corrupt field. +const MAX_DIRECTORY: u64 = 16 * 1024 * 1024; + +/// Where a file's bytes are, which is all the listener needs to serve it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Extent { + /// Byte offset into the image. + pub offset: u64, + pub size: u64, + pub directory: bool, +} + +/// Which name spaces an image carries. Phase 1 reports it; Phase 4 refuses on it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct Trees { + /// SUSP is present, and with it Rock Ridge's long names. + pub rock_ridge: bool, + /// A supplementary descriptor with a Joliet escape sequence. + pub joliet: bool, + /// A UDF filesystem alongside the ISO9660 one. **A Windows ISO is UDF+ISO9660 and + /// its large files exist only in the UDF tree**, so patching the ISO9660 tree of + /// such an image produces something that looks right and is not. + pub udf: bool, +} + +/// An opened image: the descriptor facts, and a handle to read extents from. +pub struct Iso { + file: File, + path: PathBuf, + root: Extent, + /// The Joliet tree's root, when there is one. + joliet_root: Option, + pub trees: Trees, + /// The volume identifier, trimmed. The fallback name for an image no probe places. + pub volume_id: String, + /// Blocks the descriptor claims, times the sector size. A file extent past this is + /// a truncated download, and saying so beats serving zeros. + pub declared_size: u64, + /// Bytes at the start of every system use area that belong to somebody else, as the + /// root's `SP` entry declares. Read once at open: recomputing it per record would + /// re-parse the root directory for every entry in the image. + susp_skip: usize, + /// Where the descriptors are, in bytes. The patcher rewrites the volume space size + /// in both, and one that had to re-scan for them could disagree with the reader + /// about which descriptor it had found. + pvd_at: u64, + svd_at: Option, +} + +impl Iso { + /// Where the primary volume descriptor starts. + pub fn pvd_at(&self) -> u64 { + self.pvd_at + } + + /// Where the supplementary (Joliet) descriptor starts, if there is one. + pub fn svd_at(&self) -> Option { + self.svd_at + } + + /// The root directory extent, for a caller that walks records itself. + pub fn root_extent(&self) -> Extent { + self.root + } + + /// The Joliet tree's root, when the image has a second tree. + pub fn joliet_root_extent(&self) -> Option { + self.joliet_root + } + + /// Raw bytes out of the image, for a caller that works in offsets rather than in + /// records. + pub fn read_raw(&mut self, offset: u64, len: usize) -> io::Result> { + let mut buffer = vec![0u8; len]; + self.file.seek(SeekFrom::Start(offset))?; + self.file.read_exact(&mut buffer)?; + Ok(buffer) + } + + /// The system use area's skip length, which a patcher needs in order to write an + /// `NM` entry the same reader will find. + pub fn susp_skip(&self) -> usize { + self.susp_skip + } +} + +impl Iso { + pub fn open(path: impl AsRef) -> io::Result { + let path = path.as_ref().to_path_buf(); + let mut file = File::open(&path)?; + + let mut primary: Option<[u8; SECTOR as usize]> = None; + let mut supplementary: Option<[u8; SECTOR as usize]> = None; + // Remembered rather than recomputed: the patcher rewrites the volume space size + // in both descriptors, and one that re-scanned for them could disagree with this + // reader about which it had found. + let mut pvd_at = FIRST_DESCRIPTOR; + let mut svd_at = None; + let mut udf = false; + + // Volume descriptors run from sector 16 until a terminator. A handful of images + // carry a dozen; none carries hundreds, so the cap is a guard, not a policy. + for index in 0..32u64 { + let mut sector = [0u8; SECTOR as usize]; + file.seek(SeekFrom::Start(FIRST_DESCRIPTOR + index * SECTOR))?; + if file.read_exact(&mut sector).is_err() { + break; + } + // The volume recognition sequence shares this field: an image carrying UDF + // announces it here, in the same place and the same run of sectors. + if matches!(§or[1..6], b"NSR02" | b"NSR03") { + udf = true; + continue; + } + if matches!(§or[1..6], b"BEA01" | b"TEA01") { + continue; + } + if §or[1..6] != b"CD001" { + // Not a descriptor at all. If we have not even found the primary yet, + // this is not an ISO9660 image and saying so is the whole answer. + break; + } + let at = FIRST_DESCRIPTOR + index * SECTOR; + match sector[0] { + 1 => { + primary = Some(sector); + pvd_at = at; + } + 2 if supplementary.is_none() && is_joliet(§or) => { + supplementary = Some(sector); + svd_at = Some(at); + } + 255 => break, + _ => {} + } + } + + let Some(pvd) = primary else { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "{} has no ISO9660 primary volume descriptor — it is not an ISO image", + path.display() + ), + )); + }; + + // The root directory record sits inside the descriptor, all 34 bytes of it. + let root = record(&pvd[156..190], 0).map(|r| r.extent).ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("{}: the root directory record is malformed", path.display()), + ) + })?; + + let joliet_root = supplementary + .as_ref() + .and_then(|svd| record(&svd[156..190], 0)) + .map(|r| r.extent); + + let declared_size = both_endian32(&pvd[80..88]).unwrap_or(0) as u64 * SECTOR; + let volume_id = strip(&pvd[40..72]); + + let mut iso = Iso { + file, + path, + root, + joliet_root, + trees: Trees { + rock_ridge: false, + joliet: supplementary.is_some(), + udf, + }, + volume_id, + declared_size, + susp_skip: 0, + pvd_at, + svd_at, + }; + iso.trees.rock_ridge = iso.detect_rock_ridge(); + Ok(iso) + } + + pub fn path(&self) -> &Path { + &self.path + } + + /// Resolve an absolute path inside the image. + /// + /// **This is the path-traversal guard, and it is structural rather than a check.** + /// An ISO9660 image is its own root: `../../../etc/shadow` resolves to nothing here + /// because no such record exists in its directory tree. `..` is refused all the + /// same — belt and braces cost one line — but the property does not depend on it. + pub fn locate(&mut self, path: &str) -> io::Result> { + let root = self.root; + self.walk_in(root, path, false) + } + + /// The same, in the Joliet tree, for an image that has one. + pub fn locate_joliet(&mut self, path: &str) -> io::Result> { + let Some(root) = self.joliet_root else { + return Ok(None); + }; + self.walk_in(root, path, true) + } + + fn walk_in(&mut self, root: Extent, path: &str, joliet: bool) -> io::Result> { + let segments: Vec<&str> = path + .trim_matches('/') + .split('/') + .filter(|s| !s.is_empty() && *s != ".") + .collect(); + if segments.len() > MAX_DEPTH || segments.contains(&"..") { + return Ok(None); + } + + let mut current = root; + for segment in segments { + // A path continued past a plain file matches nothing. + if !current.directory { + return Ok(None); + } + match self.entry_in(current, segment, joliet)? { + Some(found) => current = found, + None => return Ok(None), + } + } + Ok(Some(current)) + } + + /// One directory's worth of records, matched by name. + fn entry_in( + &mut self, + directory: Extent, + name: &str, + joliet: bool, + ) -> io::Result> { + let wanted = name.to_ascii_lowercase(); + for entry in self.entries(directory)? { + let matches = if joliet { + entry.joliet_name.as_deref() == Some(wanted.as_str()) + } else { + entry.rock_ridge_name.as_deref() == Some(wanted.as_str()) || entry.name == wanted + }; + if matches { + return Ok(Some(entry.extent)); + } + } + Ok(None) + } + + /// Every record in a directory extent. Used by lookups and by the probe. + pub fn entries(&mut self, directory: Extent) -> io::Result> { + if !directory.directory || directory.size == 0 || directory.size > MAX_DIRECTORY { + return Ok(Vec::new()); + } + let mut buffer = vec![0u8; directory.size as usize]; + self.file.seek(SeekFrom::Start(directory.offset))?; + self.file.read_exact(&mut buffer)?; + + let mut entries = Vec::new(); + let mut at = 0usize; + while at + RECORD_HEADER <= buffer.len() { + let length = buffer[at] as usize; + if length == 0 { + // A zero length pads to the end of the sector; records resume in the + // next one. This is normal, not the end of the directory. + at = (at / SECTOR as usize + 1) * SECTOR as usize; + continue; + } + if length < RECORD_HEADER || at + length > buffer.len() { + break; + } + if let Some(entry) = record(&buffer[at..at + length], self.skip_length()) { + // `.` and `..` are records with one-byte identifiers 0x00 and 0x01, and + // no caller here wants them. + if !entry.special { + entries.push(entry); + } + } + at += length; + } + Ok(entries) + } + + /// Read a whole file out of the image, for the small ones — a version string, a + /// mode file. Anything image-sized is streamed by the listener instead. + pub fn read(&mut self, path: &str, limit: u64) -> io::Result>> { + let Some(extent) = self.locate(path)? else { + return Ok(None); + }; + if extent.directory { + return Ok(None); + } + let size = extent.size.min(limit) as usize; + let mut buffer = vec![0u8; size]; + self.file.seek(SeekFrom::Start(extent.offset))?; + self.file.read_exact(&mut buffer)?; + Ok(Some(buffer)) + } + + /// Whether a path resolves at all, in either tree. The probe's whole vocabulary. + pub fn has(&mut self, path: &str) -> bool { + matches!(self.locate(path), Ok(Some(e)) if !e.directory) + || matches!(self.locate_joliet(path), Ok(Some(e)) if !e.directory) + } + + fn skip_length(&self) -> usize { + self.susp_skip + } + + /// SUSP announces itself in the root directory's `.` record, and declares how many + /// bytes of each system use area belong to somebody else. + fn detect_rock_ridge(&mut self) -> bool { + // The `.` record of the root directory carries the SP entry, which is what says + // SUSP — and therefore Rock Ridge's `NM` names — is in use at all. + let root = self.root; + let Ok(mut buffer) = self.read_extent_head(root, SECTOR as usize) else { + return false; + }; + buffer.truncate(root.size.min(SECTOR) as usize); + if buffer.len() < RECORD_HEADER { + return false; + } + let length = buffer[0] as usize; + if length < RECORD_HEADER || length > buffer.len() { + return false; + } + let name_len = buffer[32] as usize; + let mut at = RECORD_HEADER + name_len; + if name_len % 2 == 0 { + at += 1; + } + let system = &buffer[at.min(length)..length]; + // SP is "SP", length 7, version 1, then 0xBE 0xEF, then the skip length. + let mut found = false; + for entry in susp_entries(system, 0) { + if entry.signature == *b"SP" && entry.data.len() >= 3 { + found = true; + self.susp_skip = entry.data[2] as usize; + } + } + found + } + + fn read_extent_head(&mut self, extent: Extent, want: usize) -> io::Result> { + let size = (extent.size as usize).min(want); + let mut buffer = vec![0u8; size]; + self.file.seek(SeekFrom::Start(extent.offset))?; + self.file.read_exact(&mut buffer)?; + Ok(buffer) + } + + /// Hand out the file so the listener can stream an extent without reopening. + pub fn into_file(self) -> File { + self.file + } +} + +/// One directory record, with every name it answers to. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Entry { + /// The ISO9660 identifier, lower-cased, with the `;1` version and a trailing dot + /// removed — the form a human would have written. + pub name: String, + /// The Rock Ridge `NM` name, when the record carries one. + pub rock_ridge_name: Option, + /// The Joliet name, when this record came from that tree. + pub joliet_name: Option, + pub extent: Extent, + /// `.` or `..`. + pub special: bool, +} + +fn record(bytes: &[u8], susp_skip: usize) -> Option { + if bytes.len() < RECORD_HEADER { + return None; + } + let name_len = bytes[32] as usize; + if RECORD_HEADER + name_len > bytes.len() { + return None; + } + let lba = both_endian32(&bytes[2..10])?; + let size = both_endian32(&bytes[10..18])?; + let directory = bytes[25] & 0x02 != 0; + let raw = &bytes[RECORD_HEADER..RECORD_HEADER + name_len]; + + let special = name_len == 1 && (raw[0] == 0 || raw[0] == 1); + let extent = Extent { + offset: lba as u64 * SECTOR, + size: size as u64, + directory, + }; + + // The system use area begins after the identifier, plus a pad byte when the + // identifier length is even. + let mut at = RECORD_HEADER + name_len; + if name_len % 2 == 0 { + at += 1; + } + let system = bytes.get(at..).unwrap_or(&[]); + + Some(Entry { + name: iso_name(raw), + rock_ridge_name: rock_ridge_name(system, susp_skip), + joliet_name: ucs2_name(raw), + extent, + special, + }) +} + +/// `LINUX26.;1` and `BOOT` and `README.TXT;1` all become what a person would type. +fn iso_name(raw: &[u8]) -> String { + let text = String::from_utf8_lossy(raw); + let text = text.split(';').next().unwrap_or(""); + text.trim_end_matches('.').to_ascii_lowercase() +} + +/// Joliet identifiers are UCS-2, big-endian. Anything outside the basic plane is not +/// something a boot file is named, so a lossy decode is the honest one. +fn ucs2_name(raw: &[u8]) -> Option { + if raw.len() < 2 || raw.len() % 2 != 0 { + return None; + } + let units: Vec = raw + .chunks_exact(2) + .map(|pair| u16::from_be_bytes([pair[0], pair[1]])) + .collect(); + let text = String::from_utf16_lossy(&units); + let text = text.split(';').next().unwrap_or(""); + Some(text.trim_end_matches('.').to_ascii_lowercase()) +} + +/// The `NM` entries of a system use area, concatenated — a long name can be split +/// across several with a CONTINUE flag. +fn rock_ridge_name(system: &[u8], skip: usize) -> Option { + let mut name = String::new(); + for entry in susp_entries(system, skip) { + if entry.signature != *b"NM" || entry.data.is_empty() { + continue; + } + let flags = entry.data[0]; + // Bits 1 and 2 mean "current" and "parent"; those are not names. + if flags & 0b0000_0110 != 0 { + return None; + } + name.push_str(&String::from_utf8_lossy(&entry.data[1..])); + if flags & 0b0000_0001 == 0 { + break; + } + } + (!name.is_empty()).then(|| name.to_ascii_lowercase()) +} + +struct Susp<'a> { + signature: [u8; 2], + data: &'a [u8], +} + +/// Walk a system use area. Entries are `signature[2] len version data…`. +fn susp_entries(system: &[u8], skip: usize) -> Vec> { + let mut out = Vec::new(); + let mut at = skip.min(system.len()); + while at + 4 <= system.len() { + let length = system[at + 2] as usize; + if length < 4 || at + length > system.len() { + break; + } + out.push(Susp { + signature: [system[at], system[at + 1]], + data: &system[at + 4..at + length], + }); + at += length; + } + out +} + +/// ISO9660 records every number twice, little-endian then big-endian. Read the little +/// half and check the other agrees — a disagreement is a corrupt image, and trusting +/// either half of it would serve garbage. +fn both_endian32(bytes: &[u8]) -> Option { + if bytes.len() < 8 { + return None; + } + let little = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]); + let big = u32::from_be_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]); + (little == big).then_some(little) +} + +fn is_joliet(sector: &[u8]) -> bool { + // The escape sequences at offset 88: %/@, %/C, %/E — UCS-2 at three levels. + let escapes = §or[88..120]; + escapes + .windows(3) + .any(|w| w == b"%/@" || w == b"%/C" || w == b"%/E") +} + +fn strip(bytes: &[u8]) -> String { + String::from_utf8_lossy(bytes).trim().to_string() +} + +/// Building an ISO9660 image, for tests and for nothing else. +/// +/// **No binary fixture in the repository.** A checked-in ISO is a blob nobody can +/// review, that nobody can vary, and that grows a repository by megabytes. This builds +/// exactly the image a test needs, in memory, and doubles as the case generator for the +/// Rock Ridge, Joliet and refusal paths Phase 4 will need. +#[cfg(any(test, feature = "test-support"))] +pub mod build { + use super::*; + use std::collections::BTreeMap; + + /// What to put in the image, and which name spaces to describe it in. + pub struct Builder { + files: BTreeMap>, + pub volume_id: String, + pub rock_ridge: bool, + pub joliet: bool, + } + + impl Default for Builder { + fn default() -> Self { + Builder { + files: BTreeMap::new(), + volume_id: "TEST VOLUME".to_string(), + rock_ridge: true, + joliet: false, + } + } + } + + impl Builder { + pub fn new() -> Builder { + Builder::default() + } + + /// One file, at `/name` or `/dir/name`. One level of directories is all any + /// marker path in the probe table needs. + pub fn file(mut self, path: &str, content: &[u8]) -> Builder { + self.files + .insert(path.trim_start_matches('/').to_string(), content.to_vec()); + self + } + + pub fn volume(mut self, id: &str) -> Builder { + self.volume_id = id.to_string(); + self + } + + pub fn rock_ridge(mut self, on: bool) -> Builder { + self.rock_ridge = on; + self + } + + pub fn joliet(mut self, on: bool) -> Builder { + self.joliet = on; + self + } + + pub fn build(&self) -> Vec { + // Directories, then their files, each on its own sector boundary. + let mut dirs: BTreeMap> = BTreeMap::new(); + dirs.insert(String::new(), Vec::new()); + for path in self.files.keys() { + match path.rsplit_once('/') { + Some((dir, _)) => { + dirs.entry(dir.to_string()).or_default().push(path.clone()); + // Every ancestor has to exist as a directory of its own — + // `/images/pxeboot/vmlinuz` needs `/images` even though no file + // sits directly in it. + let mut ancestor = dir; + while let Some((parent, _)) = ancestor.rsplit_once('/') { + dirs.entry(parent.to_string()).or_default(); + ancestor = parent; + } + } + None => dirs.entry(String::new()).or_default().push(path.clone()), + } + } + + // Sector 16 is the PVD, 17 the SVD when there is one, then the terminator. + // Data starts at 20 — fixed, so a failing test's offsets are readable. + let mut next = 20u32; + let mut dir_lba: BTreeMap = BTreeMap::new(); + for dir in dirs.keys() { + dir_lba.insert(dir.clone(), next); + next += 1; + } + // The Joliet tree is a second set of directory extents over the same data. + let mut joliet_lba: BTreeMap = BTreeMap::new(); + if self.joliet { + for dir in dirs.keys() { + joliet_lba.insert(dir.clone(), next); + next += 1; + } + } + let mut file_lba: BTreeMap = BTreeMap::new(); + for (path, content) in &self.files { + let sectors = (content.len() as u64).div_ceil(SECTOR).max(1) as u32; + file_lba.insert(path.clone(), (next, content.len() as u32)); + next += sectors; + } + let total = next; + + let mut image = vec![0u8; total as usize * SECTOR as usize]; + let put = |image: &mut Vec, lba: u32, bytes: &[u8]| { + let at = lba as usize * SECTOR as usize; + image[at..at + bytes.len()].copy_from_slice(bytes); + }; + + // Descriptors. + let root_lba = dir_lba[""]; + put( + &mut image, + 16, + &self.descriptor(1, root_lba, self.dir_size(&dirs, "", false), total), + ); + let terminator_lba = if self.joliet { + let joliet_root = joliet_lba[""]; + put( + &mut image, + 17, + &self.descriptor(2, joliet_root, self.dir_size(&dirs, "", true), total), + ); + 18 + } else { + 17 + }; + let mut terminator = vec![0u8; SECTOR as usize]; + terminator[0] = 255; + terminator[1..6].copy_from_slice(b"CD001"); + terminator[6] = 1; + put(&mut image, terminator_lba, &terminator); + + // Directory extents, in both trees. + for (dir, children) in &dirs { + let extent = self.directory(dir, children, &dirs, &dir_lba, &file_lba, false); + put(&mut image, dir_lba[dir], &extent); + if self.joliet { + let extent = self.directory(dir, children, &dirs, &joliet_lba, &file_lba, true); + put(&mut image, joliet_lba[dir], &extent); + } + } + + // File data. + for (path, content) in &self.files { + put(&mut image, file_lba[path].0, content); + } + image + } + + fn dir_size(&self, dirs: &BTreeMap>, dir: &str, _joliet: bool) -> u32 { + let _ = dirs; + let _ = dir; + SECTOR as u32 + } + + fn descriptor(&self, kind: u8, root_lba: u32, root_size: u32, total: u32) -> Vec { + let mut d = vec![0u8; SECTOR as usize]; + d[0] = kind; + d[1..6].copy_from_slice(b"CD001"); + d[6] = 1; + d[8..40].fill(b' '); + d[40..72].fill(b' '); + let id = self.volume_id.as_bytes(); + let n = id.len().min(32); + d[40..40 + n].copy_from_slice(&id[..n]); + d[80..88].copy_from_slice(&both32(total)); + if kind == 2 { + // The escape sequence is what makes a supplementary descriptor Joliet. + d[88..91].copy_from_slice(b"%/E"); + } + d[120..124].copy_from_slice(&both16(1)); + d[124..128].copy_from_slice(&both16(1)); + d[128..132].copy_from_slice(&both16(SECTOR as u16)); + let root = self.record(&[0u8], root_lba, root_size, true, kind == 2, true); + d[156..156 + root.len()].copy_from_slice(&root); + d + } + + fn directory( + &self, + dir: &str, + children: &[String], + dirs: &BTreeMap>, + dir_lba: &BTreeMap, + file_lba: &BTreeMap, + joliet: bool, + ) -> Vec { + let mut out = Vec::new(); + // `.` carries the SP entry that announces SUSP, in the root only. + out.extend_from_slice(&self.record( + &[0u8], + dir_lba[dir], + SECTOR as u32, + true, + joliet, + dir.is_empty(), + )); + let parent = dir.rsplit_once('/').map(|(p, _)| p).unwrap_or(""); + out.extend_from_slice(&self.record( + &[1u8], + dir_lba[parent], + SECTOR as u32, + true, + joliet, + false, + )); + + // Subdirectories of this one. + for other in dirs.keys() { + if other.is_empty() || other == dir { + continue; + } + let (its_parent, base) = other.rsplit_once('/').unwrap_or(("", other.as_str())); + if its_parent != dir { + continue; + } + let name = self.identifier(base, true, joliet); + out.extend_from_slice(&self.record( + &name, + dir_lba[other], + SECTOR as u32, + true, + joliet, + false, + )); + } + + for path in children { + let base = path.rsplit_once('/').map(|(_, b)| b).unwrap_or(path); + let name = self.identifier(base, false, joliet); + let (lba, size) = file_lba[path]; + out.extend_from_slice(&self.record(&name, lba, size, false, joliet, false)); + } + assert!( + out.len() <= SECTOR as usize, + "test directory outgrew a sector" + ); + out + } + + /// ISO9660 identifiers are upper-case and version-suffixed; Joliet's are UCS-2. + /// A name that cannot be expressed in ISO9660 — a hyphen, lower case — is + /// deliberately mangled here exactly as a real mastering tool would mangle it, + /// because that mangling is the trap Rock Ridge exists to undo. + fn identifier(&self, base: &str, directory: bool, joliet: bool) -> Vec { + if joliet { + let mut out = Vec::new(); + for unit in base.encode_utf16() { + out.extend_from_slice(&unit.to_be_bytes()); + } + return out; + } + let mangled: String = base + .to_ascii_uppercase() + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '.' { + c + } else { + '_' + } + }) + .collect(); + if directory { + mangled.into_bytes() + } else { + format!("{mangled};1").into_bytes() + } + } + + fn record( + &self, + name: &[u8], + lba: u32, + size: u32, + directory: bool, + joliet: bool, + with_sp: bool, + ) -> Vec { + let mut system: Vec = Vec::new(); + if self.rock_ridge && !joliet { + if with_sp { + system.extend_from_slice(&[b'S', b'P', 7, 1, 0xBE, 0xEF, 0]); + } + // A `.`/`..` record gets no NM: its name is structural. + if !(name.len() == 1 && (name[0] == 0 || name[0] == 1)) { + // The real name, the one a mounted image shows. + let real = self.rock_ridge_name_for(name); + let mut nm = vec![b'N', b'M', (5 + real.len()) as u8, 1, 0]; + nm.extend_from_slice(real.as_bytes()); + system.extend_from_slice(&nm); + } + } + + let mut r = vec![0u8; RECORD_HEADER]; + r[2..10].copy_from_slice(&both32(lba)); + r[10..18].copy_from_slice(&both32(size)); + r[25] = if directory { 0x02 } else { 0 }; + r[28..32].copy_from_slice(&both16(1)); + r[32] = name.len() as u8; + r.extend_from_slice(name); + if name.len() % 2 == 0 { + r.push(0); + } + r.extend_from_slice(&system); + if r.len() % 2 == 1 { + r.push(0); + } + r[0] = r.len() as u8; + r + } + + /// The builder mangles ISO9660 identifiers, so the Rock Ridge name has to be + /// recovered from what the caller asked for. Tests set it through `file`, and + /// the mangling is reversible enough for the fixture's purposes: the NM name is + /// the original base name, which is exactly what a mastering tool records. + fn rock_ridge_name_for(&self, mangled: &[u8]) -> String { + let text = String::from_utf8_lossy(mangled); + let stem = text.split(';').next().unwrap_or("").to_ascii_lowercase(); + // Find the original spelling among the paths we were given. + for path in self.files.keys() { + let base = path.rsplit_once('/').map(|(_, b)| b).unwrap_or(path); + if mangle_matches(base, &stem) { + return base.to_string(); + } + if let Some((dir, _)) = path.split_once('/') + && mangle_matches(dir, &stem) + { + return dir.to_string(); + } + } + stem + } + } + + fn mangle_matches(original: &str, mangled: &str) -> bool { + let expected: String = original + .to_ascii_lowercase() + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '.' { + c + } else { + '_' + } + }) + .collect(); + expected == mangled + } + + fn both32(value: u32) -> [u8; 8] { + let mut out = [0u8; 8]; + out[..4].copy_from_slice(&value.to_le_bytes()); + out[4..].copy_from_slice(&value.to_be_bytes()); + out + } + + fn both16(value: u16) -> [u8; 4] { + let mut out = [0u8; 4]; + out[..2].copy_from_slice(&value.to_le_bytes()); + out[2..].copy_from_slice(&value.to_be_bytes()); + out + } + + /// Write an image to a temporary file and open it, which is what every test wants. + pub fn open(name: &str, builder: &Builder) -> (std::path::PathBuf, Iso) { + let dir = std::env::temp_dir().join(format!("rescriptum-iso-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("temp dir"); + let path = dir.join(format!("{name}.iso")); + std::fs::write(&path, builder.build()).expect("write image"); + let iso = Iso::open(&path).expect("opens"); + (path, iso) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_file_resolves_to_an_offset_and_a_length() { + // The property the whole listener rests on: a file is a contiguous extent, so + // serving it is a seek and a length rather than an extraction. + let (_path, mut iso) = build::open( + "basic", + &build::Builder::new().file("/boot/linux26", b"kernel bytes"), + ); + + let extent = iso + .locate("/boot/linux26") + .expect("readable") + .expect("present"); + assert_eq!(extent.size, 12); + assert_eq!(extent.offset % SECTOR, 0, "extents are sector-aligned"); + assert!(!extent.directory); + + assert_eq!( + iso.read("/boot/linux26", 1024).expect("readable"), + Some(b"kernel bytes".to_vec()) + ); + } + + #[test] + fn a_missing_path_is_absence_rather_than_an_error() { + let (_path, mut iso) = + build::open("missing", &build::Builder::new().file("/present", b"x")); + assert_eq!(iso.locate("/absent").expect("readable"), None); + assert_eq!(iso.locate("/absent/deeper").expect("readable"), None); + // A path continued past a plain file resolves to nothing rather than to the file. + assert_eq!(iso.locate("/present/deeper").expect("readable"), None); + } + + #[test] + fn traversal_out_of_the_image_resolves_to_nothing() { + // The guard is structural — no such record exists in the directory tree — and + // `..` is refused outright on top of that. + let (_path, mut iso) = build::open( + "traversal", + &build::Builder::new().file("/boot/linux26", b"k"), + ); + for path in [ + "/../etc/shadow", + "/boot/../../etc/shadow", + "../../../etc/passwd", + ] { + assert_eq!(iso.locate(path).expect("readable"), None, "{path}"); + } + } + + #[test] + fn a_rock_ridge_name_wins_over_the_mangled_identifier() { + // `auto-installer-mode.toml` cannot be spelled in ISO9660 — hyphens are not + // allowed, lower case is not allowed, and 8.3 does not stretch that far. The + // record is called `AUTO_INSTALLER_MODE.TOM;1` and only the Rock Ridge `NM` + // entry carries the name the installer actually looks for. Getting this wrong + // is the trap that puts the file in the image and hides it from its reader. + let (_path, mut iso) = build::open( + "rockridge", + &build::Builder::new().file("/auto-installer-mode.toml", b"mode = \"http\"\n"), + ); + assert!(iso.trees.rock_ridge, "SP announces SUSP"); + assert!(iso.has("/auto-installer-mode.toml")); + // The mangled identifier still resolves, because it is genuinely in the image. + assert!(iso.has("/auto_installer_mode.toml")); + } + + #[test] + fn an_image_without_rock_ridge_only_answers_to_the_mangled_name() { + let (_path, mut iso) = build::open( + "plain", + &build::Builder::new() + .rock_ridge(false) + .file("/auto-installer-mode.toml", b"x"), + ); + assert!(!iso.trees.rock_ridge); + assert!( + !iso.has("/auto-installer-mode.toml"), + "the hyphen is not in the image" + ); + assert!(iso.has("/auto_installer_mode.toml")); + } + + #[test] + fn a_joliet_tree_is_detected_and_searchable() { + // Which tree a mount reads is not ours to decide, so both have to be visible. + let (_path, mut iso) = build::open( + "joliet", + &build::Builder::new() + .rock_ridge(false) + .joliet(true) + .file("/boot/linux26", b"kernel"), + ); + assert!(iso.trees.joliet); + assert!( + iso.locate_joliet("/boot/linux26") + .expect("readable") + .is_some(), + "the Joliet tree carries the readable name" + ); + assert!(iso.has("/boot/linux26")); + } + + #[test] + fn the_volume_identifier_is_the_fallback_name() { + let (_path, iso) = build::open( + "volume", + &build::Builder::new().volume("PVE 8.4-1").file("/x", b"y"), + ); + assert_eq!(iso.volume_id, "PVE 8.4-1"); + assert!(iso.declared_size > 0); + } + + #[test] + fn something_that_is_not_an_iso_is_refused_by_name() { + let dir = std::env::temp_dir().join(format!("rescriptum-iso-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("temp dir"); + let path = dir.join("not-an-iso.iso"); + std::fs::write(&path, vec![0u8; 100 * 1024]).expect("write"); + + // `Iso` holds an open file rather than deriving `Debug`, so unwrap by hand. + let Err(e) = Iso::open(&path) else { + panic!("a file of zeros must not open as an image"); + }; + assert!(e.to_string().contains("not an ISO image"), "{e}"); + } + + #[test] + fn a_number_written_two_ways_must_agree() { + // ISO9660 records every integer twice. A disagreement is a corrupt image, and + // trusting either half would serve garbage from a plausible-looking offset. + let mut bytes = [0u8; 8]; + bytes[..4].copy_from_slice(&7u32.to_le_bytes()); + bytes[4..].copy_from_slice(&7u32.to_be_bytes()); + assert_eq!(both_endian32(&bytes), Some(7)); + + bytes[4..].copy_from_slice(&9u32.to_be_bytes()); + assert_eq!(both_endian32(&bytes), None); + } +} diff --git a/src/boot/loaders.rs b/src/boot/loaders.rs new file mode 100644 index 0000000..8d16fbd --- /dev/null +++ b/src/boot/loaders.rs @@ -0,0 +1,296 @@ +//! What firmware announces in DHCP option 93, and which loader it gets. +//! +//! A small alias table in the spirit of `format::endpoint_formats` — auditable, not +//! clever. **One table, two consumers**: the TFTP server serves from it and +//! `boot dhcp-snippet` generates from it, so the configuration an operator pastes into +//! their DHCP server and the files this one actually hands out cannot drift apart. A +//! snippet naming a loader that is not on disk fails *silently, at the ROM*, which is +//! the most common way this goes wrong and the least diagnosable. +//! +//! What it serves is **our own branded iPXE build, always.** A stock netboot.xyz binary +//! embeds a script that chains to the public `boot.netboot.xyz`, which is exactly the +//! failure the whole entry-point design exists to prevent. netboot.xyz stays what it is +//! here: menus served over HTTP, never the loader TFTP hands out. +//! +//! The values come from IANA's "Processor Architecture Types" registry **plus one +//! recorded exception**: RFC 4578 defined `0x0009` as "EFI x86-64", and the registry — +//! rewritten by RFC 5970 — lists it as "EBC". Real x64 firmware announces `0x0007` or +//! `0x0009`, so both map to x64, exactly as every deployed dhcpd example does. A table +//! generated from the registry alone would hand x64 firmware nothing. + +/// How a client reaches us at all. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Transport { + /// The loader arrives over TFTP, named by DHCP options 66/67. + Tftp, + /// Firmware fetches it over HTTP itself — option 60 `HTTPClient` plus a URL in 67. + /// The shortest chain there is, and it skips TFTP entirely. + Http, +} + +/// One row: what the firmware said it is, and what it gets. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Client { + /// The option 93 value, as the ROM sends it. + pub arch: u16, + pub label: &'static str, + /// The loader, or `None` when we have nothing to give it. + pub loader: Option<&'static str>, + pub transport: Transport, + /// Said out loud when `loader` is `None`. A refusal that names its reason is a + /// bug report; a silent one is a machine that hangs at power-on. + pub refusal: &'static str, +} + +const UNBUILT: &str = "not in the build matrix — no machine that needs it has been seen, and a loader \ + nobody has booted is worse than an honest refusal"; + +/// Every value the registry defines that a PXE ROM might plausibly send. +pub const TABLE: &[Client] = &[ + Client { + arch: 0x0000, + label: "BIOS PXE", + // UNDI is upstream's own chainloading recommendation. `ipxe.kpxe` (native + // drivers) is the one to reach for when a NIC's UNDI stack misbehaves. + loader: Some("ipxe-undionly.kpxe"), + transport: Transport::Tftp, + refusal: "", + }, + Client { + arch: 0x0006, + label: "UEFI IA32", + loader: None, + transport: Transport::Tftp, + refusal: UNBUILT, + }, + Client { + arch: 0x0007, + label: "UEFI x86-64", + loader: Some("ipxe-x86_64.efi"), + transport: Transport::Tftp, + refusal: "", + }, + Client { + // The recorded exception: IANA calls this EBC, RFC 4578 called it x86-64, and + // real firmware sends it meaning x86-64. + arch: 0x0009, + label: "UEFI x86-64 (announced as EBC; see RFC 4578)", + loader: Some("ipxe-x86_64.efi"), + transport: Transport::Tftp, + refusal: "", + }, + Client { + arch: 0x000a, + label: "UEFI ARM32", + loader: None, + transport: Transport::Tftp, + refusal: UNBUILT, + }, + Client { + arch: 0x000b, + label: "UEFI ARM64", + loader: Some("ipxe-arm64.efi"), + transport: Transport::Tftp, + refusal: "", + }, + Client { + arch: 0x000f, + label: "UEFI IA32, HTTP boot", + loader: None, + transport: Transport::Http, + // HTTP transport does not add a loader the build matrix lacks. + refusal: UNBUILT, + }, + Client { + arch: 0x0010, + label: "UEFI x86-64, HTTP boot", + loader: Some("ipxe-x86_64.efi"), + transport: Transport::Http, + refusal: "", + }, + Client { + arch: 0x0011, + label: "EBC, HTTP boot", + loader: None, + transport: Transport::Http, + refusal: UNBUILT, + }, + Client { + arch: 0x0012, + label: "UEFI ARM32, HTTP boot", + loader: None, + transport: Transport::Http, + refusal: UNBUILT, + }, + Client { + arch: 0x0013, + label: "UEFI ARM64, HTTP boot", + loader: Some("ipxe-arm64.efi"), + transport: Transport::Http, + refusal: "", + }, + Client { + arch: 0x0014, + label: "BIOS, HTTP boot", + loader: None, + transport: Transport::Http, + refusal: UNBUILT, + }, + Client { + arch: 0x0015, + label: "ARM32 U-Boot", + loader: None, + transport: Transport::Tftp, + refusal: UNBUILT, + }, + Client { + arch: 0x0016, + label: "ARM64 U-Boot", + loader: None, + transport: Transport::Tftp, + refusal: UNBUILT, + }, +]; + +pub fn for_arch(arch: u16) -> Option<&'static Client> { + TABLE.iter().find(|c| c.arch == arch) +} + +/// Every distinct loader filename the table can hand out — what `boot check` looks for +/// on disk, and what a release has to publish. +pub fn loaders() -> Vec<&'static str> { + let mut names: Vec<&'static str> = TABLE.iter().filter_map(|c| c.loader).collect(); + names.sort_unstable(); + names.dedup(); + names +} + +/// The loader a ROM that announces nothing at all should get. +/// +/// Every architecture line in a generated snippet is tag-matched, so a client matching +/// no tag would get no boot file and simply stop. The only clients that old are BIOS, +/// and a DHCP client that is not netbooting ignores boot options entirely — so an +/// untagged default costs nothing and covers the case. +pub const FALLBACK: &str = "ipxe-undionly.kpxe"; + +/// `snp` uses UEFI's Simple Network Protocol; `snponly` uses the firmware's own NIC +/// driver and is the one to reach for when the plain build cannot see the network. +/// +/// **Serve all of them and let the table pick** — this is precisely the knowledge an +/// operator should not have to acquire. The variants are alternatives for one row +/// rather than rows of their own, because option 93 cannot tell them apart: nothing in +/// the protocol says "my UNDI stack is broken". +pub fn variants(loader: &str) -> Vec { + match loader.strip_suffix(".efi") { + Some(stem) => vec![ + loader.to_string(), + format!("{stem}-snp.efi"), + format!("{stem}-snponly.efi"), + ], + None => vec![loader.to_string()], + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_architectures_that_matter_get_a_loader() { + for (arch, expected) in [ + (0x0000u16, "ipxe-undionly.kpxe"), + (0x0007, "ipxe-x86_64.efi"), + (0x0009, "ipxe-x86_64.efi"), + (0x000b, "ipxe-arm64.efi"), + ] { + let client = for_arch(arch).unwrap_or_else(|| panic!("{arch:#06x} is in the table")); + assert_eq!(client.loader, Some(expected), "{arch:#06x}"); + } + } + + #[test] + fn the_registry_and_the_rfc_disagree_about_0x0009_and_both_map_to_x64() { + // IANA (via RFC 5970) calls it EBC; RFC 4578 defined it as EFI x86-64; real x64 + // firmware sends either. A table generated from the registry alone would hand + // x64 firmware nothing at all. + assert_eq!( + for_arch(0x0007).and_then(|c| c.loader), + Some("ipxe-x86_64.efi") + ); + assert_eq!( + for_arch(0x0009).and_then(|c| c.loader), + Some("ipxe-x86_64.efi") + ); + assert!(for_arch(0x0009).unwrap().label.contains("RFC 4578")); + } + + #[test] + fn http_boot_is_a_transport_rather_than_a_second_architecture() { + // 0x0010 and 0x0013 are the same silicon as 0x0007 and 0x000b, fetching over + // HTTP instead of TFTP — so they get the same loader and skip TFTP entirely. + for (tftp, http) in [(0x0007u16, 0x0010u16), (0x000b, 0x0013)] { + let a = for_arch(tftp).expect("in the table"); + let b = for_arch(http).expect("in the table"); + assert_eq!(a.loader, b.loader, "{tftp:#06x} vs {http:#06x}"); + assert_eq!(a.transport, Transport::Tftp); + assert_eq!(b.transport, Transport::Http); + } + } + + #[test] + fn a_thirty_two_bit_client_is_refused_over_both_transports() { + // An earlier draft of the table served 0x000f — IA32 over HTTP — while refusing + // 0x0006, the same architecture over TFTP, with no IA32 loader anywhere in the + // build matrix. HTTP transport does not conjure a loader. + for arch in [0x0006u16, 0x000f, 0x000a, 0x0012] { + let client = for_arch(arch).unwrap_or_else(|| panic!("{arch:#06x} is in the table")); + assert_eq!(client.loader, None, "{arch:#06x}"); + assert!(!client.refusal.is_empty(), "{arch:#06x} must say why"); + } + } + + #[test] + fn every_refusal_names_a_reason() { + // A refusal that names its reason is a bug report; a silent one is a machine + // that hangs at power-on with nothing on the console. + for client in TABLE { + assert_eq!( + client.loader.is_none(), + !client.refusal.is_empty(), + "{:#06x} must either serve something or say why not", + client.arch + ); + } + } + + #[test] + fn the_loaders_a_release_must_publish_are_derivable_from_the_table() { + // `boot check` looks for exactly these on disk, and a release publishes exactly + // these. Both read the table rather than a second list that could drift. + assert_eq!( + loaders(), + vec!["ipxe-arm64.efi", "ipxe-undionly.kpxe", "ipxe-x86_64.efi"] + ); + } + + #[test] + fn an_efi_loader_has_snp_variants_and_a_bios_one_does_not() { + assert_eq!( + variants("ipxe-x86_64.efi"), + vec![ + "ipxe-x86_64.efi", + "ipxe-x86_64-snp.efi", + "ipxe-x86_64-snponly.efi" + ] + ); + assert_eq!(variants("ipxe-undionly.kpxe"), vec!["ipxe-undionly.kpxe"]); + } + + #[test] + fn an_architecture_nobody_registered_is_simply_unknown() { + assert!(for_arch(0x00ff).is_none()); + // And the fallback is what a ROM announcing nothing gets. + assert_eq!(FALLBACK, "ipxe-undionly.kpxe"); + assert!(loaders().contains(&FALLBACK)); + } +} diff --git a/src/boot/media.rs b/src/boot/media.rs new file mode 100644 index 0000000..fa370d9 --- /dev/null +++ b/src/boot/media.rs @@ -0,0 +1,1155 @@ +//! The media listener: images, kernels and initrds, over HTTP, on its own socket. +//! +//! **Its own socket is forced rather than preferred, three times over:** +//! +//! 1. The answer endpoint answers on *any* path — the URL is baked into an ISO and must +//! never be wrong. A `/media/…` namespace would carve a reserved prefix out of a +//! space that is deliberately unreserved. +//! 2. `RESCRIPTUM_TIMEOUT_SECS` is a whole-connection deadline of ten seconds. A 1.5 GB +//! transfer is fifteen seconds on gigabit and two minutes on 100 Mbit, so on the +//! answer listener **every image download would be killed mid-transfer** — and it +//! would look like a flaky network rather than a setting. +//! 3. `RESCRIPTUM_MAX_CONNECTIONS` is a semaphore of in-flight connections, and a +//! download holds its permit for minutes. Shared budgets mean a rollout starves its +//! own answer requests. +//! +//! `admin.rs` already has its own listener for its own reasons. Same shape. +//! +//! Everything here is **read-only**: no `PUT`, no `DELETE`, no upload. Writing media is +//! an admin-side act, and it is a command rather than a request — no request may ever +//! trigger work proportional to the size of an image. + +use super::catalog::{Catalog, Entry}; +use super::{cpio, iso}; +use crate::config::Config; +use crate::log; +use hyper::body::{Bytes, Frame, Incoming, SizeHint}; +use hyper::server::conn::http1; +use hyper::service::service_fn; +use hyper::{Method, Request, Response, StatusCode}; +use hyper_util::rt::{TokioIo, TokioTimer}; +use std::convert::Infallible; +use std::io::{self, Read, Seek, SeekFrom}; +use std::net::SocketAddr; +use std::path::PathBuf; +use std::pin::Pin; +use std::sync::Arc; +use std::task::{Context, Poll}; +use tokio::net::TcpListener; +use tokio::sync::{Semaphore, mpsc}; + +/// Bytes read and sent at a time. Sixteen concurrent transfers at this size, with one +/// chunk queued behind the one being written, cost about two megabytes of buffers — +/// which is the arithmetic that has to hold on a 512 MB NAS. +const CHUNK: usize = 64 * 1024; + +pub struct Media { + pub cfg: Arc, + pub catalog: Arc, +} + +pub async fn serve(listener: TcpListener, media: Arc) { + let timeout = media.cfg.media_timeout; + // Its own budget, deliberately small and deliberately not shared with answers. + let permits = Arc::new(Semaphore::new(media.cfg.media_max_connections)); + + loop { + let (stream, peer) = match listener.accept().await { + Ok(pair) => pair, + Err(e) => { + log::server(&format!("media: accept failed: {e}")); + continue; + } + }; + + // Over the cap, say so and close rather than queueing: a client waiting behind + // fifteen image downloads has no way to tell that from a dead server. + let Ok(permit) = Arc::clone(&permits).try_acquire_owned() else { + log::request(&peer.to_string(), 503, "media: 503 — at max transfers"); + drop(stream); + continue; + }; + + let media = Arc::clone(&media); + tokio::spawn(async move { + let _permit = permit; + let service = service_fn(move |req| { + let media = Arc::clone(&media); + async move { Ok::<_, Infallible>(handle(req, media, peer).await) } + }); + let serving = http1::Builder::new() + .timer(TokioTimer::new()) + // The header-read timeout stays short — that is the slowloris guard, and + // it has nothing to do with how long a transfer may take. + .header_read_timeout(Some(std::time::Duration::from_secs(10))) + .serve_connection(TokioIo::new(stream), service); + let _ = tokio::time::timeout(timeout, serving).await; + }); + } +} + +async fn handle(req: Request, media: Arc, peer: SocketAddr) -> Response { + let method = req.method().clone(); + let path = req.uri().path().to_string(); + let peer_label = peer.to_string(); + + if method != Method::GET && method != Method::HEAD { + // Read-only, and the header says which two verbs that means. + log::request(&peer_label, 405, &format!("media: {method} {path} 405")); + return Response::builder() + .status(StatusCode::METHOD_NOT_ALLOWED) + .header("Allow", "GET, HEAD") + .header("Content-Type", "text/plain; charset=utf-8") + .body(Body::once(Bytes::from("405 Method Not Allowed\n"))) + .expect("a static response always builds"); + } + + // The allowlist, when there is one. Boot traffic is unauthenticated by necessity — + // a PXE ROM has no credentials — so the controls that exist are structural, and + // this is the only one that can say "not you". + if !allowed(&media.cfg, peer) { + log::request(&peer_label, 403, &format!("media: {method} {path} 403")); + return text(StatusCode::FORBIDDEN, "403 Forbidden\n"); + } + + if path == "/health" { + log::request(&peer_label, 200, "media: GET /health 200"); + return text(StatusCode::OK, "OK\n"); + } + if path == "/" || path.is_empty() { + let json = req + .headers() + .get(hyper::header::ACCEPT) + .and_then(|v| v.to_str().ok()) + .is_some_and(|v| v.contains("application/json")); + return catalogue(&media, &peer_label, json).await; + } + + // The two generated scripts. Both live here rather than in the answer set because + // **they have to work when the answer set is empty**, which is the state every new + // install starts in. + if path == "/ipxe/bootstrap" { + let script = + super::menu::bootstrap(&media.cfg.endpoints(), media.cfg.unclaimed_boots_local()); + log::request(&peer_label, 200, "media: GET /ipxe/bootstrap 200"); + return script_response(script); + } + if path == "/ipxe/menu" { + let catalog = Arc::clone(&media.catalog); + let listing = match tokio::task::spawn_blocking(move || catalog.listing()).await { + Ok(Ok(listing)) => listing, + _ => { + log::request(&peer_label, 500, "media: GET /ipxe/menu 500"); + return text(StatusCode::INTERNAL_SERVER_ERROR, "500\n"); + } + }; + let style = super::menu::Style { + title: media + .cfg + .boot_title + .clone() + .unwrap_or_else(super::menu::Style::default_title), + timeout_millis: media.cfg.boot_timeout_millis(), + }; + let script = super::menu::menu(&listing, &media.cfg.endpoints(), &style); + log::request( + &peer_label, + 200, + &format!( + "media: GET /ipxe/menu 200 entries={}", + listing.entries.len() + ), + ); + return script_response(script); + } + + // The TFTP root over HTTP: the loaders (UEFI HTTP Boot fetches them here rather + // than over TFTP), the logo, and anything else `boot sync` put there. + if let Some(name) = path.strip_prefix("/boot/") { + return boot_asset(&media, name, &peer_label, method == Method::HEAD).await; + } + + let Some((id, what)) = route(&path) else { + log::request( + &peer_label, + 404, + &format!("media: GET {path} 404 no such route"), + ); + return text(StatusCode::NOT_FOUND, "404 Not Found\n"); + }; + + // The identifier goes through the same guard the admin API and both stores use, and + // then **into the catalogue**. The filesystem path always comes from the entry, + // never from the request: the path-traversal guard survives in letter and spirit. + if !crate::store::valid_id(&id) { + log::request(&peer_label, 404, &format!("media: GET {path} 404 bad id")); + return text(StatusCode::NOT_FOUND, "404 Not Found\n"); + } + + let catalog = Arc::clone(&media.catalog); + let wanted = what.clone(); + let looked_up = tokio::task::spawn_blocking(move || { + // `read_dir`, `open` and `seek` are all blocking, and blocking an async worker + // stalls every other transfer that thread is driving. On a NAS with a sleeping + // disk that is not theoretical. + let entry = catalog.get(&id)?; + let Some(entry) = entry else { + return Ok(None); + }; + Ok::<_, io::Error>(Some((resolve(&entry, &wanted), entry))) + }) + .await; + + let (source, entry) = match looked_up { + Ok(Ok(Some((source, entry)))) => (source, entry), + Ok(Ok(None)) => { + log::request(&peer_label, 404, &format!("media: GET {path} 404 no entry")); + return text(StatusCode::NOT_FOUND, "404 Not Found\n"); + } + Ok(Err(e)) => { + log::request(&peer_label, 500, &format!("media: GET {path} 500 {e}")); + return text(StatusCode::INTERNAL_SERVER_ERROR, "500\n"); + } + // A panic in the blocking task cannot take the server with it, and must not pass + // silently either. + Err(e) => { + log::request( + &peer_label, + 500, + &format!("media: GET {path} 500 lookup panicked: {e}"), + ); + return text(StatusCode::INTERNAL_SERVER_ERROR, "500\n"); + } + }; + + let source = match source { + Ok(source) => source, + Err(why) => { + log::request(&peer_label, 404, &format!("media: GET {path} 404 {why}")); + return text(StatusCode::NOT_FOUND, format!("404 Not Found — {why}\n")); + } + }; + + send( + req, + source, + &entry, + &peer_label, + &path, + method == Method::HEAD, + ) +} + +/// `//` and `//file/`. +fn route(path: &str) -> Option<(String, What)> { + let trimmed = path.trim_start_matches('/'); + let (id, rest) = trimmed.split_once('/')?; + if id.is_empty() { + return None; + } + let what = match rest { + "iso" | "img" => What::Image, + "kernel" => What::Kernel, + "initrd" => What::Initrd, + // `+` in a path is a literal plus; only a query string reads it as a space. + "initrd+iso" => What::InitrdIso, + other => match other.strip_prefix("file/") { + Some(inside) if !inside.is_empty() => What::Inside(inside.to_string()), + _ => return None, + }, + }; + Some((id.to_string(), what)) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum What { + Image, + Kernel, + Initrd, + InitrdIso, + Inside(String), +} + +/// One run of bytes to send: a file, a generated header, or a file with substitutions. +#[derive(Debug, Clone)] +enum Segment { + Bytes(Vec), + File { + path: PathBuf, + offset: u64, + length: u64, + }, + /// An image with a few hundred bytes substituted and a few hundred appended. The + /// plan owns the arithmetic; this only streams what it says. + Patched { + plan: Arc, + }, + /// Part of one, for a range request. + Window { + plan: Arc, + start: u64, + length: u64, + }, +} + +impl Segment { + fn len(&self) -> u64 { + match self { + Segment::Bytes(b) => b.len() as u64, + Segment::File { length, .. } => *length, + Segment::Patched { plan } => plan.len(), + Segment::Window { length, .. } => *length, + } + } +} + +/// Compute the injection for a prepared entry. A few kilobytes of reads over the +/// source's directory records — never a pass over the image. +fn plan_for( + entry: &Entry, + prepared: &super::catalog::Prepared, +) -> Result { + let mode = super::patch::mode_file( + &prepared.url, + prepared.fingerprint.as_deref(), + prepared.token.as_deref(), + ); + super::patch::add_file(&entry.path, "auto-installer-mode.toml", mode.as_bytes()) +} + +/// What to send, and whether a range may be applied to it. +struct Source { + segments: Vec, + total: u64, + /// A single contiguous file can be resumed; a synthesised archive cannot. + resumable: bool, + content_type: &'static str, +} + +/// Turn a request for part of an entry into the bytes that answer it. +/// +/// **The image is never modified and never copied.** A kernel is an offset and a length +/// inside the ISO, because a file in an ISO9660 image is one contiguous extent — so this +/// is a seek, not an extraction. +fn resolve(entry: &Entry, what: &What) -> Result { + let one = |path: PathBuf, offset: u64, length: u64, resumable: bool| Source { + segments: vec![Segment::File { + path, + offset, + length, + }], + total: length, + resumable, + content_type: "application/octet-stream", + }; + + match what { + What::Image => match &entry.prepared { + None => Ok(one(entry.path.clone(), 0, entry.size, true)), + // **Synthesised on the wire, never stored.** The source is untouched, so + // its published digest stays verifiable; a second 1.5 GB copy on disk buys + // nothing; and changing the answer URL is a matter of 300 bytes. + Some(prepared) => { + let plan = plan_for(entry, prepared)?; + let total = plan.len(); + Ok(Source { + segments: vec![Segment::Patched { + plan: Arc::new(plan), + }], + total, + resumable: true, + content_type: "application/octet-stream", + }) + } + }, + + What::Kernel | What::Initrd => { + let inside = match what { + What::Kernel => entry.probed.kernel.as_deref(), + _ => entry.probed.initrd.as_deref(), + }; + let Some(inside) = inside else { + return Err(format!( + "{} has no {} — no probe row places one in it", + entry.id, + if matches!(what, What::Kernel) { + "kernel" + } else { + "initrd" + } + )); + }; + // `prepare-iso --pxe` leaves the kernel and initrd *beside* the trimmed + // image rather than inside it, and that directory is a normal thing to + // point a media directory at. + if entry.probed.external { + let beside = entry + .beside + .clone() + .unwrap_or_else(|| PathBuf::from(".")) + .join(inside); + let size = std::fs::metadata(&beside) + .map(|m| m.len()) + .map_err(|e| format!("{}: {e}", beside.display()))?; + return Ok(one(beside, 0, size, true)); + } + let extent = extent_of(entry, inside)?; + Ok(one(entry.path.clone(), extent.offset, extent.size, true)) + } + + What::Inside(path) => { + let extent = extent_of(entry, path)?; + Ok(one(entry.path.clone(), extent.offset, extent.size, true)) + } + + // The initrd, then a cpio header naming `proxmox.iso`, then the image, then the + // padding and the trailer. Synthesised on the wire rather than stored: a 1.5 GB + // second copy on disk buys nothing, and the arithmetic for `Content-Length` is + // exact. `200` only — nothing resumes an initrd. + What::InitrdIso => { + let initrd = resolve(entry, &What::Initrd)?; + let member = cpio::member("proxmox.iso", entry.size, 1)?; + let mut segments = initrd.segments; + segments.push(Segment::Bytes(member.prefix.clone())); + segments.push(Segment::File { + path: entry.path.clone(), + offset: 0, + length: entry.size, + }); + if member.padding > 0 { + segments.push(Segment::Bytes(vec![0u8; member.padding])); + } + segments.push(Segment::Bytes(cpio::trailer())); + let total = segments.iter().map(Segment::len).sum(); + Ok(Source { + segments, + total, + resumable: false, + content_type: "application/octet-stream", + }) + } + } +} + +fn extent_of(entry: &Entry, inside: &str) -> Result { + let mut image = + iso::Iso::open(&entry.path).map_err(|e| format!("{}: {e}", entry.path.display()))?; + match image.locate(inside) { + Ok(Some(extent)) if !extent.directory => Ok(extent), + Ok(_) => Err(format!("{inside} is not a file in {}", entry.id)), + Err(e) => Err(format!("{}: {e}", entry.path.display())), + } +} + +/// Build the response, ranges and validators included. +fn send( + req: Request, + source: Source, + entry: &Entry, + peer: &str, + path: &str, + head_only: bool, +) -> Response { + // A strong validator either way. The digest when somebody pinned the image; size + // and mtime otherwise, so **a resumed transfer that raced a replacement restarts + // instead of splicing two images together** even for an image nobody pinned. + let etag = match &entry.digest { + Some(digest) => format!("\"{digest}\""), + None => format!("\"{}-{}\"", entry.size, mtime_token(entry)), + }; + + let header = |name: hyper::header::HeaderName| { + req.headers() + .get(name) + .and_then(|v| v.to_str().ok()) + .map(str::to_string) + }; + + // A resumed transfer names the validator it started with. If it no longer matches, + // the honest answer is the whole entity rather than a splice. + let raced = header(hyper::header::IF_RANGE).is_some_and(|v| v.trim() != etag); + let wanted = if source.resumable && !raced { + header(hyper::header::RANGE) + .as_deref() + .map_or(Wanted::Whole, |r| parse_range(r, source.total)) + } else { + Wanted::Whole + }; + + let mut builder = Response::builder() + .header("Content-Type", source.content_type) + .header("ETag", etag); + if source.resumable { + builder = builder.header("Accept-Ranges", "bytes"); + } else { + // Say so rather than letting a client discover it by having a range ignored. + builder = builder.header("Accept-Ranges", "none"); + } + + let (status, segments, length) = match wanted { + Wanted::Whole => (StatusCode::OK, source.segments, source.total), + Wanted::Part(start, end) => { + builder = builder.header( + "Content-Range", + format!("bytes {start}-{end}/{}", source.total), + ); + ( + StatusCode::PARTIAL_CONTENT, + slice(source.segments, start, end), + end - start + 1, + ) + } + Wanted::Unsatisfiable => { + log::request(peer, 416, &format!("media: GET {path} 416")); + return Response::builder() + .status(StatusCode::RANGE_NOT_SATISFIABLE) + .header("Content-Range", format!("bytes */{}", source.total)) + .header("Content-Type", "text/plain; charset=utf-8") + .body(Body::once(Bytes::from("416 Range Not Satisfiable\n"))) + .expect("a static response always builds"); + } + }; + + log::request( + peer, + status.as_u16(), + &format!( + "media: {} {path} {} bytes={length}", + if head_only { "HEAD" } else { "GET" }, + status.as_u16() + ), + ); + + // `HEAD` is answered because UEFI HTTP Boot asks before it fetches: same headers, + // same `Content-Length`, no body. + let body = if head_only { + Body::empty() + } else { + Body::stream(segments, length) + }; + builder + .status(status) + .header("Content-Length", length.to_string()) + .body(body) + .unwrap_or_else(|_| text(StatusCode::INTERNAL_SERVER_ERROR, "500\n")) +} + +/// Narrow a plan to the requested byte range. Only ever called on a single-file plan, +/// which is what `resumable` guarantees. +fn slice(segments: Vec, start: u64, end: u64) -> Vec { + segments + .into_iter() + .map(|segment| match segment { + Segment::File { path, offset, .. } => Segment::File { + path, + offset: offset + start, + length: end - start + 1, + }, + // A patched segment keeps its whole plan and is windowed at read time: the + // offsets are the *patched* image's, and narrowing them here would move a + // substitution relative to the bytes it belongs to. + Segment::Patched { plan } => Segment::Window { + plan, + start, + length: end - start + 1, + }, + other => other, + }) + .collect() +} + +/// A tiebreaker for the fallback validator: the image's mtime, in nanoseconds. +fn mtime_token(entry: &Entry) -> u64 { + std::fs::metadata(&entry.path) + .and_then(|m| m.modified()) + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_nanos() as u64) + .unwrap_or(0) +} + +#[derive(Debug, PartialEq, Eq)] +enum Wanted { + Whole, + /// Inclusive, as `Content-Range` writes it. + Part(u64, u64), + Unsatisfiable, +} + +/// One `bytes=` range, in the three forms that exist. +/// +/// **A multi-range request is answered `200` with the whole entity.** That is permitted, +/// and far better than half-implementing `multipart/byteranges` for a client that does +/// not exist: five of the seven installers range-fetch, and not one of them asks for +/// more than one range at a time. +fn parse_range(header: &str, total: u64) -> Wanted { + let Some(spec) = header.trim().strip_prefix("bytes=") else { + return Wanted::Whole; + }; + if spec.contains(',') { + return Wanted::Whole; + } + if total == 0 { + return Wanted::Unsatisfiable; + } + + let Some((first, last)) = spec.split_once('-') else { + return Wanted::Whole; + }; + let (first, last) = (first.trim(), last.trim()); + + match (first.is_empty(), last.is_empty()) { + // `-n`: the final n bytes. + (true, false) => match last.parse::() { + Ok(0) => Wanted::Unsatisfiable, + Ok(n) => Wanted::Part(total.saturating_sub(n), total - 1), + Err(_) => Wanted::Whole, + }, + // `a-`: from a to the end. + (false, true) => match first.parse::() { + Ok(start) if start < total => Wanted::Part(start, total - 1), + Ok(_) => Wanted::Unsatisfiable, + Err(_) => Wanted::Whole, + }, + // `a-b`, with b clamped: a client may ask past the end and expects the rest. + (false, false) => match (first.parse::(), last.parse::()) { + (Ok(start), Ok(end)) if start <= end && start < total => { + Wanted::Part(start, end.min(total - 1)) + } + (Ok(_), Ok(_)) => Wanted::Unsatisfiable, + _ => Wanted::Whole, + }, + (true, true) => Wanted::Whole, + } +} + +async fn catalogue(media: &Media, peer: &str, json: bool) -> Response { + let catalog = Arc::clone(&media.catalog); + let listing = match tokio::task::spawn_blocking(move || catalog.listing()).await { + Ok(Ok(listing)) => listing, + _ => { + log::request(peer, 500, "media: GET / 500"); + return text(StatusCode::INTERNAL_SERVER_ERROR, "500\n"); + } + }; + + log::request( + peer, + 200, + &format!("media: GET / 200 entries={}", listing.entries.len()), + ); + + if json { + let rows: Vec = listing + .entries + .iter() + .map(|e| { + serde_json::json!({ + "id": e.id, + "family": e.family().label(), + "version": e.probed.version, + "arch": e.arch().map(|a| a.label()), + "size": e.size, + "sha256": e.digest, + "bootable": e.bootable(), + }) + }) + .collect(); + let body = serde_json::json!({ "media": rows, "problems": listing.problems }).to_string(); + return Response::builder() + .status(StatusCode::OK) + .header("Content-Type", "application/json") + .header("Content-Length", body.len().to_string()) + .body(Body::once(Bytes::from(body))) + .expect("a built response"); + } + + let mut out = String::new(); + for entry in &listing.entries { + out.push_str(&format!( + "{:<20} {:<8} {:<24} {:>10} {}\n", + entry.id, + entry.family().label(), + entry.describe(), + entry.size, + if entry.bootable() { + "bootable" + } else { + "image only" + }, + )); + } + if listing.entries.is_empty() { + out.push_str("no images\n"); + } + for problem in &listing.problems { + out.push_str(&format!("problem: {problem}\n")); + } + text(StatusCode::OK, out) +} + +/// A generated iPXE script. `text/plain` because that is what iPXE reads, and no +/// caching: the menu is a rendering of a catalogue that changes when an ISO is dropped +/// in, and a cached one would show yesterday's images. +fn script_response(script: String) -> Response { + Response::builder() + .status(StatusCode::OK) + .header("Content-Type", "text/plain; charset=us-ascii") + .header("Cache-Control", "no-store") + .header("Content-Length", script.len().to_string()) + .body(Body::once(Bytes::from(script))) + .expect("a built response") +} + +/// A file from the boot directory, over HTTP. +/// +/// **UEFI HTTP Boot fetches its loader here rather than over TFTP**, which is the +/// shortest chain there is — option 60 `HTTPClient` plus a URL in 67, and no TFTP at +/// all. The same files, the same directory, a faster transport. +async fn boot_asset(media: &Media, name: &str, peer: &str, head_only: bool) -> Response { + let Some(root) = media.cfg.boot_dir.clone() else { + log::request( + peer, + 404, + &format!("media: GET /boot/{name} 404 no boot directory"), + ); + return text( + StatusCode::NOT_FOUND, + "404 Not Found — RESCRIPTUM_BOOT_DIR is not set\n", + ); + }; + + let wanted = name.to_string(); + let found = tokio::task::spawn_blocking(move || { + // The same containment rule TFTP uses, for the same reason and by the same + // means: strip anything that could climb, then check the resolved path is still + // inside the canonicalised root. A symlink out of the tree fails the second + // check even though it passes the first. + let root = root.canonicalize().ok()?; + let mut path = root.clone(); + for segment in wanted.split('/') { + if segment.is_empty() || segment == "." { + continue; + } + if segment == ".." { + return None; + } + path.push(segment); + } + let resolved = path.canonicalize().ok()?; + if !resolved.starts_with(&root) || !resolved.is_file() { + return None; + } + let size = std::fs::metadata(&resolved).ok()?.len(); + Some((resolved, size)) + }) + .await; + + let Ok(Some((path, size))) = found else { + log::request(peer, 404, &format!("media: GET /boot/{name} 404")); + return text(StatusCode::NOT_FOUND, "404 Not Found\n"); + }; + + log::request( + peer, + 200, + &format!("media: GET /boot/{name} 200 bytes={size}"), + ); + let body = if head_only { + Body::empty() + } else { + Body::stream( + vec![Segment::File { + path, + offset: 0, + length: size, + }], + size, + ) + }; + Response::builder() + .status(StatusCode::OK) + .header("Content-Type", "application/octet-stream") + .header("Content-Length", size.to_string()) + .header("Accept-Ranges", "none") + .body(body) + .unwrap_or_else(|_| text(StatusCode::INTERNAL_SERVER_ERROR, "500\n")) +} + +/// Whether a peer is inside `RESCRIPTUM_BOOT_ALLOW`, which is a comma-separated list of +/// CIDRs. Unset means anyone who can reach the port — which on a boot VLAN is the honest +/// configuration, and the documentation says so. +fn allowed(cfg: &Config, peer: SocketAddr) -> bool { + let Some(list) = &cfg.boot_allow else { + return true; + }; + list.split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .any(|cidr| in_cidr(peer.ip(), cidr)) +} + +pub(crate) fn in_cidr(address: std::net::IpAddr, cidr: &str) -> bool { + let (network, bits) = match cidr.split_once('/') { + Some((network, bits)) => match bits.parse::() { + Ok(bits) => (network, bits), + Err(_) => return false, + }, + // A bare address is that address, which is the same as a full-length prefix. + None => ( + cidr, + match address { + std::net::IpAddr::V4(_) => 32, + std::net::IpAddr::V6(_) => 128, + }, + ), + }; + let Ok(network) = network.parse::() else { + return false; + }; + + // Compare the leading `bits` of both addresses. Mixing families never matches, which + // is right: a v4 CIDR says nothing about a v6 peer. + let (a, b) = match (address, network) { + (std::net::IpAddr::V4(a), std::net::IpAddr::V4(b)) => { + (a.octets().to_vec(), b.octets().to_vec()) + } + (std::net::IpAddr::V6(a), std::net::IpAddr::V6(b)) => { + (a.octets().to_vec(), b.octets().to_vec()) + } + _ => return false, + }; + if bits as usize > a.len() * 8 { + return false; + } + let whole = (bits / 8) as usize; + if a[..whole] != b[..whole] { + return false; + } + let leftover = bits % 8; + if leftover == 0 { + return true; + } + let mask = 0xffu8 << (8 - leftover); + a[whole] & mask == b[whole] & mask +} + +// ---- the body ------------------------------------------------------------ + +/// **Streaming, never buffering.** A response is a list of runs of bytes, produced by a +/// blocking task and handed over a channel one chunk at a time, so sixteen concurrent +/// transfers cost megabytes rather than sixteen images. +pub struct Body { + inner: Inner, + remaining: u64, +} + +enum Inner { + Once(Option), + Stream(mpsc::Receiver>), +} + +impl Body { + fn once(bytes: Bytes) -> Body { + Body { + remaining: bytes.len() as u64, + inner: Inner::Once(Some(bytes)), + } + } + + fn empty() -> Body { + Body { + remaining: 0, + inner: Inner::Once(None), + } + } + + fn stream(segments: Vec, total: u64) -> Body { + // One chunk queued behind the one being written: enough to keep the socket fed, + // little enough that the arithmetic above stays true. + let (tx, rx) = mpsc::channel(1); + tokio::task::spawn_blocking(move || produce(segments, &tx)); + Body { + remaining: total, + inner: Inner::Stream(rx), + } + } +} + +/// Stream a window of a patched image. The plan resolves every offset — through the +/// source, a substitution, or the appended tail — so this only moves bytes. +fn stream_patched( + plan: &super::patch::Plan, + start: u64, + length: u64, + tx: &mpsc::Sender>, +) -> bool { + let mut file = match std::fs::File::open(&plan.source) { + Ok(file) => file, + Err(e) => { + let _ = tx.blocking_send(Err(e)); + return false; + } + }; + let mut buffer = vec![0u8; CHUNK]; + let mut at = start; + let end = start + length; + while at < end { + let want = ((end - at) as usize).min(CHUNK); + match plan.read_at(&mut file, at, &mut buffer[..want]) { + Ok(0) => return true, + Ok(n) => { + if tx + .blocking_send(Ok(Bytes::copy_from_slice(&buffer[..n]))) + .is_err() + { + return false; + } + at += n as u64; + } + Err(e) => { + let _ = tx.blocking_send(Err(e)); + return false; + } + } + } + true +} + +/// The blocking half: open, seek, read, send. A closed channel means the client hung up +/// — stop reading rather than finishing an image nobody is receiving. +fn produce(segments: Vec, tx: &mpsc::Sender>) { + for segment in segments { + match segment { + Segment::Bytes(bytes) => { + if tx.blocking_send(Ok(Bytes::from(bytes))).is_err() { + return; + } + } + Segment::Patched { plan } => { + if !stream_patched(&plan, 0, plan.len(), tx) { + return; + } + } + Segment::Window { + plan, + start, + length, + } => { + if !stream_patched(&plan, start, length, tx) { + return; + } + } + Segment::File { + path, + offset, + length, + } => { + let mut file = match std::fs::File::open(&path) { + Ok(file) => file, + Err(e) => { + let _ = tx.blocking_send(Err(e)); + return; + } + }; + if let Err(e) = file.seek(SeekFrom::Start(offset)) { + let _ = tx.blocking_send(Err(e)); + return; + } + let mut left = length; + let mut buffer = vec![0u8; CHUNK]; + while left > 0 { + let want = (left as usize).min(CHUNK); + match file.read(&mut buffer[..want]) { + // Short of what the catalogue promised: the file shrank under + // us. Ending the body early is what the client will notice + // against `Content-Length`, which is the honest signal. + Ok(0) => return, + Ok(n) => { + if tx + .blocking_send(Ok(Bytes::copy_from_slice(&buffer[..n]))) + .is_err() + { + return; + } + left -= n as u64; + } + Err(ref e) if e.kind() == io::ErrorKind::Interrupted => continue, + Err(e) => { + let _ = tx.blocking_send(Err(e)); + return; + } + } + } + } + } + } +} + +impl hyper::body::Body for Body { + type Data = Bytes; + type Error = io::Error; + + fn poll_frame( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll, io::Error>>> { + let this = self.get_mut(); + match &mut this.inner { + Inner::Once(slot) => match slot.take() { + Some(bytes) => { + this.remaining = 0; + Poll::Ready(Some(Ok(Frame::data(bytes)))) + } + None => Poll::Ready(None), + }, + Inner::Stream(rx) => match rx.poll_recv(cx) { + Poll::Ready(Some(Ok(bytes))) => { + this.remaining = this.remaining.saturating_sub(bytes.len() as u64); + Poll::Ready(Some(Ok(Frame::data(bytes)))) + } + Poll::Ready(Some(Err(e))) => Poll::Ready(Some(Err(e))), + Poll::Ready(None) => Poll::Ready(None), + Poll::Pending => Poll::Pending, + }, + } + } + + fn size_hint(&self) -> SizeHint { + SizeHint::with_exact(self.remaining) + } +} + +fn text(status: StatusCode, body: impl Into) -> Response { + Response::builder() + .status(status) + .header("Content-Type", "text/plain; charset=utf-8") + .body(Body::once(body.into())) + .expect("a static response always builds") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_three_range_forms_are_read_correctly() { + assert_eq!(parse_range("bytes=0-99", 1000), Wanted::Part(0, 99)); + assert_eq!(parse_range("bytes=500-", 1000), Wanted::Part(500, 999)); + assert_eq!(parse_range("bytes=-100", 1000), Wanted::Part(900, 999)); + // A client may ask past the end and expects the rest, not a refusal. + assert_eq!(parse_range("bytes=990-2000", 1000), Wanted::Part(990, 999)); + } + + #[test] + fn a_range_that_starts_past_the_end_is_unsatisfiable() { + assert_eq!(parse_range("bytes=1000-", 1000), Wanted::Unsatisfiable); + assert_eq!(parse_range("bytes=2000-3000", 1000), Wanted::Unsatisfiable); + assert_eq!(parse_range("bytes=-0", 1000), Wanted::Unsatisfiable); + // Nothing can be satisfied out of an empty entity. + assert_eq!(parse_range("bytes=0-0", 0), Wanted::Unsatisfiable); + } + + #[test] + fn a_multi_range_request_is_answered_whole() { + // Permitted, and far better than half-implementing multipart/byteranges for a + // client that does not exist. + assert_eq!(parse_range("bytes=0-99,200-299", 1000), Wanted::Whole); + } + + #[test] + fn something_that_is_not_a_byte_range_is_ignored() { + assert_eq!(parse_range("items=0-99", 1000), Wanted::Whole); + assert_eq!(parse_range("bytes=abc-def", 1000), Wanted::Whole); + assert_eq!(parse_range("bytes=", 1000), Wanted::Whole); + } + + #[test] + fn the_routes_are_the_ones_documented() { + assert_eq!( + route("/pve-8.4/iso"), + Some(("pve-8.4".to_string(), What::Image)) + ); + assert_eq!( + route("/pve-8.4/kernel"), + Some(("pve-8.4".to_string(), What::Kernel)) + ); + assert_eq!( + route("/pve-8.4/initrd"), + Some(("pve-8.4".to_string(), What::Initrd)) + ); + // `+` in a path is a literal plus; only a query string reads it as a space. + assert_eq!( + route("/pve-8.4/initrd+iso"), + Some(("pve-8.4".to_string(), What::InitrdIso)) + ); + assert_eq!( + route("/pve-8.4/file/boot/linux26"), + Some(( + "pve-8.4".to_string(), + What::Inside("boot/linux26".to_string()) + )) + ); + assert_eq!(route("/pve-8.4"), None); + assert_eq!(route("/pve-8.4/nonsense"), None); + assert_eq!(route("/pve-8.4/file/"), None); + } + + #[test] + fn a_cidr_allowlist_admits_and_refuses_by_prefix() { + let matches = |peer: &str, cidr: &str| in_cidr(peer.parse().expect("address"), cidr); + assert!(matches("10.0.0.5", "10.0.0.0/8")); + assert!(matches("10.0.0.5", "10.0.0.0/24")); + assert!(!matches("10.0.1.5", "10.0.0.0/24")); + assert!(matches("192.168.1.130", "192.168.1.128/25")); + assert!(!matches("192.168.1.127", "192.168.1.128/25")); + // A bare address is itself. + assert!(matches("10.0.0.5", "10.0.0.5")); + assert!(!matches("10.0.0.6", "10.0.0.5")); + // Everything matches /0, which is the "why bother" case, and it must still work. + assert!(matches("203.0.113.9", "0.0.0.0/0")); + // A v4 CIDR says nothing about a v6 peer. + assert!(!matches("::1", "10.0.0.0/8")); + assert!(matches("2001:db8::1", "2001:db8::/32")); + assert!(!matches("2001:db9::1", "2001:db8::/32")); + // Nonsense refuses rather than admits: a typo must not open the door. + assert!(!matches("10.0.0.5", "not-a-network/8")); + assert!(!matches("10.0.0.5", "10.0.0.0/wide")); + assert!(!matches("10.0.0.5", "10.0.0.0/99")); + } + + #[test] + fn an_unset_allowlist_admits_everyone() { + let cfg = Config::from_lookup(|_| None); + assert!(allowed(&cfg, "203.0.113.9:1234".parse().expect("peer"))); + } + + #[test] + fn a_set_allowlist_refuses_everyone_else() { + let cfg = Config::from_lookup(|key| { + (key == "RESCRIPTUM_BOOT_ALLOW").then(|| "10.0.0.0/8, 192.168.0.0/16".to_string()) + }); + assert!(allowed(&cfg, "10.1.2.3:1".parse().expect("peer"))); + assert!(allowed(&cfg, "192.168.5.5:1".parse().expect("peer"))); + assert!(!allowed(&cfg, "203.0.113.9:1".parse().expect("peer"))); + } + + #[test] + fn a_range_narrows_the_plan_rather_than_re_reading_it() { + let segments = vec![Segment::File { + path: PathBuf::from("/srv/media/x.iso"), + offset: 4096, + length: 1000, + }]; + let narrowed = slice(segments, 100, 199); + match &narrowed[0] { + Segment::File { offset, length, .. } => { + // The offset moves *within the extent*, which is what makes a range over + // a file inside an image work at all. + assert_eq!(*offset, 4196); + assert_eq!(*length, 100); + } + other => panic!("{other:?}"), + } + } +} diff --git a/src/boot/menu.rs b/src/boot/menu.rs new file mode 100644 index 0000000..6365409 --- /dev/null +++ b/src/boot/menu.rs @@ -0,0 +1,628 @@ +//! The two scripts the server writes for iPXE: the bootstrap, and the menu. +//! +//! ## Why a bootstrap exists at all +//! +//! DHCP hands iPXE a URL, and **a DHCP option cannot carry `${net0/mac}`**. So the URL +//! DHCP names arrives with no query string — no MAC, no serial, no UUID. A `GET` has no +//! body either, so the haystack would be empty and every machine would match nothing +//! but the default. The whole selection engine would go dark at exactly the moment it +//! matters. +//! +//! Stage two is therefore one fixed `chain` that stage three is not. It is served by +//! the media listener rather than from the answer set, because **it has to work when +//! the answer set is empty** — which is the state every new install starts in. +//! +//! ## A menu is the default answer +//! +//! The bootstrap's `||` is the whole of it: a machine something claims gets its own +//! unattended answer, and a machine nothing claims falls through to the menu. That is +//! `default.toml`'s job description word for word, applied to a different format, and +//! it needs no new concept in `select.rs`. +//! +//! ## What iPXE's own parser allows here +//! +//! Both read out of the source rather than remembered: +//! +//! - **`;` separates commands only as a whole whitespace-delimited token** +//! (`split_command` in `core/exec.c`), so a `;` inside an argument is safe. +//! - **A trailing `\` continues a line**, which is how a long URL stays readable. +//! +//! And two things that are ours to get right: **`${version}` is iPXE's own version**, +//! not ours, so everything the server knows is rendered as a literal; and the text +//! stays **ASCII**, because a BIOS text console is not UTF-8. + +use super::catalog::Listing; +use super::probe::{Arch, Family}; +use super::stanza::{self, Endpoints}; + +/// The three-line stage two, baked into no loader and configurable by nobody. +/// +/// Two expansions carry a correction each. **`netX`, not `net0`**: `net0` is merely the +/// first interface, so a server booting from its second port would identify as its +/// unused first — `netX` is iPXE's virtual scope for the device that actually booted. +/// And **`:uristring` on every SMBIOS string**: `${manufacturer}` expands to +/// `Dell Inc.`, space included, and iPXE percent-encodes nothing on plain expansion, so +/// a space in a request line is a broken fetch. +pub fn bootstrap(endpoints: &Endpoints, unclaimed_boots_local: bool) -> String { + let answer = endpoints.answer.trim_end_matches('/'); + let media = endpoints.media.trim_end_matches('/'); + // **What happens when nothing claims this machine is the whole policy**, and it is + // one line. The menu is the default and the project's thesis: a machine nobody has + // decided anything about ends up where a human can decide. `exit 0` is for a fleet + // that is already installed — it hands control back to the firmware, which moves to + // the next boot device, so netboot can stay first in the BIOS order without every + // reboot passing through a menu. It is the one that works on BIOS *and* UEFI; + // `sanboot --drive 0x80` is BIOS-only. + let fallback = if unclaimed_boots_local { + "|| exit 0\n".to_string() + } else { + format!("|| chain {media}/ipxe/menu\n") + }; + format!( + "#!ipxe\n\ + # Stage two. DHCP cannot carry a MAC, so this is what puts one in the query\n\ + # string — without it every machine would match nothing but the default.\n\ + chain {answer}/ipxe/boot?mac=${{netX/mac}}&uuid=${{uuid}}\\\n\ + &serial=${{serial:uristring}}&asset=${{asset:uristring}}\\\n\ + &manufacturer=${{manufacturer:uristring}}&product=${{product:uristring}}\\\n\ + &platform=${{platform}}&arch=${{buildarch}} \\\n\ + {fallback}" + ) +} + +/// The catalogue, rendered as an iPXE menu. +/// +/// **Generated at request time, not built into a file.** netboot.xyz's menus are static +/// templates rendered by Ansible at build time; ours are a rendering pass over facts the +/// server already holds, so dropping an ISO in the media directory puts it in the menu +/// on the next fetch. The catalogue is the single source of truth — the same instinct as +/// answers being discovered rather than registered. +pub fn menu(listing: &Listing, endpoints: &Endpoints, style: &Style) -> String { + let media = endpoints.media.trim_end_matches('/'); + let mut out = String::from("#!ipxe\n"); + + // Rows 2 and 3 of the branding are a chain rather than alternatives: `console + // --picture … ||` **tolerates its own failure**, so a client with no framebuffer — + // a serial console over IPMI, which is how half of all datacenter installs are + // actually watched — simply keeps the text console and gets the colours instead. + // Write it as a chain and there is nothing to detect. + out.push_str(&format!( + "console --picture {media}/boot/logo.png --left 0 --right 0 --keep ||\n" + )); + out.push_str("colour --rgb 0x1c1b19 0 ||\n"); + out.push_str("colour --rgb 0xc8a15a 3 ||\n"); + out.push_str("cpair --foreground 3 1 ||\n"); + out.push('\n'); + + out.push_str(&format!("menu {}\n", ascii(&style.title))); + + // `item local` first, and the timeout falls through to it. **A machine that + // PXE-boots by accident, and that nothing claims, ends up on its own disk** — it + // does not sit at a menu forever waiting for a human who is not coming, and it + // never installs anything. The worst case of being wrong about which machines + // reach us is a few seconds added to a boot. + out.push_str("item --gap Default:\n"); + out.push_str("item local Boot from the local disk\n"); + + let bootable: Vec<_> = listing.entries.iter().filter(|e| e.bootable()).collect(); + if !bootable.is_empty() { + out.push_str("item --gap Install:\n"); + for entry in &bootable { + let line = format!( + "item {:<20} {:<28} ({})\n", + ascii(&entry.id), + truncate(&entry.describe(), 28), + entry.family().label() + ); + out.push_str(&gate(entry.arch(), &line)); + } + } + + // An image no probe placed cannot produce a stanza, but it can still be booted as a + // CD — which is a normal thing to want from a live tool. + let opaque: Vec<_> = listing.entries.iter().filter(|e| !e.bootable()).collect(); + if !opaque.is_empty() { + out.push_str("item --gap Tools:\n"); + for entry in &opaque { + out.push_str(&format!( + "item {:<20} {:<28} (boot as CD)\n", + ascii(&entry.id), + truncate(&entry.describe(), 28) + )); + } + } + + out.push_str("item --gap Diagnostics:\n"); + out.push_str("item shell iPXE shell\n"); + out.push_str("item netinfo Network card information\n"); + out.push_str("item retry Ask this server again\n"); + out.push_str("item endpoints Boot from another rescriptum\n"); + out.push_str("item reboot Reboot\n"); + + // The timeout is rendered **in milliseconds**, because that is what `choose` + // counts. A seconds value passed through unconverted is a menu that flashes past + // before a human has read its title — hence the `_SECS` suffix on the variable and + // exactly one conversion, here. + out.push_str(&format!( + "choose --timeout {} --default local target || goto local\n", + style.timeout_millis + )); + out.push_str("goto ${target} ||\n"); + out.push_str("goto local\n\n"); + + // One label per entry. `sanboot` is the generic fallback for an image no probe + // could place: how far it reaches on real firmware is a bench question, so the + // entry tolerates its own failure and returns to the menu rather than hanging. + for entry in &bootable { + out.push_str(&format!(":{}\n", ascii(&entry.id))); + match stanza::ipxe(entry, endpoints) { + Ok(script) => { + for line in script.lines().skip(1) { + if line.starts_with('#') { + continue; + } + out.push_str(line); + out.push('\n'); + } + } + // Unreachable for a bootable entry, but a menu that silently omitted a + // label would `goto` into nothing. + Err(why) => { + out.push_str(&format!("echo {}\n", ascii(&why))); + out.push_str("goto start\n"); + } + } + out.push_str("goto start\n\n"); + } + for entry in &opaque { + out.push_str(&format!(":{}\n", ascii(&entry.id))); + out.push_str(&format!("sanboot {media}/{}/iso ||\n", ascii(&entry.id))); + out.push_str("goto start\n\n"); + } + + out.push_str(":local\n"); + out.push_str("echo Booting from the local disk\n"); + // `exit` hands control back to the firmware, which moves to its next boot device. + // `sanboot --no-describe --drive 0x80` is the BIOS-only version and fails on UEFI, + // so this is the one that works on both. + out.push_str("exit 0\n\n"); + + out.push_str(":shell\n"); + out.push_str("echo Type exit to come back to the menu\n"); + out.push_str("shell ||\n"); + out.push_str("goto start\n\n"); + + out.push_str(":netinfo\n"); + out.push_str("ifstat ||\n"); + out.push_str("echo MAC ${netX/mac} IP ${netX/ip} next-server ${next-server}\n"); + out.push_str("prompt Press any key to return\n"); + out.push_str("goto start\n\n"); + + out.push_str(":retry\n"); + out.push_str(&format!("chain {media}/ipxe/bootstrap ||\n")); + out.push_str("goto start\n\n"); + + // The one borrow that is a feature rather than a pattern: `read` a URL, `chain` it. + // It is how a candidate server is tested **on site, from the running one, without + // touching DHCP or the loaders** — netboot.xyz runs its whole staged release + // process through exactly this entry. + out.push_str(":endpoints\n"); + out.push_str("echo Boot from another rescriptum, to try one before moving DHCP.\n"); + out.push_str(&format!("set endpoint {media}\n")); + out.push_str("read endpoint ||\n"); + out.push_str("chain ${endpoint}/ipxe/bootstrap ||\n"); + out.push_str("goto start\n\n"); + + out.push_str(":reboot\n"); + out.push_str("reboot\n\n"); + + // `goto start` above needs somewhere to land, and re-fetching the menu is the + // honest way back: the catalogue may have changed since it was rendered. + out.push_str(":start\n"); + out.push_str(&format!("chain {media}/ipxe/menu ||\n")); + out.push_str("goto local\n"); + + out +} + +/// What the menu is called and how long it waits. +pub struct Style { + pub title: String, + /// **Milliseconds**, converted once, by the caller that owns the seconds. + pub timeout_millis: u64, +} + +impl Style { + /// The title a site has not overridden. `${next-server}` stays a variable because it + /// is genuinely client-side; the version is a literal, because `${version}` in an + /// iPXE script is *iPXE's* version and the title would advertise iPXE, not us. + pub fn default_title() -> String { + format!( + "rescriptum {} - ${{next-server}}", + env!("CARGO_PKG_VERSION") + ) + } +} + +/// Wrap a line in a client-side architecture guard, netboot.xyz's `menu_*` trick fed by +/// the catalogue instead of by Ansible. **An ARM64 image offered to an x86 client is a +/// menu entry that boots the wrong kernel**, and one menu has to serve every client. +fn gate(arch: Option, line: &str) -> String { + match arch { + Some(arch) => format!( + "iseq ${{buildarch}} {} && {}||\n", + arch.buildarch(), + line.trim_end() + ), + // An image whose architecture nobody could establish is offered to everybody: + // hiding it would be a guess in the more damaging direction. + None => line.to_string(), + } +} + +/// A BIOS text console is not UTF-8, and a menu title full of replacement characters is +/// worse than a plain one. Anything outside printable ASCII becomes a space. +fn ascii(text: &str) -> String { + text.chars() + .map(|c| { + if c.is_ascii_graphic() || c == ' ' { + c + } else { + ' ' + } + }) + .collect() +} + +fn truncate(text: &str, width: usize) -> String { + let text = ascii(text); + if text.len() <= width { + return text; + } + format!("{}...", &text[..width.saturating_sub(3)]) +} + +/// The families whose entries the menu can render, for `boot check` to report. +pub fn describable() -> Vec { + vec![ + Family::Proxmox, + Family::Debian, + Family::Ubuntu, + Family::Rhel, + Family::Suse, + Family::CoreOs, + ] +} + +#[cfg(test)] +mod tests { + use super::super::catalog::Entry; + use super::super::probe::Probed; + use super::*; + use std::path::PathBuf; + + fn endpoints() -> Endpoints { + Endpoints { + media: "http://192.0.2.10:8001".to_string(), + answer: "http://192.0.2.10:8000".to_string(), + } + } + + fn style() -> Style { + Style { + title: Style::default_title(), + timeout_millis: 15000, + } + } + + fn entry(id: &str, family: Option, arch: Option) -> Entry { + Entry { + id: id.to_string(), + path: PathBuf::from(format!("/srv/media/{id}.iso")), + size: 1024, + digest: None, + probed: Probed { + family, + version: Some(format!("{id} 1.0")), + arch, + kernel: family.map(|_| "/kernel".to_string()), + initrd: family.map(|_| "/initrd".to_string()), + external: false, + zstd_initrd: false, + }, + beside: None, + prepared: None, + } + } + + /// An entry carrying a version string a vendor might really have written. + fn described(id: &str, version: &str) -> Entry { + let mut e = entry(id, Some(Family::Debian), None); + e.probed.version = Some(version.to_string()); + e + } + + fn listing(entries: Vec) -> Listing { + Listing { + entries, + problems: Vec::new(), + } + } + + // ---- the bootstrap ---------------------------------------------------- + + #[test] + fn the_bootstrap_puts_the_machines_identity_in_the_query_string() { + // Without this the haystack is empty for every GET and the selection engine + // goes dark at exactly the moment it matters. + let script = bootstrap(&endpoints(), false); + assert!(script.starts_with("#!ipxe\n")); + assert!(script.contains("mac=${netX/mac}"), "{script}"); + assert!(script.contains("uuid=${uuid}"), "{script}"); + assert!(script.contains("arch=${buildarch}"), "{script}"); + } + + #[test] + fn the_bootstrap_names_the_booting_nic_rather_than_the_first_one() { + // `net0` is merely the first interface. A server that PXE-boots from its second + // port would identify as its unused first, and install the wrong machine. + let script = bootstrap(&endpoints(), false); + assert!(script.contains("${netX/mac}"), "{script}"); + assert!(!script.contains("${net0/"), "{script}"); + } + + #[test] + fn what_an_unclaimed_machine_gets_is_the_whole_policy() { + // **These two are opposite readings of what an answer file means**, and the + // choice belongs to the deployment rather than to us. + // + // With the menu — the default, and the project's thesis — a machine nobody has + // decided anything about ends up where a human can decide. An answer file then + // means "leave this one alone". + // + // With `local` the machine is handed straight back to its firmware, so an answer + // file means "install this one" and its absence is the safe state. That is the + // reading a fleet already in production needs: netboot can stay first in the BIOS + // order — which is what makes reinstalling a machine "add a file and reboot" — + // without every routine reboot passing through a menu that could be clicked. + let menu = bootstrap(&endpoints(), false); + assert!( + menu.contains("|| chain http://192.0.2.10:8001/ipxe/menu"), + "{menu}" + ); + assert!(!menu.contains("exit 0"), "{menu}"); + + let local = bootstrap(&endpoints(), true); + // `exit 0` and not `sanboot --drive 0x80`: the latter is BIOS-only and fails on + // UEFI, and this script is served to both. + assert!(local.contains("|| exit 0"), "{local}"); + assert!(!local.contains("/ipxe/menu"), "{local}"); + + // Whichever it is, the identity still goes up first — the fallback is what + // happens when nothing claimed the machine, not instead of asking. + for script in [&menu, &local] { + assert!(script.contains("mac=${netX/mac}"), "{script}"); + } + } + + #[test] + fn every_smbios_string_is_percent_encoded_at_expansion() { + // `${manufacturer}` is `Dell Inc.` — space included — and iPXE encodes nothing + // on plain expansion, so a space in the request line is a broken fetch. + let script = bootstrap(&endpoints(), false); + for field in ["serial", "asset", "manufacturer", "product"] { + assert!( + script.contains(&format!("{field}=${{{field}:uristring}}")), + "{field} must be uristring: {script}" + ); + } + // These cannot carry a reserved character, so they stay plain. + assert!(script.contains("platform=${platform}"), "{script}"); + } + + #[test] + fn an_unclaimed_machine_falls_through_to_the_menu() { + // **A menu is what a machine gets when nobody has decided anything about it + // yet** — `default.toml`'s job description, applied to a different format, and + // implemented as one `||` rather than as a new concept in select.rs. + let script = bootstrap(&endpoints(), false); + assert!( + script.contains("|| chain http://192.0.2.10:8001/ipxe/menu"), + "{script}" + ); + } + + // ---- the menu --------------------------------------------------------- + + #[test] + fn the_menu_renders_the_catalogue() { + let script = menu( + &listing(vec![ + entry("pve-8.4", Some(Family::Proxmox), Some(Arch::X86_64)), + entry("ubuntu-24.04", Some(Family::Ubuntu), Some(Arch::X86_64)), + ]), + &endpoints(), + &style(), + ); + assert!(script.starts_with("#!ipxe\n")); + assert!(script.contains("item pve-8.4"), "{script}"); + assert!(script.contains("item ubuntu-24.04"), "{script}"); + // And each has a label to `goto`, carrying its family's own stanza. + assert!(script.contains(":pve-8.4\n"), "{script}"); + assert!(script.contains("proxmox-start-auto-installer"), "{script}"); + } + + #[test] + fn local_boot_is_first_and_is_what_the_timeout_falls_through_to() { + // **The safety behaviour that must not be lost.** A machine that PXE-boots by + // accident, and that nothing claims, ends up on its own disk rather than + // sitting at a menu forever waiting for a human who is not coming. + let script = menu(&listing(vec![]), &endpoints(), &style()); + let items: Vec<&str> = script + .lines() + .filter(|l| l.starts_with("item ") && !l.starts_with("item --gap")) + .collect(); + assert!(items[0].starts_with("item local"), "{items:?}"); + assert!( + script.contains("choose --timeout 15000 --default local target || goto local"), + "{script}" + ); + } + + #[test] + fn the_timeout_is_rendered_in_the_unit_choose_actually_counts() { + // `choose --timeout` is milliseconds. A seconds value passed through + // unconverted is a menu that flashes past before a human has read its title. + let script = menu( + &listing(vec![]), + &endpoints(), + &Style { + title: "t".to_string(), + timeout_millis: 30_000, + }, + ); + assert!(script.contains("--timeout 30000"), "{script}"); + } + + #[test] + fn an_architecture_specific_entry_is_gated_client_side() { + // One menu serves every client, so an ARM64 image must not be offered to an x86 + // one — that is a menu entry that boots the wrong kernel. + let script = menu( + &listing(vec![ + entry("arm-image", Some(Family::Debian), Some(Arch::Arm64)), + entry("x86-image", Some(Family::Debian), Some(Arch::X86_64)), + ]), + &endpoints(), + &style(), + ); + assert!( + script.contains("iseq ${buildarch} arm64 && item arm-image"), + "{script}" + ); + assert!( + script.contains("iseq ${buildarch} x86_64 && item x86-image"), + "{script}" + ); + } + + #[test] + fn an_image_of_unknown_architecture_is_offered_to_everybody() { + // Hiding it would be a guess in the more damaging direction: an entry nobody + // can see is an image nobody can boot. + let script = menu( + &listing(vec![entry("mystery", Some(Family::Debian), None)]), + &endpoints(), + &style(), + ); + assert!(script.contains("item mystery"), "{script}"); + assert!(!script.contains("iseq ${buildarch}"), "{script}"); + } + + #[test] + fn an_image_no_probe_placed_is_offered_as_a_cd() { + // Not describable is not the same as not usable. + let script = menu( + &listing(vec![entry("gparted", None, None)]), + &endpoints(), + &style(), + ); + assert!(script.contains("item gparted"), "{script}"); + assert!(script.contains("boot as CD"), "{script}"); + assert!( + script.contains("sanboot http://192.0.2.10:8001/gparted/iso ||"), + "{script}" + ); + } + + #[test] + fn the_title_is_ours_and_the_version_is_a_literal() { + // `${version}` in an iPXE script is *iPXE's* version — the title would have + // advertised iPXE, not us. + let script = menu(&listing(vec![]), &endpoints(), &style()); + assert!(script.contains(&format!("menu rescriptum {}", env!("CARGO_PKG_VERSION")))); + assert!(!script.contains("${version}"), "{script}"); + // `${next-server}` stays a variable, because it is genuinely client-side. + assert!(script.contains("${next-server}"), "{script}"); + } + + #[test] + fn the_text_stays_ascii_because_a_bios_console_is_not_utf8() { + let script = menu( + // The realistic vector is vendor text read out of an image — a volume + // identifier or a `/.disk/info` line can hold anything. An id cannot: + // `valid_id` constrains it at the catalogue boundary. Both are filtered + // anyway, so the menu does not rest on a guarantee made elsewhere. + &listing(vec![described("live-cd", "Ubuntu 24.04 « Naïve Numbat »")]), + &endpoints(), + &Style { + title: "rescriptum — naïve".to_string(), + timeout_millis: 1000, + }, + ); + assert!(script.is_ascii(), "a BIOS text console cannot render this"); + } + + #[test] + fn the_logo_tolerates_its_own_failure() { + // A serial console over IPMI has no framebuffer, and that is how half of all + // datacenter installs are watched. Written as a chain, there is nothing to + // detect: the picture fails, the text console stays, the colours still apply. + let script = menu(&listing(vec![]), &endpoints(), &style()); + let line = script + .lines() + .find(|l| l.starts_with("console ")) + .expect("a console line"); + assert!(line.ends_with("||"), "{line}"); + } + + #[test] + fn every_menu_target_has_a_label_to_land_on() { + // A `goto` into nothing is a menu that hangs on a keypress, and it would only + // show up on the machine. + let script = menu( + &listing(vec![ + entry("pve-8.4", Some(Family::Proxmox), Some(Arch::X86_64)), + entry("gparted", None, None), + ]), + &endpoints(), + &style(), + ); + let labels: Vec<&str> = script.lines().filter_map(|l| l.strip_prefix(':')).collect(); + for line in script.lines() { + let Some(rest) = line.strip_prefix("item ") else { + continue; + }; + if rest.starts_with("--gap") { + continue; + } + let target = rest.split_whitespace().next().unwrap_or_default(); + assert!(labels.contains(&target), "no :{target} label in\n{script}"); + } + // Including the one every other label returns to. + assert!(labels.contains(&"start"), "{script}"); + } + + #[test] + fn no_line_carries_a_bare_semicolon_token() { + // `;` separates commands only as a whole whitespace-delimited token, so one + // that appeared alone would split a line into two commands. + let script = menu( + &listing(vec![entry( + "ubuntu", + Some(Family::Ubuntu), + Some(Arch::X86_64), + )]), + &endpoints(), + &style(), + ); + for line in script.lines() { + assert!( + !line.split_whitespace().any(|t| t == ";"), + "a bare `;` would split this: {line}" + ); + } + // And the NoCloud argument, which contains one, is left alone. + assert!(script.contains("ds=nocloud-net;s="), "{script}"); + } +} diff --git a/src/boot/mod.rs b/src/boot/mod.rs new file mode 100644 index 0000000..5ffa2a4 --- /dev/null +++ b/src/boot/mod.rs @@ -0,0 +1,28 @@ +//! Boot media: the images machines install from, and the bits that carry them. +//! +//! The answer engine tells a machine *what* to install. This half is *where the +//! installer itself comes from* — the kernel, the initrd and the image, served over +//! HTTP from a catalogue discovered the same way answers are. +//! +//! Nothing here decides anything about answers, and `select.rs` knows nothing about +//! this. The one seam between them is `stanza`: a generator that writes an `.ipxe` +//! answer document naming a catalogue entry, which then goes through the existing +//! selection, layering and templating unchanged. The server does not become clever +//! about booting; it gains a generator. + +pub mod catalog; +pub mod cpio; +pub mod dhcp; +pub mod fetch; +pub mod iso; +pub mod loaders; +pub mod media; +pub mod menu; +pub mod patch; +pub mod privileges; +pub mod probe; +pub mod sha256; +/// Where images can be fetched *from*, as opposed to what is held. +pub mod sources; +pub mod stanza; +pub mod tftp; diff --git a/src/boot/patch.rs b/src/boot/patch.rs new file mode 100644 index 0000000..06bf54e --- /dev/null +++ b/src/boot/patch.rs @@ -0,0 +1,677 @@ +//! Adding one file to an ISO9660 image without writing 1.5 GB. +//! +//! The only genuinely hard part of this whole design, and it is needed by exactly one +//! installer. 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. +//! +//! ## Why this is tractable at all +//! +//! **On the PXE path the image is never booted, only mounted.** Bootability is not at +//! stake — the requirement is *"still a readable ISO9660 filesystem exposing one more +//! file"*, which is a far weaker problem than the one xorriso solves. No boot catalog, +//! no hybrid MBR, no repacking. +//! +//! An ISO9660 file is a contiguous extent, so adding one is three small overwrites and +//! an append: +//! +//! 1. **Append the content** past the end of the image. +//! 2. **Write a directory record** into the slack at the end of the root directory's +//! extent — directory extents are padded to 2048 bytes, and a root with a dozen +//! entries typically leaves a kilobyte free. +//! 3. **Bump the volume space size** in the primary descriptor, and in the +//! supplementary one if there is a Joliet tree. +//! +//! So this does not produce a file. It produces a **plan** — a list of `(offset, bytes)` +//! plus a tail — which the listener applies *while streaming*. What goes on the wire is +//! the source image with a few hundred bytes substituted and a few hundred appended: +//! 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 is a matter of recomputing 300 bytes. +//! +//! ## The trap that decides whether this works: Rock Ridge +//! +//! `auto-installer-mode.toml` is **not a legal ISO9660 identifier** — hyphens are not +//! d-characters, lower case is not allowed, and it exceeds 8.3. The record would be +//! called something like `AUTO_INS.TOM;1`, and **the installer would never find its +//! file**: it would be in the image and invisible to its only reader. +//! +//! Linux mounts iso9660 with Rock Ridge auto-detected and Rock Ridge names win, so the +//! record carries an `NM` entry with the real name. Where there is a Joliet tree the +//! record goes there too, in UCS-2 — because **which tree the mount reads is not ours +//! to decide**. Where there is neither, this refuses, and refusing is a *complete* +//! answer: the fallback is one command on any Debian box +//! (`proxmox-auto-install-assistant prepare-iso`), whose output this server is perfectly +//! happy to serve. + +use super::iso::{Extent, Iso, SECTOR}; +use std::path::{Path, PathBuf}; + +/// One overwrite inside the original image. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Overwrite { + pub at: u64, + pub bytes: Vec, +} + +/// What to substitute and what to append, with the arithmetic already done. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Plan { + pub source: PathBuf, + /// The original image's length. Everything past it is `tail`. + pub source_len: u64, + pub overwrites: Vec, + pub tail: Vec, + /// What was added, for a listing to report. + pub added: String, +} + +impl Plan { + /// The length of the image this plan describes. Exact arithmetic, because a + /// `Content-Length` computed any other way would be a guess. + pub fn len(&self) -> u64 { + self.source_len + self.tail.len() as u64 + } + + pub fn is_empty(&self) -> bool { + self.overwrites.is_empty() && self.tail.is_empty() + } + + /// Fill `buffer` with the patched image's bytes starting at `offset`. + /// + /// This is what makes a range request work over a virtual file: the offsets are the + /// patched image's, and every one of them resolves to either the source, an + /// overwrite, or the tail. + pub fn read_at( + &self, + source: &mut std::fs::File, + offset: u64, + buffer: &mut [u8], + ) -> std::io::Result { + use std::io::{Read, Seek, SeekFrom}; + + let total = self.len(); + if offset >= total { + return Ok(0); + } + let want = buffer.len().min((total - offset) as usize); + let buffer = &mut buffer[..want]; + + // The source's part of this window, then the tail's. + let from_source = if offset < self.source_len { + (self.source_len - offset).min(want as u64) as usize + } else { + 0 + }; + if from_source > 0 { + source.seek(SeekFrom::Start(offset))?; + source.read_exact(&mut buffer[..from_source])?; + } + if from_source < want { + // Where in the tail this window starts: at its beginning when the window + // straddles the join, and further in when it starts past the source's end. + let tail_start = offset.saturating_sub(self.source_len) as usize; + let take = want - from_source; + let end = (tail_start + take).min(self.tail.len()); + let slice = &self.tail[tail_start.min(self.tail.len())..end]; + buffer[from_source..from_source + slice.len()].copy_from_slice(slice); + // Past the tail is zero, which only happens if the arithmetic above is + // wrong; leaving it zero beats reading somebody else's memory. + for byte in &mut buffer[from_source + slice.len()..] { + *byte = 0; + } + } + + // Then substitute, which is what makes this a patch rather than a copy. + for overwrite in &self.overwrites { + let end = overwrite.at + overwrite.bytes.len() as u64; + if end <= offset || overwrite.at >= offset + want as u64 { + continue; + } + let from = overwrite.at.max(offset); + let to = end.min(offset + want as u64); + let in_buffer = (from - offset) as usize; + let in_patch = (from - overwrite.at) as usize; + let count = (to - from) as usize; + buffer[in_buffer..in_buffer + count] + .copy_from_slice(&overwrite.bytes[in_patch..in_patch + count]); + } + Ok(want) + } + + /// Write the whole thing out, for a USB stick. **One code path with the streaming + /// one**: `media export` materialises exactly what the listener would have served. + pub fn materialise(&self, to: &Path) -> std::io::Result<()> { + use std::io::Write; + let mut source = std::fs::File::open(&self.source)?; + let mut out = std::fs::File::create(to)?; + let mut buffer = vec![0u8; 1024 * 1024]; + let mut at = 0u64; + while at < self.len() { + let n = self.read_at(&mut source, at, &mut buffer)?; + if n == 0 { + break; + } + out.write_all(&buffer[..n])?; + at += n as u64; + } + out.flush() + } +} + +/// Plan the addition of one file at the root of an image. +pub fn add_file(path: &Path, name: &str, content: &[u8]) -> Result { + let mut iso = Iso::open(path).map_err(|e| format!("{}: {e}", path.display()))?; + + // A Windows ISO is UDF+ISO9660 and its large files exist only in the UDF tree. + // Patching the ISO9660 tree of such an image produces something that looks right and + // is not, which is the worst of the three outcomes. + if iso.trees.udf { + return Err(format!( + "{}: the image carries a UDF filesystem, which this does not understand. \ + Patching only its ISO9660 tree would produce an image that looks right and \ + is not. Prepare it with the vendor's own tool instead.", + path.display() + )); + } + // Neither long-name tree: the record would be in the image under a mangled name and + // invisible to the only reader that wants it. + if !iso.trees.rock_ridge && !iso.trees.joliet { + return Err(format!( + "{}: the image has neither Rock Ridge nor Joliet, so {name} could only exist \ + under a mangled 8.3 name and the installer would never find it.", + path.display() + )); + } + + let source_len = std::fs::metadata(path) + .map_err(|e| format!("{}: {e}", path.display()))? + .len(); + // Every extent is addressed in sectors, so an image that does not end on one has no + // sector to put the new file in. + if source_len == 0 || source_len % SECTOR != 0 { + return Err(format!( + "{}: the image is {source_len} bytes, which is not a whole number of 2048-byte \ + sectors — there is nowhere to append a file.", + path.display() + )); + } + if content.len() as u64 > SECTOR * 64 { + // This exists to add a few hundred bytes of configuration. Anything larger is a + // different job and should say so rather than half-working. + return Err(format!( + "{name} is {} bytes; this adds small files, not payloads.", + content.len() + )); + } + + let lba = (source_len / SECTOR) as u32; + let mut overwrites = Vec::new(); + + // Read out of the descriptors before the borrow checker has an opinion about + // holding one while reading through the other. + let root = iso.root_extent(); + let joliet_root = iso.joliet_root_extent(); + let susp_skip = iso.susp_skip(); + + // The ISO9660 tree, with a Rock Ridge `NM` entry carrying the real name. + if iso.trees.rock_ridge { + let record = iso9660_record(name, lba, content.len() as u32, true, susp_skip); + let at = slack_in(&mut iso, root, record.len())?; + overwrites.push(Overwrite { at, bytes: record }); + } + + // The Joliet tree, where names are UCS-2 and case-preserving. Both, when both exist: + // which one the mount reads is not ours to decide. + if let Some(joliet_root) = joliet_root { + let record = joliet_record(name, lba, content.len() as u32); + let at = slack_in(&mut iso, joliet_root, record.len())?; + overwrites.push(Overwrite { at, bytes: record }); + } + + // The volume space size, in both descriptors. An image whose descriptor still claims + // the old length has a file past its own end, and a mount will not read it. + let added_sectors = (content.len() as u64).div_ceil(SECTOR) as u32; + let blocks = (source_len / SECTOR) as u32 + added_sectors; + overwrites.push(Overwrite { + at: iso.pvd_at() + 80, + bytes: both_endian32(blocks), + }); + if let Some(svd_at) = iso.svd_at() { + overwrites.push(Overwrite { + at: svd_at + 80, + bytes: both_endian32(blocks), + }); + } + + let mut tail = content.to_vec(); + tail.resize(added_sectors as usize * SECTOR as usize, 0); + + Ok(Plan { + source: path.to_path_buf(), + source_len, + overwrites, + tail, + added: name.to_string(), + }) +} + +/// Find room for a record in a directory extent, and say where it goes. +/// +/// **A directory record may not cross a sector boundary**, so this looks per sector +/// rather than at the extent as a whole: the space after the last record in sector three +/// is unusable if the record does not fit in it, even when sector four is empty. +fn slack_in(iso: &mut Iso, directory: Extent, need: usize) -> Result { + if !directory.directory || directory.size == 0 { + return Err("the root directory extent is not readable".to_string()); + } + let extent = iso + .read_raw(directory.offset, directory.size as usize) + .map_err(|e| format!("cannot read the root directory: {e}"))?; + + let sector = SECTOR as usize; + for (index, chunk) in extent.chunks(sector).enumerate() { + let mut used = 0usize; + while used + 33 <= chunk.len() { + let length = chunk[used] as usize; + if length == 0 { + break; + } + if used + length > chunk.len() { + // A record claiming to run past its own sector: refuse rather than + // write into whatever follows. + return Err("a directory record overruns its sector".to_string()); + } + used += length; + } + if chunk.len() - used >= need { + return Ok(directory.offset + (index * sector) as u64 + used as u64); + } + } + + Err(format!( + "the root directory has no {need} bytes of slack in any of its sectors. \ + Relocating the extent would drag in the path tables, which is deliberately not \ + done here — prepare the image with `proxmox-auto-install-assistant prepare-iso` \ + instead, and this server will serve the result." + )) +} + +/// An ISO9660 directory record, optionally carrying the real name in a Rock Ridge `NM`. +fn iso9660_record(name: &str, lba: u32, size: u32, rock_ridge: bool, skip: usize) -> Vec { + let identifier = mangle(name); + let mut system: Vec = vec![0u8; skip]; + if rock_ridge { + // NM: signature, length, version, flags, then the name. Flags zero means "this + // is the whole name" — no CONTINUE, not a `.` or `..` marker. + let mut nm = vec![b'N', b'M', (5 + name.len()) as u8, 1, 0]; + nm.extend_from_slice(name.as_bytes()); + system.extend_from_slice(&nm); + } + record(identifier.as_bytes(), lba, size, &system) +} + +/// The same record in the Joliet tree, where the name is UCS-2 big-endian and needs no +/// mangling at all — which is why an image with only Joliet is still patchable. +fn joliet_record(name: &str, lba: u32, size: u32) -> Vec { + let mut identifier = Vec::new(); + for unit in name.encode_utf16() { + identifier.extend_from_slice(&unit.to_be_bytes()); + } + record(&identifier, lba, size, &[]) +} + +fn record(identifier: &[u8], lba: u32, size: u32, system: &[u8]) -> Vec { + let mut out = vec![0u8; 33]; + out[2..10].copy_from_slice(&both_endian32(lba)); + out[10..18].copy_from_slice(&both_endian32(size)); + // Recording date and time: zeros. Every reader accepts it, and a fabricated + // timestamp would be a lie about when somebody wrote the file. + out[25] = 0; // flags: a plain file + out[28..32].copy_from_slice(&both_endian16(1)); + out[32] = identifier.len() as u8; + out.extend_from_slice(identifier); + // A pad byte when the identifier length is even, so the system use area starts on an + // even offset — which is where a reader looks for it. + if identifier.len() % 2 == 0 { + out.push(0); + } + out.extend_from_slice(system); + // Records are even-length. + if out.len() % 2 == 1 { + out.push(0); + } + out[0] = out.len() as u8; + out +} + +/// The best legal ISO9660 identifier for a name that is not one. +/// +/// Deliberately conservative — upper case, `A-Z0-9_`, 8.3 — because the *real* name +/// comes from Rock Ridge or Joliet and this only has to be legal and unlikely to +/// collide. A mastering tool would do the same thing. +fn mangle(name: &str) -> String { + let (stem, extension) = match name.rsplit_once('.') { + Some((stem, extension)) => (stem, extension), + None => (name, ""), + }; + let clean = |text: &str, limit: usize| -> String { + text.to_ascii_uppercase() + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) + .take(limit) + .collect() + }; + let stem = clean(stem, 8); + let extension = clean(extension, 3); + if extension.is_empty() { + format!("{stem}.;1") + } else { + format!("{stem}.{extension};1") + } +} + +fn both_endian32(value: u32) -> Vec { + let mut out = Vec::with_capacity(8); + out.extend_from_slice(&value.to_le_bytes()); + out.extend_from_slice(&value.to_be_bytes()); + out +} + +fn both_endian16(value: u16) -> Vec { + let mut out = Vec::with_capacity(4); + out.extend_from_slice(&value.to_le_bytes()); + out.extend_from_slice(&value.to_be_bytes()); + out +} + +/// The file Proxmox reads to learn where its answer lives. +/// +/// **`AutoInstSettings` is `deny_unknown_fields`**, so one key this does not know about +/// is a *rejected file*, not a warning — and the machine boots the interactive installer +/// with nobody there. Only the five keys upstream defines are ever written. +pub fn mode_file(url: &str, fingerprint: Option<&str>, token: Option<&str>) -> String { + let mut out = String::from( + "# Written by rescriptum. The Proxmox installer reads this from the mounted image\n\ + # to learn where to POST its hardware inventory.\n\ + mode = \"http\"\n", + ); + // **Underscores, and the installer is the authority.** It rejected a hyphenated key + // with `unknown field \`partition-label\`, expected one of \`mode\`, + // \`partition_label\`, \`http\`` — on a real machine, after every other link in the + // chain had been made to work. `deny_unknown_fields` means one wrong key is a + // *rejected file*, not a warning, so the whole automated install stops and a human + // who is not coming is asked to continue. + out.push_str("partition_label = \"proxmox-ais\"\n\n[http]\n"); + out.push_str(&format!("url = \"{}\"\n", escape(url))); + if let Some(fingerprint) = fingerprint { + // Snake case here too, for the same reason — this one has simply never been + // exercised, because it only appears when somebody pins a certificate. + out.push_str(&format!("cert_fingerprint = \"{}\"\n", escape(fingerprint))); + } + if let Some(token) = token { + out.push_str(&format!("token = \"{}\"\n", escape(token))); + } + out +} + +/// A quote or a backslash in a URL would end the string early, and the result would be +/// a mode file the installer refuses — which reads as this server being broken. +fn escape(value: &str) -> String { + value.replace('\\', "\\\\").replace('"', "\\\"") +} + +#[cfg(test)] +mod tests { + use super::super::iso::build; + use super::*; + + fn image(name: &str, builder: &build::Builder) -> PathBuf { + let dir = std::env::temp_dir().join(format!("rescriptum-patch-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("temp dir"); + let path = dir.join(format!("{name}.iso")); + std::fs::write(&path, builder.build()).expect("write"); + path + } + + const MODE: &str = "mode = \"http\"\n[http]\nurl = \"http://192.0.2.10:8000/proxmox\"\n"; + + #[test] + fn a_file_added_to_an_image_reads_back_under_its_real_name() { + // **The whole point.** `auto-installer-mode.toml` is not a legal ISO9660 name, so + // without a Rock Ridge `NM` entry the file would be in the image and invisible + // to the only reader that wants it. + let path = image( + "basic", + &build::Builder::new().file("/boot/linux26", b"kernel"), + ); + let plan = add_file(&path, "auto-installer-mode.toml", MODE.as_bytes()) + .unwrap_or_else(|e| panic!("{e}")); + + let out = path.with_extension("patched.iso"); + plan.materialise(&out).expect("materialises"); + + let mut patched = Iso::open(&out).expect("still an image"); + assert_eq!( + patched + .read("/auto-installer-mode.toml", 4096) + .expect("readable"), + Some(MODE.as_bytes().to_vec()), + "the installer must find it under the name it looks for" + ); + } + + #[test] + fn everything_that_was_already_there_is_byte_identical() { + // A corrupt image fails silently, on somebody's USB stick, weeks later. + let original = build::Builder::new() + .file("/boot/linux26", b"the kernel, verbatim") + .file("/boot/initrd.img", b"the initrd, verbatim") + .file("/.disk/info", b"PRODUCTLONG='Proxmox VE'\n"); + let path = image("preserved", &original); + let before = std::fs::read(&path).expect("read"); + + let plan = add_file(&path, "auto-installer-mode.toml", MODE.as_bytes()).expect("plans"); + let out = path.with_extension("patched.iso"); + plan.materialise(&out).expect("materialises"); + let after = std::fs::read(&out).expect("read"); + + // **The first 32 KiB is untouched**, which is where a boot catalog and a hybrid + // MBR live — the two things whose corruption would only surface on a stick. + assert_eq!(&before[..32768], &after[..32768], "the system area moved"); + + let mut patched = Iso::open(&out).expect("still an image"); + for (name, contents) in [ + ("/boot/linux26", &b"the kernel, verbatim"[..]), + ("/boot/initrd.img", &b"the initrd, verbatim"[..]), + ] { + assert_eq!( + patched.read(name, 4096).expect("readable"), + Some(contents.to_vec()), + "{name} changed" + ); + } + // And the source itself was never touched, so its published digest still holds. + assert_eq!(std::fs::read(&path).expect("read"), before); + } + + #[test] + fn the_image_grows_by_exactly_one_sector_for_a_small_file() { + let path = image("growth", &build::Builder::new().file("/x", b"y")); + let before = std::fs::metadata(&path).expect("stat").len(); + let plan = add_file(&path, "auto-installer-mode.toml", MODE.as_bytes()).expect("plans"); + + assert_eq!(plan.len(), before + SECTOR); + // Exact arithmetic rather than a guess: this is a `Content-Length`. + let out = path.with_extension("grown.iso"); + plan.materialise(&out).expect("materialises"); + assert_eq!(std::fs::metadata(&out).expect("stat").len(), plan.len()); + } + + #[test] + fn a_range_over_the_patched_image_reads_the_same_bytes_as_the_whole_of_it() { + // The listener serves ranges over this virtual file, so every window has to + // resolve — through the source, an overwrite, or the tail. + let path = image( + "ranges", + &build::Builder::new().file("/boot/linux26", b"kernel"), + ); + let plan = add_file(&path, "auto-installer-mode.toml", MODE.as_bytes()).expect("plans"); + let out = path.with_extension("ranged.iso"); + plan.materialise(&out).expect("materialises"); + let whole = std::fs::read(&out).expect("read"); + + let mut source = std::fs::File::open(&path).expect("open"); + for size in [1usize, 7, 2048, 4096, 100_000] { + let mut rebuilt = Vec::new(); + let mut buffer = vec![0u8; size]; + let mut at = 0u64; + loop { + let n = plan.read_at(&mut source, at, &mut buffer).expect("reads"); + if n == 0 { + break; + } + rebuilt.extend_from_slice(&buffer[..n]); + at += n as u64; + } + assert_eq!(rebuilt, whole, "reading in {size}-byte windows disagreed"); + } + } + + #[test] + fn an_image_with_only_joliet_is_patched_in_that_tree() { + // Which tree a mount reads is not ours to decide, and Joliet needs no mangling + // at all — so an image with only Joliet is still patchable. + let path = image( + "joliet", + &build::Builder::new() + .rock_ridge(false) + .joliet(true) + .file("/boot/linux26", b"kernel"), + ); + let plan = add_file(&path, "auto-installer-mode.toml", MODE.as_bytes()).expect("plans"); + let out = path.with_extension("joliet-patched.iso"); + plan.materialise(&out).expect("materialises"); + + let mut patched = Iso::open(&out).expect("still an image"); + assert!( + patched + .locate_joliet("/auto-installer-mode.toml") + .expect("readable") + .is_some(), + "the Joliet tree must carry the readable name" + ); + } + + #[test] + fn an_image_with_both_trees_is_patched_in_both() { + let path = image( + "both", + &build::Builder::new() + .rock_ridge(true) + .joliet(true) + .file("/boot/linux26", b"kernel"), + ); + let plan = add_file(&path, "auto-installer-mode.toml", MODE.as_bytes()).expect("plans"); + // Two records plus two descriptor updates: neither tree may be left behind. + assert_eq!(plan.overwrites.len(), 4, "{:?}", plan.overwrites); + + let out = path.with_extension("both-patched.iso"); + plan.materialise(&out).expect("materialises"); + let mut patched = Iso::open(&out).expect("still an image"); + assert!(patched.has("/auto-installer-mode.toml")); + assert!( + patched + .locate_joliet("/auto-installer-mode.toml") + .expect("readable") + .is_some() + ); + } + + #[test] + fn an_image_with_neither_long_name_tree_is_refused_with_the_way_out() { + // Refusing is a *complete* answer, because the fallback is one command on any + // Debian box and this server is perfectly happy to serve its output. + let path = image( + "mangled-only", + &build::Builder::new() + .rock_ridge(false) + .file("/boot/linux26", b"kernel"), + ); + let e = + add_file(&path, "auto-installer-mode.toml", MODE.as_bytes()).expect_err("must refuse"); + assert!(e.contains("neither Rock Ridge nor Joliet"), "{e}"); + assert!(e.contains("would never find it"), "{e}"); + } + + #[test] + fn a_name_that_is_not_legal_iso9660_is_mangled_the_way_a_mastering_tool_would() { + // The mangled name is what the record is *called*; the real one lives in the NM + // entry. Both have to exist, and neither is the other's substitute. + assert_eq!(mangle("auto-installer-mode.toml"), "AUTO_INS.TOM;1"); + assert_eq!(mangle("readme"), "README.;1"); + assert_eq!(mangle("a.b"), "A.B;1"); + } + + #[test] + fn the_mode_file_carries_only_the_keys_upstream_defines() { + // `AutoInstSettings` is `deny_unknown_fields`: one key it does not know is a + // rejected file, not a warning, and the machine boots the interactive installer + // with nobody there to answer it. + let text = mode_file("http://192.0.2.10:8000/proxmox", None, None); + let keys: Vec<&str> = text + .lines() + .filter(|l| !l.trim_start().starts_with('#') && l.contains('=')) + .map(|l| l.split('=').next().unwrap_or("").trim()) + .collect(); + // **The names are the installer's, quoted from its own refusal.** It stopped a + // real machine with `unknown field \`partition-label\`, expected one of + // \`mode\`, \`partition_label\`, \`http\`` — after every other link in the + // chain had been made to work, which is the expensive place to learn it. + for key in &keys { + assert!( + [ + "mode", + "partition_label", + "url", + "cert_fingerprint", + "token" + ] + .contains(key), + "{key} is not a key the installer defines" + ); + } + // Said separately because it is the specific mistake that was made: every key + // here is snake case, and a hyphen anywhere in this file rejects the whole of it. + assert!( + !keys.iter().any(|k| k.contains('-')), + "a hyphenated key rejects the entire file: {keys:?}" + ); + assert!(text.contains("mode = \"http\""), "{text}"); + assert!(text.contains("[http]"), "{text}"); + + // The fingerprint path is the one nobody exercises, so it is pinned here rather + // than left to be discovered by whoever first pins a certificate. + let with_cert = mode_file("http://192.0.2.10:8000/proxmox", Some("AB:CD"), None); + assert!( + with_cert.contains("cert_fingerprint = \"AB:CD\""), + "{with_cert}" + ); + assert!(!with_cert.contains("cert-fingerprint"), "{with_cert}"); + } + + #[test] + fn a_quote_in_the_url_cannot_end_the_string_early() { + // The result would be a mode file the installer refuses, which reads as this + // server being broken rather than as a bad URL. + let text = mode_file("http://host/a\"b", None, Some("name:sec\\ret")); + assert!(text.contains("\\\""), "{text}"); + assert!(text.contains("\\\\"), "{text}"); + } + + #[test] + fn a_payload_rather_than_a_configuration_file_is_refused() { + let path = image("payload", &build::Builder::new().file("/x", b"y")); + let e = add_file(&path, "big.bin", &vec![0u8; 200 * 1024]).expect_err("must refuse"); + assert!(e.contains("small files, not payloads"), "{e}"); + } +} diff --git a/src/boot/privileges.rs b/src/boot/privileges.rs new file mode 100644 index 0000000..65d8ffc --- /dev/null +++ b/src/boot/privileges.rs @@ -0,0 +1,212 @@ +//! Dropping privileges after binding, the way dnsmasq and nginx do it. +//! +//! TFTP wants port 69, which is privileged. That is the *only* privileged port this +//! server ever asks for — with no DHCP responder there is nothing wanting 67 or 4011 — +//! so the whole question is: bind one low port, then stop being root. +//! +//! **Bind every listener first, then drop, then say what the process now is.** Dropping +//! before binding is the bug that works in testing as root and fails on deployment, and +//! it fails at the one moment nobody is watching: a reboot. +//! +//! This costs **zero new crates**: `libc` is already in `Cargo.lock` by way of tokio, so +//! promoting it from transitive to direct adds nothing to the build. +//! +//! Two other answers exist and neither needs this code, so both are documented rather +//! than implemented: socket activation (`LISTEN_FDS`) on a systemd host, which needs no +//! privileges at all, and `setcap cap_net_bind_service` on the binary. + +/// Become `user` and `group`, in the order that actually works. +/// +/// The order is not a style choice. Supplementary groups must go **before** the primary +/// group, and the group before the user: `setuid` is what surrenders the privilege to +/// call the other two, so doing it first leaves a process that kept every group it had. +/// That is the classic privilege-dropping bug, and it is silent — the process looks +/// unprivileged and is not. +pub fn drop_to(user: Option<&str>, group: Option<&str>) -> Result<(), String> { + if user.is_none() && group.is_none() { + return Ok(()); + } + + #[cfg(not(unix))] + { + let _ = (user, group); + return Err( + "RESCRIPTUM_USER and RESCRIPTUM_GROUP are Unix concepts, and this is not Unix" + .to_string(), + ); + } + + #[cfg(unix)] + { + // Nothing to drop *to*, and nothing to drop *from*: a non-root process cannot + // change identity, and pretending otherwise would fail later and less clearly. + let root = unsafe { libc::geteuid() } == 0; + if !root { + return Err(format!( + "RESCRIPTUM_USER{} is set, but this process is not root, so it cannot change \ + identity. Either start as root — binding port 69 needs it anyway — or drop \ + the setting and give the binary `setcap cap_net_bind_service`.", + match group { + Some(_) => "/RESCRIPTUM_GROUP", + None => "", + } + )); + } + + let gid = match group { + Some(name) => Some(lookup_group(name)?), + None => None, + }; + let target = match user { + Some(name) => Some(lookup_user(name)?), + None => None, + }; + + // The group first, and the supplementary set before that. + // An explicit group wins; otherwise take the user's own primary group, which is + // what naming only a user is asking for. + let primary = gid.or(target.as_ref().map(|(_, gid, _)| *gid)); + if let Some(gid) = primary { + // Supplementary groups survive `setgid` on their own, and a process that + // kept root's is not unprivileged in any sense that matters. + let name = user.map(std::ffi::CString::new).transpose().ok().flatten(); + let result = match &name { + // `as _`, not a named type: Darwin declares the base group as `int` and + // Linux as `gid_t`, so spelling either one out breaks the other. + Some(name) => unsafe { libc::initgroups(name.as_ptr(), gid as _) }, + // No user named, so there is no supplementary set to compute. Clear it. + None => unsafe { libc::setgroups(0, std::ptr::null()) }, + }; + let initialised = result == 0; + if !initialised { + return Err(format!( + "cannot set the supplementary groups for gid {gid}: {}", + std::io::Error::last_os_error() + )); + } + if unsafe { libc::setgid(gid as libc::gid_t) } != 0 { + return Err(format!( + "cannot become group {gid}: {}", + std::io::Error::last_os_error() + )); + } + } + + if let Some((uid, _, name)) = target { + if unsafe { libc::setuid(uid as libc::uid_t) } != 0 { + return Err(format!( + "cannot become user {name}: {}", + std::io::Error::last_os_error() + )); + } + // **Verify rather than assume.** On some systems a failed drop returns + // success; a process that thinks it dropped and did not is worse than one + // that never tried, because nothing will ever check again. + if unsafe { libc::setuid(0) } == 0 { + return Err( + "dropped to an unprivileged user and was still able to become root again \ + — refusing to run in that state" + .to_string(), + ); + } + } + + crate::log::server(&format!( + "dropped privileges: uid={} gid={}", + unsafe { libc::getuid() }, + unsafe { libc::getgid() } + )); + Ok(()) + } +} + +#[cfg(unix)] +fn lookup_user(name: &str) -> Result<(u32, u32, String), String> { + // A numeric value is an identity in its own right — a container image often has no + // passwd entry at all, and refusing one there would be refusing the normal case. + if let Ok(uid) = name.parse::() { + return Ok((uid, uid, name.to_string())); + } + let c_name = std::ffi::CString::new(name).map_err(|_| format!("{name:?} is not a name"))?; + let entry = unsafe { libc::getpwnam(c_name.as_ptr()) }; + if entry.is_null() { + return Err(format!( + "RESCRIPTUM_USER={name:?} does not exist on this system" + )); + } + let entry = unsafe { *entry }; + Ok((entry.pw_uid as u32, entry.pw_gid as u32, name.to_string())) +} + +#[cfg(unix)] +fn lookup_group(name: &str) -> Result { + if let Ok(gid) = name.parse::() { + return Ok(gid); + } + let c_name = std::ffi::CString::new(name).map_err(|_| format!("{name:?} is not a name"))?; + let entry = unsafe { libc::getgrnam(c_name.as_ptr()) }; + if entry.is_null() { + return Err(format!( + "RESCRIPTUM_GROUP={name:?} does not exist on this system" + )); + } + Ok(unsafe { *entry }.gr_gid as u32) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn naming_nobody_does_nothing() { + // The overwhelmingly common case: no setting, no syscall, no behaviour. + assert!(drop_to(None, None).is_ok()); + } + + #[test] + #[cfg(unix)] + fn a_user_that_does_not_exist_is_refused_by_name() { + // Whatever this process is, the answer must name the setting rather than fail + // with an errno nobody can act on. + let e = drop_to(Some("definitely-not-a-user-on-this-box"), None).expect_err("must refuse"); + assert!( + e.contains("RESCRIPTUM_USER") || e.contains("not root"), + "{e}" + ); + } + + #[test] + #[cfg(unix)] + fn a_non_root_process_says_so_rather_than_failing_obscurely() { + // The test suite does not run as root, so this is the branch it can prove: the + // error names the setting and offers the two alternatives. + if unsafe { libc::geteuid() } == 0 { + return; + } + let e = drop_to(Some("nobody"), None).expect_err("must refuse"); + assert!(e.contains("not root"), "{e}"); + assert!(e.contains("setcap"), "{e}"); + } + + #[test] + #[cfg(unix)] + fn a_numeric_identity_needs_no_passwd_entry() { + // A container image often has no passwd file at all, and refusing a numeric uid + // there would be refusing the normal case. + assert_eq!( + lookup_user("1000").expect("numeric"), + (1000, 1000, "1000".to_string()) + ); + assert_eq!(lookup_group("1000").expect("numeric"), 1000); + } + + #[test] + #[cfg(unix)] + fn a_real_account_resolves() { + // `root` exists on every Unix this can run on, and looking it up proves the + // getpwnam path rather than only the numeric shortcut. + let (uid, _, name) = lookup_user("root").expect("root exists"); + assert_eq!(uid, 0); + assert_eq!(name, "root"); + } +} diff --git a/src/boot/probe.rs b/src/boot/probe.rs new file mode 100644 index 0000000..176c545 --- /dev/null +++ b/src/boot/probe.rs @@ -0,0 +1,548 @@ +//! Placing an image: which installer family it is, and where its kernel and initrd sit. +//! +//! A table of markers, read in order, first match wins. It costs a few kilobytes of +//! reads — a volume descriptor and a directory extent or two — never a pass over the +//! file, because ISO9660 directories are extents you can seek to. +//! +//! **Every row was written from documentation and must be pinned against a real image +//! before it is trusted.** The table is a table precisely so that verifying it is cheap. +//! An image no row claims is `Unknown` and still served: not describable is not the +//! same as not usable. + +use super::iso::Iso; +use std::collections::BTreeMap; +use std::path::Path; + +/// The families whose boot arguments `stanza` knows how to write. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum Family { + Proxmox, + Debian, + Ubuntu, + Rhel, + Suse, + CoreOs, + Unknown, +} + +impl Family { + pub fn label(self) -> &'static str { + match self { + Family::Proxmox => "proxmox", + Family::Debian => "debian", + Family::Ubuntu => "ubuntu", + Family::Rhel => "rhel", + Family::Suse => "suse", + Family::CoreOs => "coreos", + Family::Unknown => "unknown", + } + } + + pub fn parse(text: &str) -> Option { + Some(match text { + "proxmox" => Family::Proxmox, + "debian" => Family::Debian, + "ubuntu" => Family::Ubuntu, + "rhel" => Family::Rhel, + "suse" => Family::Suse, + "coreos" => Family::CoreOs, + "unknown" => Family::Unknown, + _ => return None, + }) + } +} + +/// What the menu gates entries on. An ARM64 image offered to an x86 client is a menu +/// entry that boots the wrong kernel, so this is part of the probe rather than a note. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum Arch { + X86_64, + Arm64, +} + +impl Arch { + pub fn label(self) -> &'static str { + match self { + Arch::X86_64 => "x86_64", + Arch::Arm64 => "arm64", + } + } + + /// iPXE's `${buildarch}`, which is what a generated menu compares against. + pub fn buildarch(self) -> &'static str { + match self { + Arch::X86_64 => "x86_64", + Arch::Arm64 => "arm64", + } + } + + pub fn parse(text: &str) -> Option { + Some(match text { + "x86_64" | "amd64" => Arch::X86_64, + "arm64" | "aarch64" => Arch::Arm64, + _ => return None, + }) + } +} + +/// Everything the probe could establish. Any of it may be absent; none of it is fatal. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct Probed { + pub family: Option, + pub version: Option, + pub arch: Option, + /// Where the kernel is **inside the image**. + pub kernel: Option, + pub initrd: Option, + /// The kernel and initrd are files *beside* the image, not inside it — which is + /// what `prepare-iso --pxe` leaves behind, since it strips `/boot` from the ISO it + /// emits and writes `vmlinuz` and `initrd.img` next to it. + pub external: bool, + /// A stock Proxmox initrd is zstd-compressed. The assistant recompresses it to gzip + /// when it splits, saying "iPXE does not support a zstd-compressed initrd" — so an + /// image carrying the original is worth a word from `media check`. + pub zstd_initrd: bool, +} + +/// One row of the table: a marker that claims an image, and what it implies. +struct Row { + marker: &'static str, + family: Family, + kernel: &'static str, + initrd: &'static str, + arch: Option, +} + +/// **Ordered, and the order carries meaning.** CoreOS sits above the RHEL family whose +/// on-disk skeleton it borrows; a plain RHEL row first would claim every CoreOS image. +const TABLE: &[Row] = &[ + Row { + marker: "/boot/linux26", + family: Family::Proxmox, + kernel: "/boot/linux26", + initrd: "/boot/initrd.img", + arch: None, + }, + Row { + marker: "/casper/vmlinuz", + family: Family::Ubuntu, + kernel: "/casper/vmlinuz", + initrd: "/casper/initrd", + arch: None, + }, + Row { + marker: "/install.amd/vmlinuz", + family: Family::Debian, + kernel: "/install.amd/vmlinuz", + initrd: "/install.amd/initrd.gz", + arch: Some(Arch::X86_64), + }, + Row { + marker: "/install.a64/vmlinuz", + family: Family::Debian, + kernel: "/install.a64/vmlinuz", + initrd: "/install.a64/initrd.gz", + arch: Some(Arch::Arm64), + }, + // Before the RHEL row: Fedora CoreOS lays its files out the same way and is told + // apart by the live rootfs the RHEL installer does not have. + Row { + marker: "/images/pxeboot/rootfs.img", + family: Family::CoreOs, + kernel: "/images/pxeboot/vmlinuz", + initrd: "/images/pxeboot/initrd.img", + arch: None, + }, + Row { + marker: "/images/pxeboot/vmlinuz", + family: Family::Rhel, + kernel: "/images/pxeboot/vmlinuz", + initrd: "/images/pxeboot/initrd.img", + arch: None, + }, + Row { + marker: "/boot/x86_64/loader/linux", + family: Family::Suse, + kernel: "/boot/x86_64/loader/linux", + initrd: "/boot/x86_64/loader/initrd", + arch: Some(Arch::X86_64), + }, + Row { + marker: "/boot/aarch64/loader/linux", + family: Family::Suse, + kernel: "/boot/aarch64/loader/linux", + initrd: "/boot/aarch64/loader/initrd", + arch: Some(Arch::Arm64), + }, +]; + +/// Read enough of an image to place it. +pub fn probe(path: &Path) -> std::io::Result { + let mut iso = Iso::open(path)?; + let mut found = Probed::default(); + + // `/.disk/info` first, and Proxmox is why. `prepare-iso --pxe` **strips `/boot`** + // from the ISO it emits — about 100 MiB — so the `/boot/linux26` marker misses + // exactly the Proxmox image most likely to be dropped into a media directory. This + // file survives, and identifying an installer by it is upstream's own method: + // `proxmox-auto-install-assistant inspect-iso` reads this file and checks that + // PRODUCTLONG starts with "Proxmox". + let disk_info = iso + .read("/.disk/info", 8 * 1024) + .ok() + .flatten() + .map(|bytes| String::from_utf8_lossy(&bytes).into_owned()); + + if let Some(info) = &disk_info { + let fields = parse_disk_info(info); + let product = fields.get("PRODUCTLONG").map(String::as_str).unwrap_or(""); + if product.starts_with("Proxmox") { + found.family = Some(Family::Proxmox); + found.version = Some(proxmox_version(&fields, product)); + // ISOs predating the ARCH key are always amd64, as the assistant records. + found.arch = fields + .get("ARCH") + .and_then(|a| Arch::parse(a)) + .or(Some(Arch::X86_64)); + } else if fields.is_empty() { + // Debian and Ubuntu write a single descriptive line here instead. + found.version = info.lines().next().map(|l| l.trim().to_string()); + } + } + + for row in TABLE { + if !iso.has(row.marker) { + continue; + } + // A family established from `/.disk/info` is not overridden by a marker; it was + // read from the vendor's own identification, which is the stronger evidence. + if found.family.is_none() { + found.family = Some(row.family); + } + if found.family == Some(row.family) { + found.kernel = Some(row.kernel.to_string()); + found.initrd = Some(row.initrd.to_string()); + if found.arch.is_none() { + found.arch = row.arch; + } + } + break; + } + + // A Proxmox image the assistant has already split: the family is known from + // `/.disk/info`, and the kernel and initrd it needs are the files beside it. + if found.family == Some(Family::Proxmox) && found.kernel.is_none() { + let beside = path.parent().unwrap_or(Path::new(".")); + if beside.join("vmlinuz").is_file() && beside.join("initrd.img").is_file() { + found.kernel = Some("vmlinuz".to_string()); + found.initrd = Some("initrd.img".to_string()); + found.external = true; + } + } + + if found.version.is_none() { + found.version = version_from(&mut iso, found.family); + } + if found.arch.is_none() { + found.arch = arch_from_kernel(&mut iso, found.kernel.as_deref()); + } + // The compression an initrd actually carries, which decides whether a loader that + // only speaks gzip can use it. + if let Some(initrd) = &found.initrd + && !found.external + && let Ok(Some(head)) = iso.read(initrd, 4) + { + found.zstd_initrd = head.starts_with(&[0x28, 0xB5, 0x2F, 0xFD]); + } + if found.version.is_none() && !iso.volume_id.is_empty() { + found.version = Some(iso.volume_id.clone()); + } + + Ok(found) +} + +/// `/.disk/info` in a Proxmox image is shell-env style: `KEY='value'` a line at a time. +/// Debian and Ubuntu write one descriptive sentence instead, which parses to nothing +/// here — and that emptiness is what tells the two apart. +fn parse_disk_info(text: &str) -> BTreeMap { + let mut out = BTreeMap::new(); + for line in text.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let Some((key, value)) = line.split_once('=') else { + continue; + }; + // A key with a space in it is prose, not a field. + if key.trim().contains(char::is_whitespace) { + continue; + } + out.insert( + key.trim().to_string(), + value.trim().trim_matches(['\'', '"']).to_string(), + ); + } + out +} + +fn proxmox_version(fields: &BTreeMap, product: &str) -> String { + match (fields.get("RELEASE"), fields.get("ISORELEASE")) { + (Some(release), Some(iso)) => format!("{product} {release}-{iso}"), + (Some(release), None) => format!("{product} {release}"), + _ => product.to_string(), + } +} + +/// Where a vendor left a version string, take it. Nobody has to, and the volume +/// identifier is the fallback. +fn version_from(iso: &mut Iso, family: Option) -> Option { + match family { + Some(Family::Rhel) | Some(Family::CoreOs) => { + let text = iso.read("/.treeinfo", 16 * 1024).ok().flatten()?; + let text = String::from_utf8_lossy(&text).into_owned(); + let mut name = None; + let mut version = None; + for line in text.lines() { + let line = line.trim(); + if let Some(v) = line.strip_prefix("version") { + version = v.trim_start_matches([' ', '=']).trim().to_string().into(); + } else if let Some(v) = line.strip_prefix("name") { + name = v.trim_start_matches([' ', '=']).trim().to_string().into(); + } + } + match (name, version) { + (Some(n), _) => Some(n), + (None, Some(v)) => Some(v), + _ => None, + } + } + _ => None, + } +} + +/// A kernel says what it is in its first few dozen bytes. Cheaper and more honest than +/// guessing from a filename, and it is the only signal for a family whose layout is not +/// per-architecture. +fn arch_from_kernel(iso: &mut Iso, kernel: Option<&str>) -> Option { + let kernel = kernel?; + let head = iso.read(kernel, 0x400).ok().flatten()?; + // x86 bzImage: "HdrS" at 0x202. + if head.len() > 0x206 && &head[0x202..0x206] == b"HdrS" { + return Some(Arch::X86_64); + } + // arm64 Image: the magic at offset 56. + if head.len() > 60 && &head[56..60] == b"ARM\x64" { + return Some(Arch::Arm64); + } + None +} + +#[cfg(test)] +mod tests { + use super::super::iso::build; + use super::*; + + fn probe_of(name: &str, builder: &build::Builder) -> Probed { + let dir = std::env::temp_dir().join(format!("rescriptum-probe-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("temp dir"); + let path = dir.join(format!("{name}.iso")); + std::fs::write(&path, builder.build()).expect("write"); + probe(&path).expect("probes") + } + + /// A kernel the arch sniffer will recognise, so a fixture can be honest about it. + fn bzimage() -> Vec { + let mut k = vec![0u8; 0x400]; + k[0x202..0x206].copy_from_slice(b"HdrS"); + k + } + + fn arm64_image() -> Vec { + let mut k = vec![0u8; 0x400]; + k[56..60].copy_from_slice(b"ARM\x64"); + k + } + + #[test] + fn each_family_is_claimed_by_its_own_marker() { + // The table is the behaviour, so the test walks it the way a real image would. + let cases: &[(&str, &str, Family)] = &[ + ("pve", "/boot/linux26", Family::Proxmox), + ("ubuntu", "/casper/vmlinuz", Family::Ubuntu), + ("debian", "/install.amd/vmlinuz", Family::Debian), + ("rhel", "/images/pxeboot/vmlinuz", Family::Rhel), + ("suse", "/boot/x86_64/loader/linux", Family::Suse), + ]; + for (name, marker, family) in cases { + let found = probe_of(name, &build::Builder::new().file(marker, &bzimage())); + assert_eq!(found.family, Some(*family), "{marker}"); + assert_eq!(found.kernel.as_deref(), Some(*marker), "{marker}"); + assert!(found.initrd.is_some(), "{marker}"); + } + } + + #[test] + fn coreos_is_told_apart_from_the_rhel_skeleton_it_borrows() { + // Both lay their files out in /images/pxeboot. Ordering the table wrongly makes + // every CoreOS image a RHEL one, and the boot arguments are entirely different. + let coreos = probe_of( + "coreos", + &build::Builder::new() + .file("/images/pxeboot/vmlinuz", &bzimage()) + .file("/images/pxeboot/rootfs.img", b"live root"), + ); + assert_eq!(coreos.family, Some(Family::CoreOs)); + + let rhel = probe_of( + "rhel-only", + &build::Builder::new().file("/images/pxeboot/vmlinuz", &bzimage()), + ); + assert_eq!(rhel.family, Some(Family::Rhel)); + } + + #[test] + fn a_proxmox_image_the_assistant_trimmed_is_still_recognised() { + // `prepare-iso --pxe` strips /boot — about 100 MiB — so the marker the plain + // table would use is gone from exactly the image most likely to be dropped into + // a media directory. `/.disk/info` survives, and reading it is how + // `proxmox-auto-install-assistant inspect-iso` identifies an ISO itself. + let found = probe_of( + "pve-trimmed", + &build::Builder::new().file( + "/.disk/info", + b"PRODUCTLONG='Proxmox Virtual Environment'\nRELEASE='8.4'\nISORELEASE='1'\nARCH='amd64'\n", + ), + ); + assert_eq!(found.family, Some(Family::Proxmox)); + assert_eq!( + found.version.as_deref(), + Some("Proxmox Virtual Environment 8.4-1") + ); + assert_eq!(found.arch, Some(Arch::X86_64)); + // Nothing beside it, so it declares no kernel rather than inventing one. + assert_eq!(found.kernel, None); + assert!(!found.external); + } + + #[test] + fn a_trimmed_image_finds_the_kernel_the_assistant_left_beside_it() { + // `--pxe` writes vmlinuz and initrd.img into the same output directory. Finding + // them there is what makes an assistant-prepared directory work as-is. + let dir = std::env::temp_dir().join(format!("rescriptum-pxe-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("temp dir"); + let image = dir.join("pve.iso"); + std::fs::write( + &image, + build::Builder::new() + .file("/.disk/info", b"PRODUCTLONG='Proxmox VE'\nRELEASE='8.4'\n") + .build(), + ) + .expect("write"); + std::fs::write(dir.join("vmlinuz"), bzimage()).expect("write"); + std::fs::write(dir.join("initrd.img"), b"\x1f\x8b gzip").expect("write"); + + let found = probe(&image).expect("probes"); + assert_eq!(found.family, Some(Family::Proxmox)); + assert!(found.external, "the kernel is beside the image, not inside"); + assert_eq!(found.kernel.as_deref(), Some("vmlinuz")); + assert_eq!(found.initrd.as_deref(), Some("initrd.img")); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_stock_proxmox_initrd_is_noticed_to_be_zstd() { + // The assistant recompresses it to gzip when it splits, on the grounds that + // "iPXE does not support a zstd-compressed initrd". Whether that binds through + // our chain is a bench question; noticing it is not. + let found = probe_of( + "pve-zstd", + &build::Builder::new() + .file("/boot/linux26", &bzimage()) + .file("/boot/initrd.img", &[0x28, 0xB5, 0x2F, 0xFD, 0, 0, 0, 0]), + ); + assert_eq!(found.family, Some(Family::Proxmox)); + assert!(found.zstd_initrd); + + let gzipped = probe_of( + "pve-gzip", + &build::Builder::new() + .file("/boot/linux26", &bzimage()) + .file("/boot/initrd.img", &[0x1f, 0x8b, 0x08, 0x00]), + ); + assert!(!gzipped.zstd_initrd); + } + + #[test] + fn architecture_comes_from_the_path_where_the_layout_says_it() { + let debian = probe_of( + "debian-arm", + &build::Builder::new().file("/install.a64/vmlinuz", b"not a recognisable kernel"), + ); + assert_eq!(debian.arch, Some(Arch::Arm64)); + + let suse = probe_of( + "suse-arm", + &build::Builder::new().file("/boot/aarch64/loader/linux", b"opaque"), + ); + assert_eq!(suse.arch, Some(Arch::Arm64)); + } + + #[test] + fn architecture_falls_back_to_the_kernel_image_itself() { + // Ubuntu's layout is the same on both architectures, so the only honest source + // is the kernel. An ARM64 image offered to an x86 client is a menu entry that + // boots the wrong thing. + let x86 = probe_of( + "ubuntu-x86", + &build::Builder::new().file("/casper/vmlinuz", &bzimage()), + ); + assert_eq!(x86.arch, Some(Arch::X86_64)); + + let arm = probe_of( + "ubuntu-arm", + &build::Builder::new().file("/casper/vmlinuz", &arm64_image()), + ); + assert_eq!(arm.arch, Some(Arch::Arm64)); + } + + #[test] + fn an_image_nothing_claims_is_unknown_and_still_described() { + // Not describable is not the same as not usable: it is still served, and the + // volume identifier is the name it gets. + let found = probe_of( + "mystery", + &build::Builder::new() + .volume("SOME LIVE CD") + .file("/readme.txt", b"hello"), + ); + assert_eq!(found.family, None); + assert_eq!(found.version.as_deref(), Some("SOME LIVE CD")); + assert_eq!(found.kernel, None); + } + + #[test] + fn a_debian_style_disk_info_is_not_mistaken_for_proxmox_fields() { + // Debian and Ubuntu write one sentence where Proxmox writes KEY='value'. The + // parser must not turn that sentence into a field. + let fields = + parse_disk_info("Ubuntu-Server 24.04.1 LTS \"Noble Numbat\" - Release amd64\n"); + assert!(fields.is_empty(), "{fields:?}"); + + let found = probe_of( + "ubuntu-info", + &build::Builder::new() + .file( + "/.disk/info", + b"Ubuntu-Server 24.04.1 LTS - Release amd64\n", + ) + .file("/casper/vmlinuz", &bzimage()), + ); + assert_eq!(found.family, Some(Family::Ubuntu)); + assert_eq!( + found.version.as_deref(), + Some("Ubuntu-Server 24.04.1 LTS - Release amd64") + ); + } +} diff --git a/src/boot/sha256.rs b/src/boot/sha256.rs new file mode 100644 index 0000000..b4e5cf9 --- /dev/null +++ b/src/boot/sha256.rs @@ -0,0 +1,259 @@ +//! SHA-256, hand-written, because a digest is not worth a dependency. +//! +//! Two jobs and no third: record what an image was when it was ingested, and re-check +//! it later. FIPS 180-4, the straightforward implementation — a 1.5 GB image is hashed +//! once at `media add`, never per request, so the constant factor here buys nothing +//! worth the code to earn it. + +const K: [u32; 64] = [ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, +]; + +const INITIAL: [u32; 8] = [ + 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19, +]; + +pub struct Sha256 { + state: [u32; 8], + block: [u8; 64], + buffered: usize, + /// Message length in **bits**, which is what the padding encodes. + bits: u64, +} + +impl Default for Sha256 { + fn default() -> Self { + Sha256::new() + } +} + +impl Sha256 { + pub fn new() -> Sha256 { + Sha256 { + state: INITIAL, + block: [0; 64], + buffered: 0, + bits: 0, + } + } + + pub fn update(&mut self, mut data: &[u8]) { + self.bits = self.bits.wrapping_add((data.len() as u64) * 8); + + if self.buffered > 0 { + let take = (64 - self.buffered).min(data.len()); + self.block[self.buffered..self.buffered + take].copy_from_slice(&data[..take]); + self.buffered += take; + data = &data[take..]; + // Still short of a block: return, or the tail below would reset `buffered` + // to zero and drop what is already held. + if self.buffered < 64 { + return; + } + let block = self.block; + self.compress(&block); + self.buffered = 0; + } + + let mut chunks = data.chunks_exact(64); + for chunk in &mut chunks { + let mut block = [0u8; 64]; + block.copy_from_slice(chunk); + self.compress(&block); + } + + let rest = chunks.remainder(); + self.block[..rest.len()].copy_from_slice(rest); + self.buffered = rest.len(); + } + + pub fn finish(mut self) -> [u8; 32] { + let bits = self.bits; + // 0x80, then zeros, then the length: the padding must land the message on a + // 64-byte boundary with eight bytes to spare. + self.pad(0x80); + while self.buffered != 56 { + self.pad(0); + } + for byte in bits.to_be_bytes() { + self.pad(byte); + } + debug_assert_eq!(self.buffered, 0); + + let mut out = [0u8; 32]; + for (chunk, word) in out.chunks_exact_mut(4).zip(self.state.iter()) { + chunk.copy_from_slice(&word.to_be_bytes()); + } + out + } + + /// One padding byte, flushing the block when it fills. Deliberately not `update`: + /// padding must not count toward the length. + fn pad(&mut self, byte: u8) { + self.block[self.buffered] = byte; + self.buffered += 1; + if self.buffered == 64 { + let block = self.block; + self.compress(&block); + self.buffered = 0; + } + } + + fn compress(&mut self, block: &[u8; 64]) { + let mut w = [0u32; 64]; + for (i, chunk) in block.chunks_exact(4).enumerate() { + w[i] = u32::from_be_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]); + } + for i in 16..64 { + let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3); + let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10); + w[i] = w[i - 16] + .wrapping_add(s0) + .wrapping_add(w[i - 7]) + .wrapping_add(s1); + } + + let [mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut h] = self.state; + for i in 0..64 { + let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25); + let ch = (e & f) ^ ((!e) & g); + let t1 = h + .wrapping_add(s1) + .wrapping_add(ch) + .wrapping_add(K[i]) + .wrapping_add(w[i]); + let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22); + let maj = (a & b) ^ (a & c) ^ (b & c); + let t2 = s0.wrapping_add(maj); + + h = g; + g = f; + f = e; + e = d.wrapping_add(t1); + d = c; + c = b; + b = a; + a = t1.wrapping_add(t2); + } + + for (slot, value) in self.state.iter_mut().zip([a, b, c, d, e, f, g, h]) { + *slot = slot.wrapping_add(value); + } + } +} + +/// The digest of a slice, lowercase hex — the form every checksum file in the world uses. +pub fn hex(data: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(data); + to_hex(&hasher.finish()) +} + +pub fn to_hex(digest: &[u8; 32]) -> String { + let mut out = String::with_capacity(64); + for byte in digest { + out.push(char::from_digit((byte >> 4) as u32, 16).unwrap_or('0')); + out.push(char::from_digit((byte & 0x0f) as u32, 16).unwrap_or('0')); + } + out +} + +/// Whether a string is a plausible SHA-256, so `--sha256 deadbeef` is refused at the +/// boundary rather than never matching anything. +pub fn is_digest(value: &str) -> bool { + value.len() == 64 && value.bytes().all(|b| b.is_ascii_hexdigit()) +} + +/// Hash a whole file, reporting progress as it goes. +/// +/// **This is an ingest-time cost and never a request-time one.** At about 30 MB/s on the +/// small end of the range this has to work on, a 1.5 GB image is three quarters of a +/// minute — and a minute of silence reads as a hang, so `progress` is a callback rather +/// than an option nobody turns on. +pub fn file(path: &std::path::Path, mut progress: impl FnMut(u64, u64)) -> std::io::Result { + use std::io::Read; + + let total = std::fs::metadata(path)?.len(); + let mut file = std::fs::File::open(path)?; + let mut hasher = Sha256::new(); + let mut buffer = vec![0u8; 1024 * 1024]; + let mut done = 0u64; + + loop { + let n = match file.read(&mut buffer) { + Ok(0) => break, + Ok(n) => n, + Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => continue, + Err(e) => return Err(e), + }; + hasher.update(&buffer[..n]); + done += n as u64; + progress(done, total); + } + Ok(to_hex(&hasher.finish())) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The three vectors everybody's SHA-256 is checked against, plus the two that + /// actually catch padding bugs: a message that lands exactly on a block boundary, + /// and one that leaves too little room for the length and forces a second block. + #[test] + fn known_vectors() { + assert_eq!( + hex(b""), + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ); + assert_eq!( + hex(b"abc"), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + assert_eq!( + hex(b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq"), + "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1" + ); + // 64 bytes: exactly one block, so the padding is entirely a second one. + assert_eq!( + hex(&[b'a'; 64]), + "ffe054fe7ae0cb6dc65c3af9b61d5209f439851db43d0ba5997337df154668eb" + ); + // 56 bytes: the length field has nowhere to go without a second block. + assert_eq!( + hex(&[b'a'; 56]), + "b35439a4ac6f0948b6d6f9e3c6af0f5f590ce20f1bde7090ef7970686ec6738a" + ); + } + + #[test] + fn a_streamed_message_hashes_the_same_as_one_slice() { + // The whole reason this is incremental: a 1.5 GB image is fed 64 KiB at a time, + // and a buffering bug there would surface as a digest that never matches. + let data: Vec = (0..1000u32).flat_map(|n| n.to_le_bytes()).collect(); + let once = hex(&data); + + for chunk in [1usize, 7, 63, 64, 65, 127, 128, 1000] { + let mut hasher = Sha256::new(); + for part in data.chunks(chunk) { + hasher.update(part); + } + assert_eq!(to_hex(&hasher.finish()), once, "chunked by {chunk}"); + } + } + + #[test] + fn a_digest_is_recognised_by_shape() { + assert!(is_digest(&hex(b"anything"))); + assert!(!is_digest("deadbeef")); + assert!(!is_digest(&"z".repeat(64))); + assert!(!is_digest("")); + } +} diff --git a/src/boot/sources.rs b/src/boot/sources.rs new file mode 100644 index 0000000..061db3f --- /dev/null +++ b/src/boot/sources.rs @@ -0,0 +1,398 @@ +//! Where installer images can be *fetched from* — as opposed to `catalog`, which is what +//! is already held. +//! +//! ## Why this is a list of indexes rather than a list of ISOs +//! +//! The obvious shape for "offer the usual images" is a table of URLs with their digests +//! baked in. It is also wrong, and would be wrong the day it shipped: Proxmox prunes old +//! ISOs from its CDN, Debian and Ubuntu publish point releases every few weeks, and a +//! release cut in August would still be offering June's images — some of them 404. Worse, +//! this project *requires* a digest for a URL (`media add`), because that decision is what +//! every machine ends up installing. A baked-in table would need re-cutting on somebody +//! else's schedule, forever. +//! +//! So nothing about a specific image is stored here. Each entry names the **checksum index +//! the vendor already publishes beside its own images**, and both the filenames and their +//! digests are read from it at the moment somebody asks. The list is current because it is +//! the vendor's, and the digest requirement is satisfied by the vendor's own file — which +//! is exactly what the documentation already tells people to do by hand. +//! +//! ## What that is worth, honestly +//! +//! Taking the digest from the same host that serves the image is **not** the same as +//! verifying a signature you already trusted. Over HTTPS it authenticates the vendor's +//! domain and it catches a truncated download, a corrupted mirror and a file that quietly +//! changed underneath — which is most of what goes wrong. It is *not* protection against a +//! vendor's CDN being compromised, and this module does not pretend otherwise. Somebody +//! who wants more pastes a digest they obtained out of band into `media add URL --sha256`, +//! which stays the stronger path and is never removed. +//! +//! ## The two formats, both measured +//! +//! Every index checked is one of two shapes, so the parser handles exactly two and refuses +//! to guess at a third: +//! +//! - **coreutils** — ` `, with an optional `*` marking binary mode. +//! Proxmox, Debian and Ubuntu. +//! - **BSD tag** — `SHA256 () = `. AlmaLinux and Rocky, inside a +//! PGP-clearsigned document whose surrounding lines simply do not match and are skipped. + +use super::sha256; + +/// One vendor's published index. +/// +/// **There is no separate base URL**, deliberately: it is the index's own directory. A +/// second field could drift from the first, and the whole point of reading the vendor's +/// index is that the two cannot disagree. +pub struct Source { + /// What `media add --from` takes. Becomes part of no URL, but reads like an id. + pub id: &'static str, + pub label: &'static str, + /// The checksum index, beside the images it describes. + pub index: &'static str, + /// Kept when the index lists more than this project should offer — Proxmox's one file + /// covers Backup Server and Mail Gateway too, and offering those under "Proxmox VE" + /// would be a lie. Empty means no filtering. + pub keep: &'static str, + /// A word for what these images install, so a list is readable without knowing the + /// project. Not the `Family` enum: this is documentation, and `probe` decides the + /// real family once an image is on disk. + pub about: &'static str, +} + +/// The indexes shipped by default. +/// +/// **Every URL here was fetched before being written down**, and so was one image URL +/// derived from each — a table of plausible-looking 404s would be worse than no table. +/// Adding one is a two-line change; an operator who needs a local mirror uses +/// `media add URL --sha256` and needs nothing from this list. +pub const SOURCES: &[Source] = &[ + Source { + id: "proxmox-ve", + label: "Proxmox VE", + index: "https://enterprise.proxmox.com/iso/SHA256SUMS", + // The same index lists proxmox-backup-server and proxmox-mail-gateway. + keep: "proxmox-ve_", + about: "the founding case — answers come from a file injected into the image", + }, + Source { + id: "debian", + label: "Debian", + index: "https://cdimage.debian.org/debian-cd/current/amd64/iso-cd/SHA256SUMS", + keep: "", + about: "netinst images; the answer is a preseed on the kernel command line", + }, + Source { + id: "ubuntu", + label: "Ubuntu LTS", + index: "https://releases.ubuntu.com/noble/SHA256SUMS", + // The index lists a .wsl alongside the images; `is_image` drops it. + keep: "", + about: "autoinstall, via a cloud-init datasource on the kernel command line", + }, + Source { + id: "almalinux", + label: "AlmaLinux 9", + index: "https://repo.almalinux.org/almalinux/9/isos/x86_64/CHECKSUM", + keep: "", + about: "kickstart, named on the kernel command line", + }, + Source { + id: "rocky", + label: "Rocky Linux 9", + index: "https://download.rockylinux.org/pub/rocky/9/isos/x86_64/CHECKSUM", + keep: "", + about: "kickstart, named on the kernel command line", + }, +]; + +/// Look one up by id. +pub fn source(id: &str) -> Option<&'static Source> { + SOURCES.iter().find(|s| s.id == id) +} + +/// One image a source offers. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Available { + pub name: String, + pub digest: String, + pub url: String, +} + +impl Source { + /// The directory the index lives in, which is where its images live too. + pub fn base(&self) -> &str { + match self.index.rfind('/') { + Some(at) => &self.index[..=at], + None => self.index, + } + } + + /// Everything this index offers that is an installer image, newest first. + pub fn offers(&self, index_text: &str) -> Vec { + let mut out: Vec = parse_index(index_text) + .into_iter() + .filter(|(name, _)| is_image(name) && name.starts_with(self.keep)) + .map(|(name, digest)| Available { + url: format!("{}{name}", self.base()), + name, + digest, + }) + .collect(); + // **Newest first, because the first row is the one that gets clicked.** Sorted by + // the version-ish parts of the name rather than the whole string: plain + // lexicographic ordering puts `9.10` before `9.9`, which would offer a rack an + // older installer than the one it asked for. + out.sort_by_key(|s| std::cmp::Reverse(natural(&s.name))); + out + } +} + +/// Whether a name from an index is an image this server could ever serve. +/// +/// Ubuntu's index lists a `.wsl` beside its ISOs; Debian's lists `.jigdo` in some +/// directories. Neither is bootable here, and offering one is a click that ends in a +/// puzzle. +fn is_image(name: &str) -> bool { + let lower = name.to_ascii_lowercase(); + lower.ends_with(".iso") || lower.ends_with(".img") +} + +/// Split a name into text and number runs, so `9.9` sorts before `9.10`. +fn natural(name: &str) -> Vec { + let mut out = Vec::new(); + let mut chars = name.chars().peekable(); + while let Some(&c) = chars.peek() { + if c.is_ascii_digit() { + let mut n: u64 = 0; + while let Some(&d) = chars.peek() { + if !d.is_ascii_digit() { + break; + } + // Saturating rather than wrapping: a 30-digit run in a filename is not a + // version, and it must not silently become a small number. + n = n.saturating_mul(10).saturating_add(d as u64 - '0' as u64); + chars.next(); + } + out.push(Chunk::Number(n)); + } else { + let mut s = String::new(); + while let Some(&d) = chars.peek() { + if d.is_ascii_digit() { + break; + } + s.push(d.to_ascii_lowercase()); + chars.next(); + } + out.push(Chunk::Text(s)); + } + } + out +} + +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)] +enum Chunk { + // Text before Number, so `debian-13` and `debian-9` compare on the number. + Text(String), + Number(u64), +} + +/// Read a checksum index in either of the two formats vendors actually publish. +/// +/// Anything that is not a digest line is skipped rather than refused — an AlmaLinux +/// `CHECKSUM` is a PGP-clearsigned document with a header, a comment per file and a +/// signature block, and none of that is an error. +pub fn parse_index(text: &str) -> Vec<(String, String)> { + let mut out = Vec::new(); + for line in text.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + if let Some(pair) = bsd_tag(line).or_else(|| coreutils(line)) { + out.push(pair); + } + } + out +} + +/// `SHA256 (name) = digest` — AlmaLinux, Rocky. +fn bsd_tag(line: &str) -> Option<(String, String)> { + let rest = line.strip_prefix("SHA256")?.trim_start(); + let rest = rest.strip_prefix('(')?; + let (name, rest) = rest.split_once(')')?; + let digest = rest.trim_start().strip_prefix('=')?.trim(); + sha256::is_digest(digest).then(|| (name.trim().to_string(), digest.to_ascii_lowercase())) +} + +/// `digest name`, with `*` marking binary mode — Proxmox, Debian, Ubuntu. +fn coreutils(line: &str) -> Option<(String, String)> { + let (digest, name) = line.split_once(char::is_whitespace)?; + if !sha256::is_digest(digest) { + return None; + } + let name = name.trim_start(); + let name = name.strip_prefix('*').unwrap_or(name); + let name = name.trim(); + (!name.is_empty()).then(|| (name.to_string(), digest.to_ascii_lowercase())) +} + +#[cfg(test)] +mod tests { + use super::*; + + // Captured from the real indexes, on 2026-08-28, rather than invented — the point of + // a fixture here is that it is the shape the vendor actually publishes. + const PROXMOX: &str = "\ +d237d70ca48a9f6eb47f95fd4fd337722c3f69f8106393844d027d28c26523d8 proxmox-ve_8.4-1.iso +6d8f5afc78c0c66812d7272cde7c8b98be7eb54401ceb045400db05eb5ae6d22 proxmox-ve_9.1-1.iso +4e88fe416df9b527624a175f24c9aa07c714d3332afb1ee3dbf3879573ef2c6c proxmox-ve_9.2-1.iso +721e21a88ae93dba73ca3e4a494b438190acadb99993ec755a19e721a86f0395 proxmox-backup-server_2.4-1.iso +"; + + const UBUNTU: &str = "\ +faabcf33ae53976d2b8207a001ff32f4e5daae013505ac7188c9ea63988f8328 *ubuntu-24.04.3-live-server-amd64.iso +c74833a55e525b1e99e1541509c566bb3e32bdb53bf27ea3347174364a57f47c *ubuntu-24.04.3-wsl-amd64.wsl +e907d92eeec9df64163a7e454cbc8d7755e8ddc7ed42f99dbc80c40f1a138433 *ubuntu-24.04.4-live-server-amd64.iso +"; + + const ALMA: &str = "\ +-----BEGIN PGP SIGNED MESSAGE----- +Hash: SHA256 + +# AlmaLinux-9.8-x86_64-boot.iso: 1519271936 bytes +SHA256 (AlmaLinux-9.8-x86_64-boot.iso) = 445f99e24399bbe98aab86111d60751c142eda049d2444fd76da5eb03472e4ab +SHA256 (AlmaLinux-9.8-x86_64-dvd.iso) = 7a392bdc879afd159b30da39a356b7b26c1ddf618b01549164da9aadbc40d814 +-----BEGIN PGP SIGNATURE----- +iQIzBAEBCAAdFiEE +-----END PGP SIGNATURE----- +"; + + #[test] + fn the_coreutils_format_parses_with_and_without_the_binary_marker() { + let got = parse_index(PROXMOX); + assert_eq!(got.len(), 4); + assert_eq!(got[0].0, "proxmox-ve_8.4-1.iso"); + assert_eq!( + got[0].1, + "d237d70ca48a9f6eb47f95fd4fd337722c3f69f8106393844d027d28c26523d8" + ); + + // Ubuntu marks binary mode with `*`, which is part of the format and not part of + // the filename. Left in, every fetch would ask for a file that does not exist. + let got = parse_index(UBUNTU); + assert_eq!(got[0].0, "ubuntu-24.04.3-live-server-amd64.iso"); + } + + #[test] + fn a_pgp_signed_bsd_tag_index_parses_and_its_wrapper_is_not_an_error() { + let got = parse_index(ALMA); + assert_eq!( + got.len(), + 2, + "the signature and header are skipped, not refused" + ); + assert_eq!(got[0].0, "AlmaLinux-9.8-x86_64-boot.iso"); + assert_eq!( + got[0].1, + "445f99e24399bbe98aab86111d60751c142eda049d2444fd76da5eb03472e4ab" + ); + } + + #[test] + fn nothing_that_is_not_a_digest_line_is_taken_for_one() { + // Each of these has the *shape* of an entry and is not one. A parser that accepted + // any of them would offer a fetch that cannot resolve. + let text = "\ +not-a-digest something.iso +SHA1 (old.iso) = da39a3ee5e6b4b0d3255bfef95601890afd80709 +SHA256 (truncated.iso) = abc123 +# 445f99e24399bbe98aab86111d60751c142eda049d2444fd76da5eb03472e4ab commented.iso +445f99e24399bbe98aab86111d60751c142eda049d2444fd76da5eb03472e4ab +"; + assert!(parse_index(text).is_empty(), "{:?}", parse_index(text)); + } + + #[test] + fn an_index_offers_only_images_and_only_what_the_source_claims() { + let proxmox = source("proxmox-ve").expect("shipped"); + let offers = proxmox.offers(PROXMOX); + // The same index carries Backup Server, and offering it under "Proxmox VE" would + // be a lie the catalogue told. + assert!( + offers.iter().all(|o| o.name.starts_with("proxmox-ve_")), + "{offers:?}" + ); + assert_eq!(offers.len(), 3); + + // Ubuntu's `.wsl` is in the index and is not something this server can serve. + let ubuntu = source("ubuntu").expect("shipped"); + let offers = ubuntu.offers(UBUNTU); + assert!( + offers.iter().all(|o| o.name.ends_with(".iso")), + "{offers:?}" + ); + assert_eq!(offers.len(), 2); + } + + #[test] + fn the_newest_is_offered_first_and_ten_beats_nine() { + let proxmox = source("proxmox-ve").expect("shipped"); + assert_eq!(proxmox.offers(PROXMOX)[0].name, "proxmox-ve_9.2-1.iso"); + + // **The reason sorting is not lexicographic.** 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 the one it asked for. + let text = "\ +1111111111111111111111111111111111111111111111111111111111111111 x_9.9-1.iso +2222222222222222222222222222222222222222222222222222222222222222 x_9.10-1.iso +"; + let s = Source { + id: "x", + label: "x", + index: "https://example.invalid/d/SHA256SUMS", + keep: "", + about: "", + }; + assert_eq!(s.offers(text)[0].name, "x_9.10-1.iso"); + } + + #[test] + fn an_images_url_is_the_indexs_own_directory() { + // Not a second field, so the two cannot drift apart — which is the whole reason + // for reading the vendor's index in the first place. + let proxmox = source("proxmox-ve").expect("shipped"); + assert_eq!(proxmox.base(), "https://enterprise.proxmox.com/iso/"); + assert_eq!( + proxmox.offers(PROXMOX)[0].url, + "https://enterprise.proxmox.com/iso/proxmox-ve_9.2-1.iso" + ); + } + + #[test] + fn every_shipped_source_is_usable_as_written() { + // Cheap, and it catches the paste error that a network test would blame on the + // network. The URLs themselves were fetched by hand before being written down; + // this asserts the shape they have to keep. + for s in SOURCES { + assert!(!s.id.is_empty() && !s.label.is_empty(), "{}", s.id); + assert!( + s.index.starts_with("https://"), + "{} must be https — the digest is the point", + s.id + ); + assert!( + s.base().ends_with('/') && s.base().len() < s.index.len(), + "{} has no directory to hang images off", + s.id + ); + assert!(source(s.id).is_some(), "{} is not findable", s.id); + } + // Ids are what `media add --from` takes, so a duplicate would silently shadow. + let mut ids: Vec<&str> = SOURCES.iter().map(|s| s.id).collect(); + ids.sort_unstable(); + let before = ids.len(); + ids.dedup(); + assert_eq!(ids.len(), before, "duplicate source id"); + } +} diff --git a/src/boot/stanza.rs b/src/boot/stanza.rs new file mode 100644 index 0000000..d8af03d --- /dev/null +++ b/src/boot/stanza.rs @@ -0,0 +1,342 @@ +//! What each installer family needs on the wire, in one place. +//! +//! **Proxmox is the founding case and the odd one out.** Every other family takes a +//! `GET` plus kernel arguments; Proxmox alone wants the answer's *location* carried +//! inside the image, and then POSTs a body to it. A design that only works for the POST +//! path would be wrong by default, so the table below states each family's needs +//! side by side rather than generalising from the one we met first. +//! +//! The exact spellings are per-family, versioned, and precisely the sort of thing that +//! gets half-remembered. They live here, they are tested per family, and they are +//! nowhere else in the codebase. +//! +//! Two properties of iPXE's own parser shape what may be written here, and both were +//! read out of its source rather than remembered: +//! +//! - **`;` separates commands only as a whole whitespace-delimited token.** +//! `split_command` in `core/exec.c` splits on whitespace and the separator is +//! recognised by `strcmp(token, ";")`, so `ds=nocloud-net;s=http://…` is one argument +//! and needs no escaping. Writing `foo ; bar` would be two commands. +//! - **A trailing `\` continues a line**, which is how a kernel command line longer +//! than a terminal stays readable. +//! +//! What this module produces is an ordinary `.ipxe` **answer document**. It is printed, +//! never installed: saved into the answers directory it goes through the existing +//! selection, layering and templating unchanged. The server does not become clever +//! about booting — it gains a generator, and the composition engine it already has does +//! the rest. + +use super::catalog::Entry; +use super::probe::Family; + +/// The two listeners, as a client must be able to reach them. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Endpoints { + /// Where images, kernels and initrds come from. + pub media: String, + /// Where answers come from. + pub answer: String, +} + +/// Write the `.ipxe` answer document that boots one catalogue entry. +pub fn ipxe(entry: &Entry, endpoints: &Endpoints) -> Result { + let family = entry.family(); + if !entry.bootable() { + return Err(format!( + "{}: no kernel and initrd were found in it{}. It can still be served whole — \ + `sanboot` it, or write it to a stick — but there is no stanza to generate.", + entry.id, + match family { + Family::Unknown => ", and no probe row claims it", + _ => "", + } + )); + } + + let media = endpoints.media.trim_end_matches('/'); + let answer = endpoints.answer.trim_end_matches('/'); + let id = &entry.id; + let kernel = format!("{media}/{id}/kernel"); + let initrd = format!("{media}/{id}/initrd"); + let image = format!("{media}/{id}/iso"); + + // `{{ mac }}` is filled by the answer engine's own templating, from the facts the + // request carries. A missing fact is an error there, never an empty string — which + // is what stops a machine installing itself against a truncated URL. + let mut out = String::from("#!ipxe\n"); + out.push_str(&format!( + "# {} — generated by `rescriptum media ipxe {id}`.\n\ + # An ordinary answer document: selection, layering and templating all apply.\n", + entry.describe() + )); + + match family { + // Upstream's own output, character for character. `--pxe-loader ipxe` writes + // `kernel vmlinuz ramdisk_size=16777216 rw quiet initrd=initrd.img` plus the + // chosen option's parameters, then `initrd initrd.img`, `initrd + // proxmox.iso`, `boot`. **`proxmox-start-auto-installer` is the parameter that + // selects the automated path**; without it the machine boots the interactive + // installer and waits for a human who is not coming. + Family::Proxmox => { + out.push_str(&format!( + "kernel {kernel} ramdisk_size=16777216 rw quiet initrd=initrd.img \\\n\ + \x20 splash=silent proxmox-start-auto-installer\n" + )); + // **No second argument, and that is the whole of it.** In iPXE a name turns an + // initrd into a *file inside* the initramfs — it gets a cpio header — while one + // without a name is appended raw and therefore *is* the initramfs. Naming this + // one produced an initramfs containing `/initrd.img` and `/proxmox.iso` and no + // `/init`, so the kernel unpacked it happily, found nothing to run, fell + // through to mounting a root filesystem and panicked with + // `VFS: Unable to mount root fs on unknown-block(0,0)`. + // + // Measured on a real machine, and the line that gave it away was + // `Freeing initrd memory: 1720768K` — 1.7 GB unpacked without a single + // complaint, which ruled out the decompression the first three guesses were + // about. The comment that used to stand here said the name was needed "so it + // matches the `initrd=` above"; `initrd=` is a bootloader directive that + // pxelinux consumes, not something the kernel resolves against a filename, and + // upstream's own example passes the initrd with no name at all. + out.push_str(&format!("initrd {initrd}\n")); + // The ISO does take a name, and for the opposite reason: it has to *be* a + // file in the initramfs, because the installer opens it by that name. So the + // two lines differ deliberately — one raw, one named — and that asymmetry is + // the thing to preserve. + out.push_str(&format!("initrd {image} proxmox.iso\n")); + } + Family::Debian => { + out.push_str(&format!( + "kernel {kernel} auto=true priority=critical \\\n\ + \x20 preseed/url={answer}/debian?mac={{{{ mac }}}}\n" + )); + out.push_str(&format!("initrd {initrd}\n")); + } + // The one shape that is not a single URL: cloud-init's NoCloud datasource + // fetches `user-data` *and* `meta-data` from a prefix. `facts.rs` labels a + // request's `path`, `file` and `segment`, so one answer set can tell the two + // apart with a selector — which is also why `seed` is not an endpoint alias. + Family::Ubuntu => { + out.push_str(&format!( + "kernel {kernel} autoinstall \\\n\ + \x20 ds=nocloud-net;s={answer}/ubuntu/{{{{ mac }}}}/ \\\n\ + \x20 url={image} initrd=initrd\n" + )); + out.push_str(&format!("initrd {initrd} initrd\n")); + } + Family::Rhel => { + out.push_str(&format!( + "kernel {kernel} inst.ks={answer}/rhel?mac={{{{ mac }}}} \\\n\ + \x20 inst.stage2={image}\n" + )); + out.push_str(&format!("initrd {initrd}\n")); + } + Family::Suse => { + out.push_str(&format!( + "kernel {kernel} autoyast={answer}/suse?mac={{{{ mac }}}} \\\n\ + \x20 install={image}\n" + )); + out.push_str(&format!("initrd {initrd}\n")); + } + Family::CoreOs => { + // The live rootfs is a file inside the image, served by path rather than + // extracted — the same seek the kernel and initrd routes use. + out.push_str(&format!( + "kernel {kernel} ignition.config.url={answer}/coreos?mac={{{{ mac }}}} \\\n\ + \x20 coreos.live.rootfs_url={media}/{id}/file/images/pxeboot/rootfs.img\n" + )); + out.push_str(&format!("initrd {initrd}\n")); + } + Family::Unknown => unreachable!("an unplaced image is not bootable"), + } + + out.push_str("boot\n"); + Ok(out) +} + +/// How the answer this stanza points at will be reached, for `media ipxe` to print +/// beside the script and for the guide to quote. Nothing generates the answer itself: +/// that is the operator's document, and this is only where to put it. +pub fn where_the_answer_goes(family: Family) -> &'static str { + match family { + Family::Proxmox => { + "The image must carry `/auto-installer-mode.toml` naming the answer URL — \ + `proxmox-auto-install-assistant prepare-iso --fetch-from http --url …` \ + writes it. Nothing on the kernel command line carries it." + } + Family::Debian => "Save a `.preseed` answer; it is fetched from /debian.", + Family::Ubuntu => { + "Save `.yaml` answers reached as /ubuntu//user-data and \ + /ubuntu//meta-data — cloud-init fetches both, so a selector on the \ + `file` label tells them apart." + } + Family::Rhel => "Save a `.ks` answer; it is fetched from /rhel.", + Family::Suse => "Save an `.autoyast` answer; it is fetched from /suse.", + Family::CoreOs => "Save an `.ign` answer; it is fetched from /coreos.", + Family::Unknown => "Nothing here knows how this image boots.", + } +} + +#[cfg(test)] +mod tests { + use super::super::probe::{Arch, Probed}; + use super::*; + use std::path::PathBuf; + + fn entry(family: Option, kernel: Option<&str>) -> Entry { + Entry { + id: "img".to_string(), + path: PathBuf::from("/srv/media/img.iso"), + size: 1024, + digest: None, + probed: Probed { + family, + version: Some("Test Image 1.0".to_string()), + arch: Some(Arch::X86_64), + kernel: kernel.map(str::to_string), + initrd: kernel.map(|_| "/initrd".to_string()), + external: false, + zstd_initrd: false, + }, + beside: None, + prepared: None, + } + } + + fn endpoints() -> Endpoints { + Endpoints { + media: "http://192.0.2.10:8001".to_string(), + answer: "http://192.0.2.10:8000".to_string(), + } + } + + fn render(family: Family) -> String { + ipxe(&entry(Some(family), Some("/kernel")), &endpoints()).expect("renders") + } + + /// **A stanza proven for one family claims six.** Each is asserted on the argument + /// that actually starts its unattended install, per the plan's own rule that the + /// founding case must not become the template. + #[test] + fn every_family_gets_the_argument_that_starts_its_install() { + let cases: &[(Family, &str)] = &[ + // Not a URL: the parameter that selects the automated path. Without it a + // Proxmox machine boots the interactive installer and waits forever. + (Family::Proxmox, "proxmox-start-auto-installer"), + (Family::Debian, "preseed/url=http://192.0.2.10:8000/debian"), + ( + Family::Ubuntu, + "ds=nocloud-net;s=http://192.0.2.10:8000/ubuntu/", + ), + (Family::Rhel, "inst.ks=http://192.0.2.10:8000/rhel"), + (Family::Suse, "autoyast=http://192.0.2.10:8000/suse"), + ( + Family::CoreOs, + "ignition.config.url=http://192.0.2.10:8000/coreos", + ), + ]; + for (family, needle) in cases { + let script = render(*family); + assert!(script.contains(needle), "{}: {script}", family.label()); + } + } + + #[test] + fn every_family_loads_a_kernel_an_initrd_and_boots() { + for family in [ + Family::Proxmox, + Family::Debian, + Family::Ubuntu, + Family::Rhel, + Family::Suse, + Family::CoreOs, + ] { + let script = render(family); + assert!(script.starts_with("#!ipxe\n"), "{}", family.label()); + assert!( + script.contains("kernel http://192.0.2.10:8001/img/kernel"), + "{}", + family.label() + ); + assert!( + script.contains("initrd http://192.0.2.10:8001/img/initrd"), + "{}", + family.label() + ); + assert!(script.ends_with("boot\n"), "{}", family.label()); + } + } + + #[test] + fn the_real_initrd_is_unnamed_and_the_iso_is_named() { + // **The asymmetry is the whole thing, and getting it wrong cost a real machine.** + // + // In iPXE a name turns an initrd into a *file inside* the initramfs — it gets a + // cpio header — while one without a name is appended raw and therefore *is* the + // initramfs. Naming the real initrd produced an initramfs holding `/initrd.img` + // and `/proxmox.iso` and no `/init`: the kernel unpacked 1.7 GB without a + // complaint, found nothing to run, fell through to mounting a root filesystem and + // panicked with `VFS: Unable to mount root fs on unknown-block(0,0)`. + // + // The ISO takes a name for exactly the opposite reason: the installer opens it by + // that name, so it has to be a file. + let script = render(Family::Proxmox); + assert!(script.contains("ramdisk_size=16777216 rw quiet initrd=initrd.img")); + assert!(script.contains("splash=silent proxmox-start-auto-installer")); + assert!( + script.contains("initrd http://192.0.2.10:8001/img/initrd\n"), + "the real initrd must carry no name, or it becomes a file and not the \ + initramfs: {script}" + ); + assert!(script.contains("initrd http://192.0.2.10:8001/img/iso proxmox.iso")); + // And nothing on the command line names the answer: it rides inside the image. + assert!(!script.contains("8000"), "{script}"); + } + + #[test] + fn a_semicolon_inside_an_argument_is_left_alone() { + // iPXE's `split_command` splits on whitespace and recognises the separator by + // `strcmp(token, ";")`, so `ds=nocloud-net;s=…` is one argument. Escaping it + // would corrupt the value cloud-init receives; splitting it would run `s=…` as + // a command. Neither happens, and this pins that. + let script = render(Family::Ubuntu); + assert!(script.contains("ds=nocloud-net;s="), "{script}"); + assert!(!script.contains("\\;"), "no escaping: {script}"); + for line in script.lines() { + assert!( + !line.split_whitespace().any(|token| token == ";"), + "a bare `;` token would split this line into two commands: {line}" + ); + } + } + + #[test] + fn the_templated_mac_survives_into_the_answer_url() { + // The document is a template the answer engine fills per request, which is what + // lets one stanza cover a rack. A missing fact is an error there, so a truncated + // URL never reaches a machine. + let script = render(Family::Debian); + assert!(script.contains("?mac={{ mac }}"), "{script}"); + } + + #[test] + fn an_image_no_probe_placed_is_refused_with_a_reason() { + // Refusing is a complete answer: the image is still servable whole. + let e = ipxe(&entry(None, None), &endpoints()).expect_err("must refuse"); + assert!(e.contains("no probe row claims it"), "{e}"); + assert!(e.contains("sanboot"), "{e}"); + } + + #[test] + fn a_trailing_slash_on_an_endpoint_does_not_double_up() { + let script = ipxe( + &entry(Some(Family::Rhel), Some("/kernel")), + &Endpoints { + media: "http://192.0.2.10:8001/".to_string(), + answer: "http://192.0.2.10:8000/".to_string(), + }, + ) + .expect("renders"); + assert!(!script.contains("//img"), "{script}"); + assert!(!script.contains("8000//"), "{script}"); + } +} diff --git a/src/boot/tftp.rs b/src/boot/tftp.rs new file mode 100644 index 0000000..6237f50 --- /dev/null +++ b/src/boot/tftp.rs @@ -0,0 +1,919 @@ +//! TFTP, read-only, for exactly one job: handing over the loader. +//! +//! **It is core rather than optional.** An appliance that needs somebody else's TFTP +//! server is not an appliance — that was the reasoning the first draft of the plan got +//! backwards, and correcting it is what makes the whole chain start from one binary. +//! +//! ## One file, and then HTTP +//! +//! At `blksize=1468` with lockstep acknowledgement, TFTP moves one block per +//! round-trip — call it 1.4 MB/s at a millisecond of RTT. The loader is about a +//! megabyte, so two seconds. A 1.5 GB image would be **the better part of twenty +//! minutes**, against fifteen seconds over HTTP on the same wire. Hence the rule, +//! written here and in the guide: +//! +//! > **TFTP hands over the loader. Everything after that is HTTP.** +//! +//! Nothing here will serve an image, and the root is the boot-asset directory rather +//! than the media directory precisely so that it cannot. +//! +//! ## What real ROMs need +//! +//! RFC 1350 alone is not enough. The options are what decide whether firmware actually +//! works: `blksize` (RFC 2348) because 512-byte blocks make a megabyte take 2,000 +//! round-trips, `tsize` (RFC 2349) because a number of ROMs will not proceed without +//! being told the size up front, and `timeout` (RFC 2349). **`windowsize` (RFC 7440) is +//! declined** — see the option table for the nine minutes that cost — +//! offered only when asked, because some ROMs get it wrong. +//! +//! ## UDP is forgeable, so this is defensive by construction +//! +//! One connected socket per transfer, so a transfer only ever hears from its own peer; +//! no reply to a broadcast or multicast destination, which is amplification hygiene +//! rather than politeness; a cap on concurrent transfers and a cap per peer; and a +//! duplicate acknowledgement is **ignored, never answered** — that is the Sorcerer's +//! Apprentice bug, and answering doubles the traffic for as long as it lasts. + +use crate::config::Config; +use crate::log; +use std::io; +use std::net::{IpAddr, SocketAddr}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; +use tokio::net::UdpSocket; +use tokio::sync::Semaphore; + +const OP_RRQ: u16 = 1; +const OP_WRQ: u16 = 2; +const OP_DATA: u16 = 3; +const OP_ACK: u16 = 4; +const OP_ERROR: u16 = 5; +const OP_OACK: u16 = 6; + +/// Error codes worth sending. The rest of RFC 1350's list describes writes. +const ERR_NOT_FOUND: u16 = 1; +const ERR_ACCESS: u16 = 2; +const ERR_ILLEGAL: u16 = 4; +const ERR_NO_USER: u16 = 7; + +/// RFC 1350's block size, and the floor every implementation understands. +pub const DEFAULT_BLOCK: usize = 512; +/// **Clamped so a data packet still fits one Ethernet frame.** 1500 minus 20 bytes of +/// IP and 8 of UDP leaves 1472; minus TFTP's own 4-byte header, 1468. Larger merely +/// invites fragmentation, and a fragmented TFTP transfer to a PXE ROM is a coin toss. +pub const MAX_BLOCK: usize = 1468; +/// A request larger than this is not a request. +const MAX_REQUEST: usize = 1024; +/// How long to wait for an acknowledgement before sending the block again. +const RETRY: Duration = Duration::from_millis(700); +/// Give up on a peer that has stopped acknowledging. +const MAX_RETRIES: u32 = 6; +/// A transfer that has run this long is not a loader fetch any more. +const MAX_TRANSFER: Duration = Duration::from_secs(60); +/// In-flight transfers, in total and per peer. +/// +/// **The per-peer figure is not a hostility threshold, and treating it as one is a bug +/// that only shows up at a reboot.** A PXE ROM that does not hear an answer quickly +/// *retransmits its read request* — a sleeping disk on a NAS is enough to cause it — +/// and each retransmission is a fresh transfer from the same address. Set this to the +/// three or four a suspicious mind suggests and a slow first fetch locks the machine +/// out of the boot server it was retrying to reach. +const MAX_TRANSFERS: usize = 64; +const MAX_PER_PEER: usize = 8; + +pub struct Tftp { + /// The boot-asset directory, canonicalised at start. Every request resolves inside + /// it or is refused. + root: PathBuf, + cfg: Arc, +} + +impl Tftp { + /// Canonicalise the root now, so the containment check below compares two resolved + /// paths rather than two hopeful strings. + pub fn new(root: &Path, cfg: Arc) -> io::Result { + let root = root.canonicalize().map_err(|e| { + io::Error::new( + e.kind(), + format!("cannot use {} as the TFTP root: {e}", root.display()), + ) + })?; + if !root.is_dir() { + return Err(io::Error::new( + io::ErrorKind::NotADirectory, + format!("{} is not a directory", root.display()), + )); + } + Ok(Tftp { root, cfg }) + } + + pub fn root(&self) -> &Path { + &self.root + } +} + +/// Accept requests forever. Each one is answered from its **own ephemeral socket**, +/// connected to the peer — which is both what RFC 1350 requires and what stops a +/// transfer hearing from anybody else. +pub async fn serve(socket: UdpSocket, tftp: Arc) { + let permits = Arc::new(Semaphore::new(MAX_TRANSFERS)); + let per_peer: Arc>> = + Arc::new(Default::default()); + let mut buffer = vec![0u8; MAX_REQUEST]; + + loop { + let (n, peer) = match socket.recv_from(&mut buffer).await { + Ok(pair) => pair, + Err(e) => { + log::server(&format!("tftp: recv failed: {e}")); + continue; + } + }; + + // **Never answer a broadcast or multicast destination.** Amplification hygiene: + // a forged request naming a broadcast address would have us shout a megabyte at + // a whole segment. Nothing legitimate asks for a loader that way. + if is_broadcastish(peer.ip()) { + log::request(&peer.to_string(), 0, "tftp: ignored, not a unicast peer"); + continue; + } + if !allowed(&tftp.cfg, peer) { + log::request( + &peer.to_string(), + 403, + "tftp: refused, outside the allowlist", + ); + continue; + } + + let request = buffer[..n].to_vec(); + let Ok(permit) = Arc::clone(&permits).try_acquire_owned() else { + log::request(&peer.to_string(), 503, "tftp: at max transfers"); + continue; + }; + // A per-peer cap on top of the global one, so one machine cannot use the whole + // budget — the global cap alone protects the server, not the other clients. + // + // **Only a read request takes a slot.** Anything else — junk, a write request, + // a mode we refuse — is answered with a fixed-size error and costs nothing, so + // a burst of malformed packets cannot shut a peer out of the transfers it is + // entitled to. Counting them was a real defect: four stray datagrams from an + // address locked that address out, and everything in a lab arrives from one. + let is_read = request.len() >= 2 && u16::from_be_bytes([request[0], request[1]]) == OP_RRQ; + if is_read { + let mut counts = per_peer.lock().unwrap_or_else(|e| e.into_inner()); + let count = counts.entry(peer.ip()).or_insert(0); + if *count >= MAX_PER_PEER { + log::request( + &peer.to_string(), + 503, + "tftp: at max transfers for this peer", + ); + continue; + } + *count += 1; + } + + let tftp = Arc::clone(&tftp); + let per_peer = Arc::clone(&per_peer); + tokio::spawn(async move { + let _permit = permit; + // A transfer that outlives this deadline is not a loader fetch any more. + let _ = tokio::time::timeout(MAX_TRANSFER, transfer(&request, peer, &tftp)).await; + if is_read { + let mut counts = per_peer.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(count) = counts.get_mut(&peer.ip()) { + *count = count.saturating_sub(1); + if *count == 0 { + // Bounded by construction: an idle peer leaves no entry behind, + // so the map cannot itself be turned into a memory leak. + counts.remove(&peer.ip()); + } + } + } + }); + } +} + +async fn transfer(request: &[u8], peer: SocketAddr, tftp: &Tftp) { + // The reply socket is *connected*, and on a port of its own: RFC 1350 wants the data + // to come from a fresh TID, and connecting means this transfer only ever hears its + // peer. + // + // **A fresh port is what a firewall does not expect.** The request arrives on 69, the + // answer leaves from somewhere else, and the acknowledgement comes back to *that* — + // which no rule allows, so it is dropped and the transfer dies at the handshake with + // the client looking like it lost interest. Every serious TFTP server therefore lets + // the range be pinned so it can be opened; ours did not, and a NAS firewall is + // exactly where that bites. + let Some(socket) = data_socket(peer, tftp).await else { + log::request( + &peer.to_string(), + 500, + "tftp: no data port free — RESCRIPTUM_TFTP_PORT_RANGE is too small for the \ + transfers in flight", + ); + return; + }; + // **Not `connect`ed, and that is a fix rather than a relaxation.** Connecting makes + // the kernel accept datagrams only from the exact address *and port* the request came + // from — so a client that acknowledges from a different source port has its packets + // dropped before this code can see them, and the transfer dies looking precisely like + // a firewall ate them. RFC 1350 says a client keeps its TID and most do; the ones + // that do not are UEFI ROMs, which is exactly the population being served here. + // + // So: answer whoever asked, accept from the same *address*, and say so when the port + // moves. A blind spot that cannot be told apart from a network fault is worse than a + // rule that bends. + let mut reply_to = peer; + + let parsed = match parse_request(request) { + Ok(parsed) => parsed, + Err(refusal) => { + let _ = socket + .send_to(&error_packet(refusal.code, &refusal.message), reply_to) + .await; + log::request(&peer.to_string(), 0, &format!("tftp: {}", refusal.message)); + return; + } + }; + + let path = match resolve(&tftp.root, &parsed.filename) { + Some(path) => path, + None => { + let _ = socket + .send_to(&error_packet(ERR_NOT_FOUND, "no such file"), reply_to) + .await; + log::request( + &peer.to_string(), + 404, + &format!("tftp: {} not found", parsed.filename), + ); + return; + } + }; + + let contents = match std::fs::read(&path) { + Ok(bytes) => bytes, + Err(e) => { + let _ = socket + .send_to(&error_packet(ERR_NOT_FOUND, "cannot read"), reply_to) + .await; + log::request( + &peer.to_string(), + 500, + &format!("tftp: {} cannot be read: {e}", path.display()), + ); + return; + } + }; + + // Options are negotiated in one OACK, acknowledged with block 0, before any data. + let mut block_size = DEFAULT_BLOCK; + let mut asked_block = DEFAULT_BLOCK; + let mut accepted: Vec<(String, String)> = Vec::new(); + for (name, value) in &parsed.options { + match name.as_str() { + "blksize" => { + if let Ok(asked) = value.parse::() { + asked_block = asked; + block_size = asked.clamp(DEFAULT_BLOCK, tftp.cfg.tftp_blksize()); + accepted.push(("blksize".to_string(), block_size.to_string())); + } + } + // A number of ROMs will not proceed without being told the size up front. + "tsize" => accepted.push(("tsize".to_string(), contents.len().to_string())), + "timeout" => { + if let Ok(seconds) = value.parse::() + && (1..=255).contains(&seconds) + { + accepted.push(("timeout".to_string(), seconds.to_string())); + } + } + // **Declined, and that is the fix rather than a limitation.** This used to + // echo the option back — agreeing to a window — while the transfer loop sent + // one block and waited for its acknowledgement. A client told `windowsize 4` + // waits for four blocks before acknowledging anything, so both sides waited, + // and only the 700 ms retransmit broke the deadlock. Every block then cost a + // resend and 700 ms: a 1.1 MB loader takes nine minutes that way, and the + // firmware gives up long before. Measured on the wire, from a capture on the + // NAS, after several wrong guesses about firewalls and block sizes. + // + // RFC 2347 is explicit that an option the server leaves out of the OACK is to + // be treated as never requested, so declining costs a client nothing but an + // acknowledgement per block — which on a LAN is a round trip of under a + // millisecond. **Agreeing to something not implemented is what cost nine + // minutes.** Implementing RFC 7440 properly is worth doing and is not this + // change: correctness first, and a window is an optimisation. + "windowsize" => {} + // An option we do not implement is left out of the OACK, which is exactly + // how RFC 2347 says to decline one. + _ => {} + } + } + + // **A request that arrived must always leave a trace, whatever happens next.** + // Moving the only line to the end of the transfer meant a request that never got + // going logged nothing at all — and that is precisely the case somebody is trying to + // diagnose. It cost the maintainer an evening: the machine said it was downloading, + // the server said nothing, and the two were both telling the truth. + // + // Two lines, then. This one says a machine asked; the one after the transfer says + // 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. + log::request( + &peer.to_string(), + 200, + &format!( + "tftp: {} requested, {} bytes, blksize={block_size}", + parsed.filename, + contents.len() + ), + ); + + if !accepted.is_empty() { + let mut oack = vec![0, OP_OACK as u8]; + for (name, value) in &accepted { + oack.extend_from_slice(name.as_bytes()); + oack.push(0); + oack.extend_from_slice(value.as_bytes()); + oack.push(0); + } + if socket.send_to(&oack, reply_to).await.is_err() { + log::request( + &peer.to_string(), + 500, + &format!( + "tftp: {} FAILED — could not send the option reply", + parsed.filename + ), + ); + return; + } + // The client acknowledges the option set with block 0 before data starts. + // + // **A client is allowed to hate the answer.** RFC 2348 lets a server reply with a + // *smaller* block size than was asked for, and the client is supposed to accept + // it — but a PXE ROM that wanted 1468 and is offered 512 often just stops, and + // this used to `return` without a word. Which is how capping the block size to + // help one network broke a machine that had been booting fine, invisibly. + if !matches!( + wait_for_ack(&socket, 0, &oack, &mut reply_to, &peer).await, + Ack::Ok + ) { + log::request( + &peer.to_string(), + 500, + &format!( + "tftp: {} FAILED at the option handshake — {}", + parsed.filename, + // **Name the cap only when the cap did something.** The first version + // named it either way, and printed "asked for 1468 and would not take + // 1468" — which sent both the maintainer and me after a setting that + // was not involved, while the real cause went unlooked-at for an hour. + // A message that suggests a cause it has already ruled out is worse + // than one that suggests none. + if block_size != asked_block { + format!( + "the client asked for blksize={asked_block} and would not take \ + {block_size}. RESCRIPTUM_TFTP_BLKSIZE is what caps it; unset \ + it to agree to what the client wants" + ) + } else { + // **Name the options that were in the reply.** A ROM that refuses + // one of them refuses the whole OACK, and which one it was is + // otherwise only visible in a packet capture — the difference + // between a fifteen-second stumble somebody can read and one they + // have to tcpdump. Seen on a Lenovo that rejects a reply carrying + // `tsize` and then retries without asking for it. + format!( + "it never acknowledged the options it asked for itself. The \ + reply offered: {}. A client that refuses one option refuses \ + the whole reply and usually retries without it — if a second \ + attempt succeeds, that is what happened. Otherwise: **the \ + reply comes from a fresh port**, which is how TFTP works, so \ + this is also what a firewall or a NAT between the two looks \ + like — the request arrives, the answer goes out, and the \ + acknowledgement never comes back", + accepted + .iter() + .map(|(k, v)| format!("{k}={v}")) + .collect::>() + .join(" ") + ) + } + ), + ); + return; + } + } + + // **This used to be logged here, before a single byte went out** — so a line reading + // `ipxe-x86_64.efi 1164800 bytes` meant "about to send", and every stalled transfer + // in the world looked exactly like a completed one. It cost a real afternoon on a + // real machine: the loader was fetched, nothing happened, and the log said success. + // An intention is not an outcome, and this is a boot path where the difference is + // the whole diagnosis. + let total = contents.len(); + let mut block: u16 = 1; + let mut sent = 0usize; + let outcome: Option<(&str, bool)> = loop { + let end = (sent + block_size).min(total); + let chunk = &contents[sent..end]; + // **A short block is what ends a transfer**, and "short" includes empty. A file + // whose length is an exact multiple of the block size therefore ends with a + // data packet carrying nothing — leave it out and the client waits forever for + // a final block that never comes. An empty file is the same case at zero. + let last = chunk.len() < block_size; + + let mut packet = Vec::with_capacity(4 + chunk.len()); + packet.extend_from_slice(&OP_DATA.to_be_bytes()); + packet.extend_from_slice(&block.to_be_bytes()); + packet.extend_from_slice(chunk); + + if socket.send_to(&packet, reply_to).await.is_err() { + break Some(("the socket went away", true)); + } + match wait_for_ack(&socket, block, &packet, &mut reply_to, &peer).await { + Ack::Ok => {} + // **A client that says ERROR meant to stop.** `boot check`'s own probe does + // exactly this after one block, and so does a loader the firmware cancelled. + // Calling that a failure fills the log with alarms about transfers nobody + // wanted finished — which is worse than useless in the one file an operator + // reads to find a real failure. + Ack::Cancelled => break Some(("cancelled by the client", false)), + Ack::Silent => break Some(("the client stopped acknowledging", true)), + } + + sent = end; + if last { + break None; + } + // Block numbers are 16 bits and wrap. A loader will never reach 65535 at 1468 + // bytes a block; be correct anyway, because "never" is how this kind of bug + // gets in. + block = block.wrapping_add(1); + }; + + match outcome { + None => log::request( + &peer.to_string(), + 200, + &format!( + "tftp: sent {} {total} bytes blksize={block_size}", + parsed.filename + ), + ), + // A cancel is ordinary and gets 200; a client that vanished gets 500, so + // `RESCRIPTUM_LOG=problems` keeps the one that means a machine did not boot. + Some((why, is_failure)) => log::request( + &peer.to_string(), + if is_failure { 500 } else { 200 }, + &format!( + "tftp: {} {} after {sent} of {total} bytes at blksize={block_size} — {why}{}", + parsed.filename, + if is_failure { "FAILED" } else { "stopped" }, + if is_failure { + ". If it stalls at the first block, the block size is too large for \ + this path: RESCRIPTUM_TFTP_BLKSIZE caps it" + } else { + "" + } + ), + ), + } +} + +/// What ended the wait for an acknowledgement. +enum Ack { + Ok, + /// The client sent an ERROR: it meant to stop, and that is not a failure. + Cancelled, + /// It stopped answering, which is what a path that cannot carry the block looks like. + Silent, +} + +/// The socket a transfer answers from. +/// +/// Unpinned it is whatever the kernel hands out, which is right on a host with no +/// firewall in the way and unusable behind one. A configured range is tried in order and +/// the first free port wins; the range only has to be as large as the transfers that can +/// overlap, which `MAX_TRANSFERS` already bounds at 64. +async fn data_socket(peer: SocketAddr, tftp: &Tftp) -> Option { + let host = if peer.is_ipv4() { "0.0.0.0" } else { "[::]" }; + match tftp.cfg.tftp_port_range() { + None => UdpSocket::bind(format!("{host}:0")).await.ok(), + Some((first, last)) => { + for port in first..=last { + if let Ok(socket) = UdpSocket::bind(format!("{host}:{port}")).await { + return Some(socket); + } + } + None + } + } +} + +/// Wait for the acknowledgement of `block`, resending on silence. +/// +/// **A duplicate acknowledgement — one for a block already acknowledged — is ignored, +/// never answered.** That is the Sorcerer's Apprentice bug: answering a duplicate with +/// a duplicate makes both sides echo each other and doubles the traffic for the rest of +/// the transfer. +async fn wait_for_ack( + socket: &UdpSocket, + block: u16, + resend: &[u8], + reply_to: &mut SocketAddr, + peer: &SocketAddr, +) -> Ack { + let mut buffer = [0u8; 64]; + for _ in 0..MAX_RETRIES { + match tokio::time::timeout(RETRY, socket.recv_from(&mut buffer)).await { + Ok(Ok((n, from))) if n >= 4 => { + // Same machine, whatever port it chose to answer from. A different + // address is somebody else's traffic and is ignored without comment — + // this socket is reachable from anywhere the request was. + if from.ip() != peer.ip() { + continue; + } + if from != *reply_to { + log::request( + &peer.to_string(), + 200, + &format!( + "tftp: the client moved to port {} — answering there \ + (RFC 1350 says it should keep {}; some UEFI ROMs do not)", + from.port(), + reply_to.port() + ), + ); + *reply_to = from; + } + let opcode = u16::from_be_bytes([buffer[0], buffer[1]]); + let acked = u16::from_be_bytes([buffer[2], buffer[3]]); + if opcode == OP_ERROR { + return Ack::Cancelled; + } + if opcode == OP_ACK { + if acked == block { + return Ack::Ok; + } + // An older block: a duplicate. Say nothing and keep waiting. + continue; + } + // Anything else on this socket is not part of the conversation. + continue; + } + Ok(Ok(_)) => continue, + Ok(Err(_)) => return Ack::Silent, + // Silence: the block was lost, or the acknowledgement was. Send it again. + Err(_) => { + if socket.send_to(resend, *reply_to).await.is_err() { + return Ack::Silent; + } + } + } + } + Ack::Silent +} + +struct Request { + filename: String, + options: Vec<(String, String)>, +} + +struct Refusal { + code: u16, + message: String, +} + +fn parse_request(bytes: &[u8]) -> Result { + if bytes.len() < 4 { + return Err(Refusal { + code: ERR_ILLEGAL, + message: "truncated request".to_string(), + }); + } + let opcode = u16::from_be_bytes([bytes[0], bytes[1]]); + if opcode == OP_WRQ { + // Read-only, and this is the whole enforcement. Writing a loader over UDP with + // no authentication would be a way to change what every machine boots. + return Err(Refusal { + code: ERR_ACCESS, + message: "this server is read-only".to_string(), + }); + } + if opcode != OP_RRQ { + return Err(Refusal { + code: ERR_ILLEGAL, + message: format!("opcode {opcode} is not a read request"), + }); + } + + let mut fields = bytes[2..].split(|b| *b == 0).map(|f| f.to_vec()); + let filename = fields + .next() + .and_then(|f| String::from_utf8(f).ok()) + .filter(|f| !f.is_empty()) + .ok_or_else(|| Refusal { + code: ERR_ILLEGAL, + message: "no filename".to_string(), + })?; + let mode = fields + .next() + .and_then(|f| String::from_utf8(f).ok()) + .unwrap_or_default() + .to_ascii_lowercase(); + + // **`netascii` is refused rather than mistranslated.** It rewrites line endings, and + // a loader is a binary: silently corrupting one produces a machine that fetches + // something and then does nothing anybody can explain. + if mode != "octet" { + return Err(Refusal { + code: ERR_NO_USER, + message: format!("mode {mode:?} is not octet; a loader is binary"), + }); + } + + let mut options = Vec::new(); + while let (Some(name), Some(value)) = (fields.next(), fields.next()) { + let (Ok(name), Ok(value)) = (String::from_utf8(name), String::from_utf8(value)) else { + break; + }; + if name.is_empty() { + break; + } + options.push((name.to_ascii_lowercase(), value)); + } + + Ok(Request { filename, options }) +} + +/// Resolve a requested name inside the root, or refuse. +/// +/// Two guards, and the second is the one that matters: the name is stripped of anything +/// that could climb, **and then the resolved path is checked to still be inside the +/// canonicalised root**. A symlink pointing out of the tree is caught by the second +/// check even though it passes the first. +fn resolve(root: &Path, filename: &str) -> Option { + let relative = filename.trim_start_matches(['/', '\\']); + if relative.is_empty() { + return None; + } + let mut path = root.to_path_buf(); + for segment in relative.split(['/', '\\']) { + if segment.is_empty() || segment == "." { + continue; + } + if segment == ".." { + return None; + } + path.push(segment); + } + let resolved = path.canonicalize().ok()?; + resolved + .starts_with(root) + .then_some(resolved) + .filter(|p| p.is_file()) +} + +fn error_packet(code: u16, message: &str) -> Vec { + let mut packet = Vec::with_capacity(5 + message.len()); + packet.extend_from_slice(&OP_ERROR.to_be_bytes()); + packet.extend_from_slice(&code.to_be_bytes()); + packet.extend_from_slice(message.as_bytes()); + packet.push(0); + packet +} + +/// Ask a TFTP server on `addr` for `filename`, and say whether anything served it. +/// +/// **This exists because binding is not a health check, and the test that found that out +/// is in `tests/tftp.rs`.** A successful bind means *nothing is listening* — which is +/// exactly the degraded state, not the healthy one — and a failed bind cannot tell our +/// own running server apart from some other daemon squatting port 69. Both are +/// `AddrInUse`. The only question with a real answer is the one a booting machine asks: +/// send a read request, and see whether a loader comes back. +/// +/// Synchronous and short on purpose: this is `boot check`'s, not the server's, and a +/// command an operator runs must not hang on a silent port. One request, one wait, and +/// the first reply decides — `DATA` or `OACK` means served, an `ERROR` means a server is +/// there and this file is not, silence means nothing is. +pub fn probe(addr: &str, filename: &str, wait: Duration) -> ProbeResult { + let Ok(socket) = std::net::UdpSocket::bind("0.0.0.0:0") else { + return ProbeResult::Silent; + }; + if socket.set_read_timeout(Some(wait)).is_err() { + return ProbeResult::Silent; + } + + let mut packet = Vec::new(); + packet.extend_from_slice(&OP_RRQ.to_be_bytes()); + packet.extend_from_slice(filename.as_bytes()); + packet.push(0); + packet.extend_from_slice(b"octet\0"); + // No options. A server that negotiates none still has to answer with plain 512-byte + // blocks, so this is the one request every TFTP server on earth understands. + if socket.send_to(&packet, addr).is_err() { + return ProbeResult::Silent; + } + + let mut buffer = [0u8; 1024]; + let Ok((n, from)) = socket.recv_from(&mut buffer) else { + return ProbeResult::Silent; + }; + if n < 2 { + return ProbeResult::Silent; + } + let opcode = u16::from_be_bytes([buffer[0], buffer[1]]); + + // **Say goodbye rather than walking away.** One block is all this needs, but a client + // that simply stops answering is indistinguishable from one whose network broke — so + // the server retried for four seconds and then logged a failed transfer. Which meant + // running `boot check` wrote a scary FAILED line into the very log an operator was + // reading to find a real one. An ERROR packet is how TFTP says "stop", and RFC 1350 + // code 0 is the one that carries a message rather than a claim about what went wrong. + if matches!(opcode, OP_DATA | OP_OACK) { + let _ = socket.send_to(&error_packet(0, "probe complete"), from); + } + + match opcode { + OP_DATA | OP_OACK => ProbeResult::Served, + OP_ERROR => ProbeResult::Refused, + _ => ProbeResult::Silent, + } +} + +/// What [`probe`] found. Three outcomes because they mean three different things to an +/// operator: a loader was handed over, a TFTP server is there but does not have that +/// file, or nothing answered at all. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProbeResult { + Served, + Refused, + Silent, +} + +/// Whether an address is one nothing legitimate asks for a loader from. +fn is_broadcastish(ip: IpAddr) -> bool { + match ip { + IpAddr::V4(v4) => v4.is_broadcast() || v4.is_multicast() || v4.is_unspecified(), + IpAddr::V6(v6) => v6.is_multicast() || v6.is_unspecified(), + } +} + +fn allowed(cfg: &Config, peer: SocketAddr) -> bool { + let Some(list) = &cfg.boot_allow else { + return true; + }; + list.split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .any(|cidr| super::media::in_cidr(peer.ip(), cidr)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rrq(filename: &str, mode: &str, options: &[(&str, &str)]) -> Vec { + let mut packet = vec![0, OP_RRQ as u8]; + packet.extend_from_slice(filename.as_bytes()); + packet.push(0); + packet.extend_from_slice(mode.as_bytes()); + packet.push(0); + for (name, value) in options { + packet.extend_from_slice(name.as_bytes()); + packet.push(0); + packet.extend_from_slice(value.as_bytes()); + packet.push(0); + } + packet + } + + #[test] + fn a_read_request_is_parsed_with_its_options() { + let parsed = parse_request(&rrq( + "ipxe-undionly.kpxe", + "octet", + &[("blksize", "1468"), ("tsize", "0")], + )) + .unwrap_or_else(|e| panic!("{}", e.message)); + assert_eq!(parsed.filename, "ipxe-undionly.kpxe"); + assert_eq!( + parsed.options, + vec![ + ("blksize".to_string(), "1468".to_string()), + ("tsize".to_string(), "0".to_string()) + ] + ); + } + + #[test] + fn a_write_request_is_refused_as_an_access_violation() { + // Read-only is the whole posture. Writing a loader over unauthenticated UDP + // would be a way to change what every machine on the segment boots. + let e = parse_request( + &rrq("evil.kpxe", "octet", &[]) + .iter() + .enumerate() + .map(|(i, b)| if i == 1 { OP_WRQ as u8 } else { *b }) + .collect::>(), + ) + .err() + .expect("must refuse"); + assert_eq!(e.code, ERR_ACCESS); + assert!(e.message.contains("read-only"), "{}", e.message); + } + + #[test] + fn netascii_is_refused_rather_than_mistranslated() { + // It rewrites line endings. Silently corrupting a loader produces a machine + // that fetches something and then does nothing anybody can explain. + let e = parse_request(&rrq("ipxe.kpxe", "netascii", &[])) + .err() + .expect("must refuse"); + assert!(e.message.contains("octet"), "{}", e.message); + } + + #[test] + fn a_truncated_or_nonsense_request_is_refused_rather_than_panicking() { + assert!(parse_request(&[]).is_err()); + assert!(parse_request(&[0, 1]).is_err()); + assert!(parse_request(&[0, 9, b'x', 0, b'o', 0]).is_err()); + // A request with no filename at all. + assert!(parse_request(&[0, 1, 0, b'o', b'c', b't', b'e', b't', 0]).is_err()); + } + + /// A root with one file in it, plus a nested directory, to resolve against. + fn root() -> PathBuf { + let dir = std::env::temp_dir().join(format!("rescriptum-tftp-{}", std::process::id())); + std::fs::create_dir_all(dir.join("nested")).expect("temp dir"); + std::fs::write(dir.join("ipxe-undionly.kpxe"), b"loader").expect("write"); + std::fs::write(dir.join("nested/deeper.efi"), b"loader").expect("write"); + dir.canonicalize().expect("canonical") + } + + #[test] + fn a_name_resolves_inside_the_root() { + let root = root(); + assert!(resolve(&root, "ipxe-undionly.kpxe").is_some()); + assert!( + resolve(&root, "/ipxe-undionly.kpxe").is_some(), + "a leading slash is fine" + ); + assert!(resolve(&root, "nested/deeper.efi").is_some()); + assert!( + resolve(&root, "nested\\deeper.efi").is_some(), + "some ROMs send backslashes" + ); + } + + #[test] + fn nothing_resolves_outside_the_root() { + let root = root(); + for name in [ + "../../../etc/passwd", + "nested/../../etc/passwd", + "/etc/passwd", + "..", + "", + ] { + assert!(resolve(&root, name).is_none(), "{name} escaped the root"); + } + // And a directory is not a file to send. + assert!(resolve(&root, "nested").is_none()); + } + + #[test] + fn a_broadcast_or_multicast_peer_is_never_answered() { + // Amplification hygiene rather than politeness: a forged request naming a + // broadcast address would have us shout a megabyte at a whole segment. + for ip in ["255.255.255.255", "224.0.0.1", "0.0.0.0"] { + assert!(is_broadcastish(ip.parse().expect("address")), "{ip}"); + } + for ip in ["10.0.0.5", "192.168.1.1"] { + assert!(!is_broadcastish(ip.parse().expect("address")), "{ip}"); + } + assert!(is_broadcastish("ff02::1".parse().expect("address"))); + assert!(!is_broadcastish("2001:db8::1".parse().expect("address"))); + } + + #[test] + fn the_block_size_is_clamped_to_one_ethernet_frame() { + // 1500 − 20 (IP) − 8 (UDP) − 4 (TFTP) = 1468. Larger merely invites + // fragmentation, and a fragmented transfer to a PXE ROM is a coin toss. + assert_eq!(MAX_BLOCK, 1468); + assert_eq!(2048usize.clamp(DEFAULT_BLOCK, MAX_BLOCK), 1468); + assert_eq!(8usize.clamp(DEFAULT_BLOCK, MAX_BLOCK), 512); + assert_eq!(1024usize.clamp(DEFAULT_BLOCK, MAX_BLOCK), 1024); + } + + #[test] + fn a_root_that_is_not_a_directory_is_refused_at_startup() { + let cfg = Arc::new(Config::from_lookup(|_| None)); + assert!(Tftp::new(Path::new("/nonexistent/rescriptum/boot"), Arc::clone(&cfg)).is_err()); + } +} diff --git a/src/cli.rs b/src/cli.rs index 00dbd82..ef0ddf0 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -23,11 +23,22 @@ USAGE: rescriptum check validate the configured store rescriptum import load a directory of TOML into the store rescriptum export write the store out as a directory of TOML + rescriptum migrate show what a flat answers directory would become + rescriptum migrate --apply move those documents into their own directories rescriptum config show the configuration, and where each value comes from rescriptum config --json the same, for a settings panel rescriptum config --value K one value, for a script (never a credential) rescriptum config set K=V edit the file RESCRIPTUM_ENV_FILE names rescriptum config unset K comment a setting back out of it + rescriptum media list the installer images this server holds + rescriptum media add FILE register one already in the media directory + rescriptum media add URL fetch one into it, then register it + rescriptum media check re-verify every recorded digest, report what drifted + rescriptum media ipxe ID print the .ipxe answer that boots one image + rescriptum boot dhcp-snippet their DHCP server's two lines, generated + rescriptum boot check are the loaders a snippet names actually here? + rescriptum boot bootstrap print the stage-two script + rescriptum boot menu print the built-in menu rescriptum --help ENVIRONMENT: @@ -43,9 +54,21 @@ ENVIRONMENT: RESCRIPTUM_LOG_FILE a path, stdout or stderr (default stderr) ADMIN API (requires RESCRIPTUM_STORE=sqlite; off unless RESCRIPTUM_ADMIN_ADDR is set): - RESCRIPTUM_ADMIN_ADDR admin listener, e.g. 127.0.0.1:8001 + RESCRIPTUM_ADMIN_ADDR admin listener, e.g. 127.0.0.1:9000 RESCRIPTUM_ADMIN_TOKEN bearer token, 16 characters or more (required) +BOOT MEDIA (off unless RESCRIPTUM_MEDIA_DIR is set): + RESCRIPTUM_MEDIA_DIR directory of installer images + RESCRIPTUM_MEDIA_ADDR media listener (default 0.0.0.0:8001) + RESCRIPTUM_MEDIA_TIMEOUT_SECS whole-transfer deadline (default 600) + RESCRIPTUM_MEDIA_MAX_CONNECTIONS concurrent transfers (default 16) + RESCRIPTUM_PUBLIC_HOST the host generated URLs name (a host, not a URL) + RESCRIPTUM_BOOT_ALLOW CIDRs allowed to fetch media (default: anyone) + RESCRIPTUM_BOOT_DIR loaders and menus, served over TFTP + RESCRIPTUM_TFTP_ADDR TFTP listener (default 0.0.0.0:69) + RESCRIPTUM_BOOT_TIMEOUT_SECS menu timeout (default 15) + RESCRIPTUM_USER / _GROUP drop to these after binding port 69 + VALIDATING A MERGED ANSWER: rescriptum render 98:fa:9b:50:d8:10 > /tmp/answer.toml proxmox-auto-install-assistant validate-answer /tmp/answer.toml @@ -205,7 +228,7 @@ pub fn check(cfg: &Config) -> ExitCode { let groups = answers.group_names().unwrap_or_default(); let machines = answers.machine_ids().unwrap_or_default(); println!( - " {} group(s), {} machine file(s)", + " {} group(s), {} machine document(s)", groups.len(), machines.len() ); @@ -335,6 +358,122 @@ pub fn import(cfg: &Config, args: &[String]) -> ExitCode { ) } +/// `migrate [--apply] []` — move a flat answers directory into the layout. +/// +/// **Shows by default and moves only when told to.** The answers directory is the thing +/// a rack installs from; a command that rearranges it the moment somebody types the name +/// to find out what it would do is not a command anybody should have to be careful with. +/// +/// Every move is a `rename` within the same directory, so the documents themselves are +/// never rewritten and a half-finished run leaves both halves readable. +pub fn migrate(cfg: &Config, args: &[String]) -> ExitCode { + let mut apply = false; + let mut dir: Option<&String> = None; + for arg in args { + match arg.as_str() { + "--apply" => apply = true, + other if other.starts_with('-') => { + eprintln!("unknown option {other:?}\nusage: rescriptum migrate [--apply] []"); + return ExitCode::FAILURE; + } + other => { + if dir.replace(arg).is_some() { + eprintln!("only one directory: {other:?}"); + return ExitCode::FAILURE; + } + } + } + } + let dir = dir + .map(std::path::PathBuf::from) + .unwrap_or_else(|| cfg.answers_dir.clone()); + + let moves = match crate::store::file::pending_moves(&dir) { + Ok(moves) => moves, + Err(e) => { + eprintln!("cannot read {}: {e}", dir.display()); + return ExitCode::FAILURE; + } + }; + + println!("migrating {}", dir.display()); + if moves.is_empty() { + println!(" nothing to move — every answer is already in a directory of its own"); + return ExitCode::SUCCESS; + } + + // Collisions first, all of them, before a single rename: finding out halfway through + // that one document cannot move is worse than finding out before anything did. + let mut blocked = Vec::new(); + for m in &moves { + if m.to.exists() { + blocked.push(m); + } + } + if !blocked.is_empty() { + for m in &blocked { + println!( + " BLOCKED {} -> {} already exists", + relative(&m.from, &dir), + relative(&m.to, &dir) + ); + } + println!( + " {} document(s) cannot move; nothing has been changed. \ + Reconcile them by hand — two documents of one format have no order between \ + them, so this cannot be decided here.", + blocked.len() + ); + return ExitCode::FAILURE; + } + + let mut failures = 0; + for m in &moves { + let from = relative(&m.from, &dir); + let to = relative(&m.to, &dir); + if !apply { + println!(" {from} -> {to}"); + continue; + } + let done = + m.to.parent() + .map(std::fs::create_dir_all) + .unwrap_or(Ok(())) + .and_then(|()| std::fs::rename(&m.from, &m.to)); + match done { + Ok(()) => println!(" {from} -> {to}"), + Err(e) => { + println!(" FAILED {from} -> {to}: {e}"); + failures += 1; + } + } + } + + if !apply { + println!( + " {} document(s) to move — nothing has been changed. \ + Re-run with --apply.", + moves.len() + ); + return ExitCode::SUCCESS; + } + if failures == 0 { + println!(" moved {} document(s) — now run `check`", moves.len()); + ExitCode::SUCCESS + } else { + println!(" {failures} failure(s)"); + ExitCode::FAILURE + } +} + +/// A path as the operator sees it, against the directory being migrated. +fn relative(path: &std::path::Path, root: &std::path::Path) -> String { + path.strip_prefix(root) + .unwrap_or(path) + .display() + .to_string() +} + /// `export ` — write the configured store out as a directory of answer files. pub fn export(cfg: &Config, args: &[String]) -> ExitCode { let [dir] = args else { @@ -411,6 +550,1084 @@ fn copy( } } +// ---- media ---------------------------------------------------------------- + +/// `media list|add|check|ipxe` — the boot-media half of the CLI. +/// +/// **Preparation and asset management are commands, never requests.** The rule that +/// keeps the server honest is that no request ever triggers work proportional to the +/// size of an image; hashing 1.5 GB happens here, once, and the result is recorded +/// beside the image so nothing ever recomputes it. +#[cfg(not(feature = "boot"))] +pub fn media(_cfg: &Config, _args: &[String]) -> ExitCode { + eprintln!("this binary was built without the `boot` feature, so it has no media commands"); + ExitCode::FAILURE +} + +#[cfg(feature = "boot")] +pub fn media(cfg: &Config, args: &[String]) -> ExitCode { + let Some(dir) = &cfg.media_dir else { + eprintln!("there is no media directory: RESCRIPTUM_MEDIA_DIR names one, and nothing does"); + return ExitCode::FAILURE; + }; + let catalog = crate::boot::catalog::Catalog::new(dir); + + match args.split_first() { + Some((cmd, rest)) if cmd == "list" && rest.is_empty() => media_list(&catalog), + Some((cmd, rest)) if cmd == "add" && !rest.is_empty() => media_add(&catalog, rest), + Some((cmd, rest)) if cmd == "check" && rest.is_empty() => media_check(&catalog), + Some((cmd, rest)) if cmd == "ipxe" && rest.len() == 1 => { + media_ipxe(cfg, &catalog, &rest[0]) + } + Some((cmd, rest)) if cmd == "prepare" && !rest.is_empty() => { + media_prepare(cfg, &catalog, rest) + } + Some((cmd, rest)) if cmd == "export" && rest.len() == 2 => { + media_export(&catalog, &rest[0], &rest[1]) + } + Some((cmd, rest)) if cmd == "sources" && rest.len() < 2 => { + media_sources(rest.first().map(String::as_str)) + } + _ => { + eprintln!( + "usage: rescriptum media list\n\ + \x20 rescriptum media sources [SOURCE]\n\ + \x20 rescriptum media add FILE|URL [--sha256 D] [--as NAME]\n\ + \x20 rescriptum media add --from SOURCE NAME\n\ + \x20 rescriptum media check\n\ + \x20 rescriptum media ipxe ID\n\ + \x20 rescriptum media prepare ID [--as NAME] [--url URL]\n\ + \x20 rescriptum media export ID FILE" + ); + ExitCode::FAILURE + } + } +} + +#[cfg(feature = "boot")] +fn media_sources(which: Option<&str>) -> ExitCode { + use crate::boot::{fetch, sources}; + + let Some(id) = which else { + println!("{:<12} {:<16} WHAT IT INSTALLS", "SOURCE", "NAME"); + for s in sources::SOURCES { + println!("{:<12} {:<16} {}", s.id, s.label, s.about); + } + println!(); + println!("`media sources ` lists what one offers, reading the vendor's own"); + println!("checksum index — so the list is current and the digests are theirs."); + println!("`media add --from ` fetches one."); + return ExitCode::SUCCESS; + }; + + let Some(source) = sources::source(id) else { + eprintln!( + "no source called {id:?}. There are: {}", + sources::SOURCES + .iter() + .map(|s| s.id) + .collect::>() + .join(", ") + ); + return ExitCode::FAILURE; + }; + + // Said before the wait, not after: on a NAS with a slow uplink this is several + // seconds of apparent nothing, and silence there reads as a hang. + eprintln!("reading {} …", source.index); + let text = match fetch::fetch_text(source.index) { + Ok(text) => text, + Err(e) => { + eprintln!("{e}"); + return ExitCode::FAILURE; + } + }; + + let offers = source.offers(&text); + if offers.is_empty() { + eprintln!( + "{} answered, but nothing in it looks like an installer image. The index may \ + have moved or changed format.", + source.index + ); + return ExitCode::FAILURE; + } + + println!("{} — {}", source.label, source.about); + for offer in &offers { + println!(" {}", offer.name); + } + println!(); + println!(" rescriptum media add --from {id} {}", offers[0].name); + ExitCode::SUCCESS +} + +#[cfg(feature = "boot")] +fn media_list(catalog: &crate::boot::catalog::Catalog) -> ExitCode { + let listing = match catalog.listing() { + Ok(listing) => listing, + Err(e) => { + eprintln!("cannot read {}: {e}", catalog.dir().display()); + return ExitCode::FAILURE; + } + }; + + // **The last column is what makes the archive visible.** A base image is what the + // vendor published, on disk, never modified; a prepared one is a few hundred bytes + // of sidecar over it. Seeing which is which is the difference between a directory + // and an archive somebody can reason about. + println!( + "{:<20} {:<8} {:<10} {:<24} {:>8} SOURCE", + "ID", "FAMILY", "ARCH", "VERSION", "SIZE" + ); + for entry in &listing.entries { + let source = match &entry.prepared { + Some(prepared) => format!("{} -> {}", prepared.source_id, prepared.url), + None => match &entry.digest { + Some(digest) => format!("base, pinned {}", &digest[..12.min(digest.len())]), + None => "base".to_string(), + }, + }; + println!( + "{:<20} {:<8} {:<10} {:<24} {:>8} {}", + entry.id, + entry.family().label(), + entry.arch().map(|a| a.label()).unwrap_or("-"), + truncate(&entry.describe(), 24), + human(entry.size), + source, + ); + } + if listing.entries.is_empty() { + println!("(nothing in {})", catalog.dir().display()); + } + for problem in &listing.problems { + eprintln!("warning: {problem}"); + } + ExitCode::SUCCESS +} + +/// `media add FILE` or `media add URL` — register an image, or fetch one and register it. +/// +/// **No base image is ever in this repository or in a release.** An ISO is somebody +/// else's artefact, it is gigabytes, and it changes on its own schedule; it belongs on +/// the deployment's disk. Two ways to get it there, and the difference is only who does +/// the download: +/// +/// - **Drop it in the media directory** — over SMB, over `scp`, from wherever it already +/// is — and register it. The native act on a NAS. +/// - **Give this a URL** and the server fetches it, through `curl` or `wget`, straight +/// into that directory. +/// +/// Either way the file lands in the directory and **is never modified afterwards**. That +/// is what makes the media directory the archive: preparing an image produces a sidecar +/// and 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 checksums. +#[cfg(feature = "boot")] +fn media_add(catalog: &crate::boot::catalog::Catalog, args: &[String]) -> ExitCode { + let mut source: Option<&String> = None; + let mut expected: Option<&String> = None; + let mut name: Option = None; + let mut unverified = false; + let mut from: Option = None; + let mut rest = args.iter(); + while let Some(arg) = rest.next() { + match arg.as_str() { + "--sha256" => match rest.next() { + Some(digest) => expected = Some(digest), + None => { + eprintln!("--sha256 wants a digest"); + return ExitCode::FAILURE; + } + }, + "--as" => match rest.next() { + Some(value) => name = Some(value.clone()), + None => { + eprintln!("--as wants a filename"); + return ExitCode::FAILURE; + } + }, + "--unverified" => unverified = true, + "--from" => match rest.next() { + Some(value) => from = Some(value.clone()), + None => { + eprintln!("--from wants a source; `media sources` lists them"); + return ExitCode::FAILURE; + } + }, + _ if source.is_none() => source = Some(arg), + other => { + eprintln!("unexpected argument {other:?}"); + return ExitCode::FAILURE; + } + } + } + // **`--from` turns a name into a URL and a digest, both read from the vendor.** It is + // not a shortcut around the digest rule — it is the strictest way to satisfy it that + // does not involve a human copying 64 characters correctly. What it is *not* is a + // signature check: the digest comes from the same host as the image, so it proves the + // download matches what that vendor is publishing right now, and nothing about + // whether the vendor is who you think. Somebody who needs that pastes a digest they + // obtained out of band, which is why --sha256 stays. + let resolved; + let source = if let Some(id) = &from { + let Some(wanted) = source else { + eprintln!("--from {id} wants an image name too; `media sources {id}` lists them"); + return ExitCode::FAILURE; + }; + let Some(src) = crate::boot::sources::source(id) else { + eprintln!( + "no source called {id:?}. There are: {}", + crate::boot::sources::SOURCES + .iter() + .map(|s| s.id) + .collect::>() + .join(", ") + ); + return ExitCode::FAILURE; + }; + if expected.is_some() { + eprintln!("--from and --sha256 disagree about where the digest comes from; pick one"); + return ExitCode::FAILURE; + } + eprintln!("reading {} …", src.index); + let text = match crate::boot::fetch::fetch_text(src.index) { + Ok(text) => text, + Err(e) => { + eprintln!("{e}"); + return ExitCode::FAILURE; + } + }; + let offers = src.offers(&text); + let Some(offer) = offers.iter().find(|o| o.name == *wanted) else { + eprintln!("{} does not offer {wanted:?}.", src.label); + if let Some(newest) = offers.first() { + eprintln!("The newest it has is {}.", newest.name); + } + eprintln!("`media sources {id}` lists them all."); + return ExitCode::FAILURE; + }; + resolved = (offer.url.clone(), offer.digest.clone()); + eprintln!("{} publishes it as {}", src.label, &resolved.1[..16]); + expected = Some(&resolved.1); + &resolved.0 + } else { + let Some(source) = source else { + eprintln!( + "usage: rescriptum media add FILE [--sha256 DIGEST]\n\ + \x20 rescriptum media add URL --sha256 DIGEST [--as NAME.iso]\n\ + \x20 rescriptum media add --from SOURCE NAME" + ); + return ExitCode::FAILURE; + }; + source + }; + + if let Some(digest) = expected + && !crate::boot::sha256::is_digest(digest) + { + eprintln!("{digest:?} is not a SHA-256 — it is 64 hexadecimal characters"); + return ExitCode::FAILURE; + } + + let path = if crate::boot::fetch::looks_like_a_url(source) { + // **A digest is required for a URL**, and `--unverified` is what makes going + // without one a deliberate act rather than the default. This decides what every + // machine on the network installs; an image pulled off a mirror with nothing + // checking it is the one place in this design where that would be a shrug. + if expected.is_none() && !unverified { + eprintln!( + "fetching {source} needs --sha256, because nothing else would check what \ + arrived. Vendors publish a SHA256SUMS beside the image.\n\ + If you genuinely mean to skip it, say --unverified." + ); + return ExitCode::FAILURE; + } + match crate::boot::fetch::fetch( + source, + catalog.dir(), + name.as_deref(), + expected.map(String::as_str), + ) { + Ok(fetched) => { + eprintln!( + "fetched {} via {}{}", + human(fetched.bytes), + fetched.via, + if expected.is_some() { + ", digest verified" + } else { + " — UNVERIFIED" + } + ); + fetched.path + } + Err(e) => { + eprintln!("{e}"); + return ExitCode::FAILURE; + } + } + } else { + std::path::PathBuf::from(source) + }; + + if !path.is_file() { + eprintln!("{} is not a file", path.display()); + return ExitCode::FAILURE; + } + + // A file already on disk is registered where it lies: nothing is copied, so + // registering one outside the directory would record a digest for a file the + // listener cannot serve. + let inside = path + .parent() + .map(|p| same_directory(p, catalog.dir())) + .unwrap_or(false); + if !inside { + eprintln!( + "{} is not in {} — put the image there first, then register it, or give a \ + URL and let the server fetch it.\n\ + Nothing is copied: the catalogue serves the file where it lies.", + path.display(), + catalog.dir().display() + ); + return ExitCode::FAILURE; + } + + let id = path + .file_stem() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_default(); + if !crate::store::valid_id(&id) { + eprintln!("{id:?} is not a usable identifier — it becomes part of a URL"); + return ExitCode::FAILURE; + } + if crate::boot::catalog::RESERVED_IDS.contains(&id.as_str()) { + eprintln!( + "{id:?} is a reserved name — the media listener answers /{id} itself, so an \ + entry called that could never be reached. Rename the file." + ); + return ExitCode::FAILURE; + } + + // Progress, because a minute of silence reads as a hang. + eprintln!("hashing {} …", path.display()); + let mut last = 0u64; + let digest = match crate::boot::sha256::file(&path, |done, total| { + // No total means no progress to report, so call it finished rather than dividing. + let percent = (done * 100).checked_div(total).unwrap_or(100); + if percent >= last + 10 { + last = percent - percent % 10; + eprintln!(" {last}% ({} of {})", human(done), human(total)); + } + }) { + Ok(digest) => digest, + Err(e) => { + eprintln!("cannot read {}: {e}", path.display()); + return ExitCode::FAILURE; + } + }; + + if let Some(expected) = expected + && !expected.eq_ignore_ascii_case(&digest) + { + // Loud and fatal. A mismatch here is either a truncated download or the wrong + // file, and both install the wrong thing on every machine that asks. + eprintln!("digest mismatch — nothing was recorded"); + eprintln!(" expected {expected}"); + eprintln!(" found {digest}"); + return ExitCode::FAILURE; + } + + let probed = match crate::boot::probe::probe(&path) { + Ok(probed) => probed, + Err(e) => { + // Still registrable: an image nothing places can be served whole, and that + // is a normal thing to want. + eprintln!("note: cannot read {} as an image ({e})", path.display()); + Default::default() + } + }; + + let sidecar = crate::boot::catalog::Sidecar::path_for(&path); + if let Err(e) = std::fs::write( + &sidecar, + crate::boot::catalog::Sidecar::render(&digest, &probed), + ) { + eprintln!("cannot write {}: {e}", sidecar.display()); + return ExitCode::FAILURE; + } + + println!("{id} {digest}"); + println!( + " {} {}", + probed + .family + .map(|f| f.label().to_string()) + .unwrap_or_else(|| "unknown".to_string()), + probed.version.clone().unwrap_or_default() + ); + match (&probed.kernel, &probed.initrd) { + (Some(kernel), Some(initrd)) => { + println!(" kernel {kernel}"); + println!(" initrd {initrd}"); + if probed.external { + println!(" (both beside the image — this looks like `prepare-iso --pxe` output)"); + } + if probed.zstd_initrd { + // The assistant's own source says "iPXE does not support a + // zstd-compressed initrd" when it recompresses to gzip. Whether that + // binds through our chain is a bench question; saying so is not. + println!( + " note: the initrd is zstd-compressed. The Proxmox assistant recompresses\n\ + \x20 it to gzip when it splits an image, on the grounds that iPXE does\n\ + \x20 not support zstd. If a loader refuses it, run:\n\ + \x20 proxmox-auto-install-assistant prepare-iso {} --pxe --output DIR", + path.display() + ); + } + } + _ => println!(" no kernel or initrd found — servable whole, but not as a boot stanza"), + } + println!(" wrote {}", sidecar.display()); + ExitCode::SUCCESS +} + +/// `media check` — re-verify what was recorded. Its exit code is a contract, like +/// `check`'s: `deploy.sh` keys on it. +#[cfg(feature = "boot")] +fn media_check(catalog: &crate::boot::catalog::Catalog) -> ExitCode { + let listing = match catalog.listing() { + Ok(listing) => listing, + Err(e) => { + println!("cannot read {}: {e}", catalog.dir().display()); + return ExitCode::FAILURE; + } + }; + println!("checking {}", catalog.describe()); + + let mut failures = listing.problems.len(); + for problem in &listing.problems { + println!(" problem: {problem}"); + } + + let mut pinned = 0usize; + let mut unpinned: Vec<&str> = Vec::new(); + for entry in &listing.entries { + let Some(recorded) = &entry.digest else { + unpinned.push(&entry.id); + continue; + }; + match crate::boot::sha256::file(&entry.path, |_, _| {}) { + Ok(digest) if digest.eq_ignore_ascii_case(recorded) => pinned += 1, + Ok(digest) => { + // An image that changed under a recorded digest is the one failure that + // silently installs something nobody reviewed. + println!( + " FAIL {}: the image no longer matches what was recorded", + entry.id + ); + println!(" recorded {recorded}"); + println!(" found {digest}"); + failures += 1; + } + Err(e) => { + println!(" FAIL {}: {e}", entry.id); + failures += 1; + } + } + if entry.probed.zstd_initrd { + println!( + " note: {}'s initrd is zstd — `prepare-iso --pxe` recompresses to gzip", + entry.id + ); + } + } + + println!( + " {} image(s), {pinned} verified against a recorded digest", + listing.entries.len() + ); + for id in &unpinned { + println!(" note: {id} has no recorded digest — `media add` records one"); + } + + if failures == 0 { + println!(" ok — everything recorded still matches"); + ExitCode::SUCCESS + } else { + println!(" {failures} problem(s)"); + ExitCode::FAILURE + } +} + +/// `media ipxe ID` — **print a script; do not install one.** +/// +/// The output is an ordinary `.ipxe` answer document. Saved into the answers directory +/// it goes through the existing selection, layering and templating unchanged, which is +/// the altitude that keeps the model intact: the server does not become clever about +/// booting, it gains a generator. +/// +/// stdout is the script and stderr is everything else, so `media ipxe … > file` works. +#[cfg(feature = "boot")] +fn media_ipxe(cfg: &Config, catalog: &crate::boot::catalog::Catalog, id: &str) -> ExitCode { + let entry = match catalog.get(id) { + Ok(Some(entry)) => entry, + Ok(None) => { + eprintln!("no image called {id:?} — `rescriptum media list` shows what there is"); + return ExitCode::FAILURE; + } + Err(e) => { + eprintln!("cannot read {}: {e}", catalog.dir().display()); + return ExitCode::FAILURE; + } + }; + + let (host, derived) = cfg.public_host(); + if derived { + eprintln!( + "# warning: RESCRIPTUM_PUBLIC_HOST is not set, so this script names {host}, \ + derived by asking the routing table. Set it if that is not the address the \ + machines can reach." + ); + } + match crate::boot::stanza::ipxe(&entry, &cfg.endpoints()) { + Ok(script) => { + eprintln!( + "# {}", + crate::boot::stanza::where_the_answer_goes(entry.family()) + ); + print!("{script}"); + ExitCode::SUCCESS + } + Err(e) => { + eprintln!("{e}"); + ExitCode::FAILURE + } + } +} + +/// `media prepare ID` — the one command that removes the last external tool. +/// +/// It writes a **sidecar**, not an image: two hundred bytes standing in for 1.5 GB. The +/// source is never modified, never copied, and its published digest stays verifiable; +/// the injection happens on the wire, and changing the answer URL later rewrites those +/// two hundred bytes rather than a gigabyte. +#[cfg(feature = "boot")] +fn media_prepare( + cfg: &Config, + catalog: &crate::boot::catalog::Catalog, + args: &[String], +) -> ExitCode { + let mut id: Option<&String> = None; + let mut name: Option = None; + let mut url: Option = None; + let mut fingerprint: Option = None; + let mut token: Option = None; + let mut rest = args.iter(); + while let Some(arg) = rest.next() { + let mut take = |what: &str| -> Option { + match rest.next() { + Some(value) => Some(value.clone()), + None => { + eprintln!("{what} wants a value"); + None + } + } + }; + match arg.as_str() { + "--as" => match take("--as") { + Some(v) => name = Some(v), + None => return ExitCode::FAILURE, + }, + "--url" => match take("--url") { + Some(v) => url = Some(v), + None => return ExitCode::FAILURE, + }, + "--cert-fingerprint" => match take("--cert-fingerprint") { + Some(v) => fingerprint = Some(v), + None => return ExitCode::FAILURE, + }, + "--token" => match take("--token") { + Some(v) => token = Some(v), + None => return ExitCode::FAILURE, + }, + other if id.is_none() && !other.starts_with('-') => id = Some(arg), + other => { + eprintln!("unexpected argument {other:?}"); + return ExitCode::FAILURE; + } + } + } + + let Some(id) = id else { + eprintln!("usage: rescriptum media prepare ID [--as NAME] [--url URL]"); + return ExitCode::FAILURE; + }; + let entry = match catalog.get(id) { + Ok(Some(entry)) => entry, + Ok(None) => { + eprintln!("no image called {id:?} — `rescriptum media list` shows what there is"); + return ExitCode::FAILURE; + } + Err(e) => { + eprintln!("cannot read {}: {e}", catalog.dir().display()); + return ExitCode::FAILURE; + } + }; + if entry.family() != crate::boot::probe::Family::Proxmox { + // Every other family takes the answer's URL on the kernel command line, where + // `media ipxe` already puts it. Injecting a file they never read would be a + // no-op that looks like a step. + eprintln!( + "{id} is {}, and only Proxmox reads its answer's location from inside the image. For every other family the URL goes on the kernel command line, which `rescriptum media ipxe {id}` already writes.", + entry.family().label() + ); + return ExitCode::FAILURE; + } + + let url = url.unwrap_or_else(|| format!("{}/proxmox", cfg.endpoints().answer)); + let derived = name.unwrap_or_else(|| format!("{id}-http")); + if !crate::store::valid_id(&derived) + || crate::boot::catalog::RESERVED_IDS.contains(&derived.as_str()) + { + eprintln!("{derived:?} is not a usable identifier — it becomes part of a URL"); + return ExitCode::FAILURE; + } + + // Plan it now rather than at request time, so a refusal is reported to the person + // who can act on it instead of to a machine at 3am. + let mode = crate::boot::patch::mode_file(&url, fingerprint.as_deref(), token.as_deref()); + let plan = match crate::boot::patch::add_file( + &entry.path, + "auto-installer-mode.toml", + mode.as_bytes(), + ) { + Ok(plan) => plan, + Err(e) => { + eprintln!("{e}"); + return ExitCode::FAILURE; + } + }; + + let mut sidecar = String::from( + "# rescriptum prepared entry — written by `media prepare`.\n # Two hundred bytes standing in for an image: nothing was copied, and the\n # source is untouched. The file is injected on the wire, so changing the URL\n # below is all it takes to point this at a different answer endpoint.\n", + ); + sidecar.push_str(&format!("source = {id}\n")); + sidecar.push_str(&format!("prepare-url = {url}\n")); + if let Some(fingerprint) = &fingerprint { + sidecar.push_str(&format!("prepare-cert-fingerprint = {fingerprint}\n")); + } + if let Some(token) = &token { + sidecar.push_str(&format!("prepare-token = {token}\n")); + } + // The length the offsets were computed against. A source that changed underneath + // would be patched in the wrong place, and the catalogue refuses rather than + // serving an image that mounts and is wrong. + sidecar.push_str(&format!("source-bytes = {}\n", entry.size)); + if let Some(digest) = &entry.digest { + sidecar.push_str(&format!("sha256 = {digest}\n")); + } + + let path = catalog.dir().join(format!( + "{derived}.{}", + crate::boot::catalog::SIDECAR_EXTENSION + )); + if let Err(e) = std::fs::write(&path, sidecar) { + eprintln!("cannot write {}: {e}", path.display()); + return ExitCode::FAILURE; + } + + println!("{derived} prepared from {id}"); + println!(" answer {url}"); + println!( + " injects /auto-installer-mode.toml ({} bytes)", + mode.len() + ); + println!( + " image {} bytes (source {} + {} appended)", + plan.len(), + entry.size, + plan.len() - entry.size + ); + println!(" wrote {}", path.display()); + println!(); + println!("Nothing was copied. Serve it as /{derived}/iso, or write it to a stick with"); + println!(" rescriptum media export {derived} /tmp/{derived}.iso"); + ExitCode::SUCCESS +} + +/// `media export ID FILE` — materialise what the listener would have served. +/// +/// **One code path with the streaming one.** A stick written from a different code path +/// than the one a machine downloads is a second implementation to keep honest, and the +/// difference would only show on somebody's desk. +#[cfg(feature = "boot")] +fn media_export(catalog: &crate::boot::catalog::Catalog, id: &str, to: &str) -> ExitCode { + let entry = match catalog.get(id) { + Ok(Some(entry)) => entry, + Ok(None) => { + eprintln!("no image called {id:?}"); + return ExitCode::FAILURE; + } + Err(e) => { + eprintln!("cannot read {}: {e}", catalog.dir().display()); + return ExitCode::FAILURE; + } + }; + let Some(prepared) = &entry.prepared else { + eprintln!( + "{id} is not a prepared entry — it is the image itself, so copy it. `rescriptum media prepare {id}` makes one that needs exporting." + ); + return ExitCode::FAILURE; + }; + + let mode = crate::boot::patch::mode_file( + &prepared.url, + prepared.fingerprint.as_deref(), + prepared.token.as_deref(), + ); + let plan = match crate::boot::patch::add_file( + &entry.path, + "auto-installer-mode.toml", + mode.as_bytes(), + ) { + Ok(plan) => plan, + Err(e) => { + eprintln!("{e}"); + return ExitCode::FAILURE; + } + }; + + eprintln!("writing {} bytes to {to} …", plan.len()); + if let Err(e) = plan.materialise(std::path::Path::new(to)) { + eprintln!("cannot write {to}: {e}"); + return ExitCode::FAILURE; + } + println!("{to}"); + ExitCode::SUCCESS +} + +/// Whether two paths name the same directory, resolving symlinks where it can. A media +/// directory reached as `/srv/media` and as `./media` is the same directory. +#[cfg(feature = "boot")] +fn same_directory(a: &std::path::Path, b: &std::path::Path) -> bool { + match (a.canonicalize(), b.canonicalize()) { + (Ok(a), Ok(b)) => a == b, + _ => a == b, + } +} + +#[cfg(feature = "boot")] +fn human(bytes: u64) -> String { + const UNITS: [&str; 5] = ["B", "K", "M", "G", "T"]; + let mut size = bytes as f64; + let mut unit = 0; + while size >= 1024.0 && unit + 1 < UNITS.len() { + size /= 1024.0; + unit += 1; + } + if unit == 0 { + format!("{bytes}{}", UNITS[0]) + } else { + format!("{size:.1}{}", UNITS[unit]) + } +} + +#[cfg(feature = "boot")] +fn truncate(text: &str, width: usize) -> String { + if text.chars().count() <= width { + return text.to_string(); + } + let kept: String = text.chars().take(width.saturating_sub(1)).collect(); + format!("{kept}…") +} + +// ---- boot ----------------------------------------------------------------- + +#[cfg(not(feature = "boot"))] +pub fn boot(_cfg: &Config, _args: &[String]) -> ExitCode { + eprintln!("this binary was built without the `boot` feature, so it has no boot commands"); + ExitCode::FAILURE +} + +/// `boot dhcp-snippet` / `boot check` / `boot bootstrap` / `boot menu`. +#[cfg(feature = "boot")] +pub fn boot(cfg: &Config, args: &[String]) -> ExitCode { + match args.split_first() { + Some((cmd, rest)) if cmd == "dhcp-snippet" => boot_snippet(cfg, rest), + Some((cmd, rest)) if cmd == "check" && rest.is_empty() => boot_check(cfg), + Some((cmd, rest)) if cmd == "bootstrap" && rest.is_empty() => { + print!( + "{}", + crate::boot::menu::bootstrap(&cfg.endpoints(), cfg.unclaimed_boots_local()) + ); + ExitCode::SUCCESS + } + Some((cmd, rest)) if cmd == "menu" && rest.is_empty() => boot_menu(cfg), + _ => { + eprintln!( + "usage: rescriptum boot dhcp-snippet [--format F] [--one-loader]\n\ + \x20 rescriptum boot check\n\ + \x20 rescriptum boot bootstrap\n\ + \x20 rescriptum boot menu\n\ + \n\ + \x20 --format: dnsmasq | isc | kea | powershell | pfsense | mikrotik" + ); + ExitCode::FAILURE + } + } +} + +/// The DHCP configuration an operator pastes into a server we do not speak to. +/// +/// stdout is the snippet and stderr is everything else, so redirecting it produces a +/// file that can be included as-is. +#[cfg(feature = "boot")] +fn boot_snippet(cfg: &Config, args: &[String]) -> ExitCode { + use crate::boot::dhcp; + + let mut format = dhcp::Format::Dnsmasq; + let mut one_loader = false; + let mut rest = args.iter(); + while let Some(arg) = rest.next() { + match arg.as_str() { + "--one-loader" => one_loader = true, + "--format" => match rest.next().map(|f| dhcp::Format::parse(f)) { + Some(Some(parsed)) => format = parsed, + Some(None) => { + eprintln!( + "unknown --format. Known: {}", + dhcp::Format::ALL + .iter() + .map(|f| f.label()) + .collect::>() + .join(", ") + ); + return ExitCode::FAILURE; + } + None => { + eprintln!("--format wants a name"); + return ExitCode::FAILURE; + } + }, + other => { + eprintln!("unexpected argument {other:?}"); + return ExitCode::FAILURE; + } + } + } + + let (host, derived) = cfg.public_host(); + if derived { + eprintln!( + "# warning: RESCRIPTUM_PUBLIC_HOST is not set, so this snippet points machines \ + at {host}, derived by asking the routing table. A DHCP server handing out an \ + address the machines cannot reach is the hardest failure here to diagnose." + ); + } + print!( + "{}", + dhcp::snippet( + format, + &dhcp::Handoff { + host, + media: cfg.endpoints().media, + version: env!("CARGO_PKG_VERSION"), + one_loader, + } + ) + ); + ExitCode::SUCCESS +} + +#[cfg(feature = "boot")] +fn boot_menu(cfg: &Config) -> ExitCode { + let Some(dir) = &cfg.media_dir else { + eprintln!("there is no media directory: RESCRIPTUM_MEDIA_DIR names one, and nothing does"); + return ExitCode::FAILURE; + }; + let catalog = crate::boot::catalog::Catalog::new(dir); + let listing = match catalog.listing() { + Ok(listing) => listing, + Err(e) => { + eprintln!("cannot read {}: {e}", dir.display()); + return ExitCode::FAILURE; + } + }; + let style = crate::boot::menu::Style { + title: cfg + .boot_title + .clone() + .unwrap_or_else(crate::boot::menu::Style::default_title), + timeout_millis: cfg.boot_timeout_millis(), + }; + print!( + "{}", + crate::boot::menu::menu(&listing, &cfg.endpoints(), &style) + ); + ExitCode::SUCCESS +} + +/// `boot check` — is the boot chain actually complete? +/// +/// **The failure this exists for is silent at the ROM.** A generated snippet names a +/// loader; if that file is not on disk, the machine asks for it, gets nothing, and +/// stops with no message anybody sees. Nothing else in the chain will notice. +#[cfg(feature = "boot")] +fn boot_check(cfg: &Config) -> ExitCode { + use crate::boot::loaders; + use crate::boot::tftp::ProbeResult; + + let mut failures = 0usize; + let mut notes: Vec = Vec::new(); + + let Some(dir) = &cfg.boot_dir else { + println!("boot assets are off — RESCRIPTUM_BOOT_DIR names a directory, and nothing does"); + println!(" nothing to check; TFTP is not running either"); + return ExitCode::SUCCESS; + }; + println!("checking boot assets in {}", dir.display()); + + // Every loader the table can hand out, plus the `snp` variants that exist because + // the plain UEFI build cannot always see the NIC. + for loader in loaders::loaders() { + let path = dir.join(loader); + if path.is_file() { + let size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0); + println!(" ok {loader} ({})", human(size)); + } else { + // Named by a snippet, absent from the disk: the silent failure. + println!( + " MISSING {loader} — every machine the snippet sends here will ask for it, \ + get nothing, and stop" + ); + failures += 1; + } + for variant in loaders::variants(loader) { + if variant != loader && !dir.join(&variant).is_file() { + notes.push(format!( + "{variant} is absent — it is the one to reach for when the plain UEFI \ + build cannot see a NIC" + )); + } + } + } + + // **Can a loader actually be handed over?** Everything above is about the files + // being on disk; this is about anything reaching them over UDP, which is the first + // question a booting machine asks and the one nothing else here answers. + // + // **Binding is not the check, and finding that out cost a test.** 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 the probe is a real read request: what + // comes back is what a machine would get. + match cfg.tftp_addr() { + None => notes.push( + "TFTP is off (RESCRIPTUM_TFTP_ADDR) — the loaders above are served over HTTP \ + at /boot/ and something else has to hand one over on port 69" + .to_string(), + ), + Some(addr) => { + // Ask for a loader that is actually here, so `Refused` means what it says. + let wanted = loaders::loaders() + .iter() + .find(|l| dir.join(l).is_file()) + .copied() + .unwrap_or("ipxe-undionly.kpxe"); + match crate::boot::tftp::probe(&addr, wanted, std::time::Duration::from_secs(2)) { + ProbeResult::Served => println!(" ok {addr} handed over {wanted}"), + ProbeResult::Refused => { + println!( + " BROKEN a TFTP server answered on {addr} but would not serve \ + {wanted} — it is not this one, or not rooted at {}", + dir.display() + ); + failures += 1; + } + // Silence splits on whether the port is even obtainable, and the two + // halves are different problems. Cannot bind: something else holds it, + // or the privilege is missing — the DSM case, where an upgrade drops the + // `setcap` and the server warns and carries on. Can bind: nothing is + // there at all, which is simply what "the server is not running" looks + // like from a command run before starting it, so it is a note. + ProbeResult::Silent => match std::net::UdpSocket::bind(&addr) { + Ok(_) => notes.push(format!( + "nothing is listening on {addr} — expected if the server is not \ + running; if it is, it failed to bind and said so at startup" + )), + Err(e) => { + println!( + " BROKEN nothing answers on {addr} and it cannot be bound \ + either: {e}{} — the server still answers and still serves \ + media, but a machine sent here by DHCP asks for a loader and \ + gets nothing", + if addr.ends_with(":69") { + ". Port 69 is privileged: run as root and set \ + RESCRIPTUM_USER to drop afterwards, or grant the binary \ + cap_net_bind_service with setcap" + } else { + "" + } + ); + failures += 1; + } + }, + } + } + } + + // The embedded script in every loader already shipped chains to a fixed port, and + // it can read no configuration — it is baked in before any deployment exists. + let media = cfg.media_addr(); + let port = media.rsplit_once(':').map(|(_, p)| p).unwrap_or(""); + let expected = crate::config::DEFAULT_MEDIA_ADDR + .rsplit_once(':') + .map(|(_, p)| p) + .unwrap_or("8001"); + if port != expected { + println!( + " WARNING the media listener is on port {port}, but every loader already shipped \ + embeds a script chaining to :{expected}. The generated autoexec.ipxe and the \ + script's own relative fallback are the recovery; moving it back is the fix." + ); + failures += 1; + } + + // The logo, which the menu asks for and tolerates the absence of. + if !dir.join("logo.png").is_file() { + notes.push( + "logo.png is absent — the menu's `console --picture` tolerates that and falls \ + back to the text console, so this is cosmetic" + .to_string(), + ); + } + + let (host, derived) = cfg.public_host(); + if derived { + notes.push(format!( + "RESCRIPTUM_PUBLIC_HOST is not set; generated scripts will name {host}" + )); + } + + println!( + " {} loader(s) the table can hand out", + loaders::loaders().len() + ); + for note in ¬es { + println!(" note: {note}"); + } + + if failures == 0 { + println!(" ok — the loaders a snippet names are all here"); + ExitCode::SUCCESS + } else { + println!(" {failures} problem(s)"); + ExitCode::FAILURE + } +} + // ---- config --------------------------------------------------------------- /// `config` / `config --json` / `config set KEY=VALUE` / `config unset KEY` diff --git a/src/config.rs b/src/config.rs index bfb01cb..553f842 100644 --- a/src/config.rs +++ b/src/config.rs @@ -28,6 +28,26 @@ pub const DEFAULT_MAX_CONNECTIONS: usize = 2048; /// worker for at most this long. pub const DEFAULT_TIMEOUT_SECS: u64 = 10; +/// The media listener's port, and **it is a contract rather than a preference.** The +/// loader we ship carries an embedded script that chains to `${next-server}:8001`, and +/// that script is baked in before any deployment exists — it can read no configuration. +/// Moving this is allowed and survivable, but every loader already shipped assumes it. +pub const DEFAULT_MEDIA_ADDR: &str = "0.0.0.0:8001"; +/// A whole-transfer deadline, deliberately not the answer listener's ten seconds: a +/// 1.5 GB image is fifteen seconds on gigabit and two minutes on 100 Mbit, and on the +/// answer listener every download would be killed mid-transfer. +pub const DEFAULT_MEDIA_TIMEOUT_SECS: u64 = 600; +/// Concurrent transfers, low on purpose. A download holds its permit for minutes, and +/// the small end of the range this has to work on is a NAS with one spinning disk. +pub const DEFAULT_MEDIA_MAX_CONNECTIONS: usize = 16; +/// TFTP's well-known port, and privileged. It is the only privileged port this server +/// ever wants — with no DHCP responder there is nothing after 67 or 4011. +pub const DEFAULT_TFTP_ADDR: &str = "0.0.0.0:69"; +/// Seconds the built-in menu waits before falling through to local boot. **The name +/// spells the unit because `choose` counts milliseconds**: a seconds value passed +/// through unconverted is a menu that flashes past before a human has read its title. +pub const DEFAULT_BOOT_TIMEOUT_SECS: u64 = 15; + /// Where answers are read from. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum StoreKind { @@ -66,6 +86,54 @@ pub struct Config { pub workers: usize, pub max_connections: usize, pub timeout: Duration, + + /// The host this server names itself by in the scripts it writes. + /// + /// **A host, never a URL.** The server writes URLs for two listeners plus a bare + /// address into a DHCP snippet, so a value carrying one port would silently pin + /// every generated script to one listener. Each URL appends its own port. + /// + /// `None` means derive one and say so loudly — a wrong guess here produces a + /// machine that boots, chains, and hangs on an address that does not exist, and + /// the startup log line is the only place the answer will ever appear. + pub public_host: Option, + /// Where installer images live. **Unset is the whole off switch**: no media + /// directory, no media listener, nothing changes for an existing deployment. + pub media_dir: Option, + /// The media listener's address, **as the operator set it**. `None` means nobody + /// did, and `media_addr()` supplies the default. The distinction is kept because + /// naming an address without naming a directory is a mistake worth refusing, and a + /// value that had already been defaulted could not be told from one that was asked + /// for. + pub media_addr: Option, + pub media_timeout: Duration, + pub media_max_connections: usize, + /// Proxmox's `[post-installation-webhook]` token. **Unset is the whole off switch**: + /// no token, no endpoint — absent rather than open. See `installed`. + pub installed_token: Option, + /// A CIDR allowlist for boot traffic. Unset means anyone who can reach the port. + pub boot_allow: Option, + /// Loaders and menus — what TFTP hands out. **Unset means no TFTP at all**, the + /// same off switch shape the media directory has. + pub boot_dir: Option, + /// The TFTP listener, as the operator set it. `None` means nobody did; see + /// `tftp_addr()`. + pub tftp_addr: Option, + /// The ports TFTP answers transfers from, as `first-last`. See `tftp_port_range`. + pub tftp_port_range: Option, + /// The largest TFTP block this server will agree to. See `tftp_blksize`. + pub tftp_blksize: Option, + /// Seconds before the built-in menu falls through to booting from local disk. + pub boot_timeout: Duration, + /// What a machine no answer claims is offered. See `unclaimed_boots_local`. + pub boot_unclaimed: Option, + /// Replace the embedded logo and the menu's title, for a site that wants its own. + pub boot_logo: Option, + pub boot_title: Option, + /// Drop to this user and group **after** binding. Binding first is the whole point: + /// the other order works as root in testing and fails on deployment. + pub user: Option, + pub group: Option, } impl Config { @@ -175,6 +243,34 @@ impl Config { "RESCRIPTUM_TIMEOUT_SECS", DEFAULT_TIMEOUT_SECS as usize, ) as u64), + public_host: optional("RESCRIPTUM_PUBLIC_HOST"), + media_dir: optional("RESCRIPTUM_MEDIA_DIR").map(PathBuf::from), + media_addr: optional("RESCRIPTUM_MEDIA_ADDR"), + media_timeout: Duration::from_secs(get_usize( + "RESCRIPTUM_MEDIA_TIMEOUT_SECS", + DEFAULT_MEDIA_TIMEOUT_SECS as usize, + ) as u64), + media_max_connections: get_usize( + "RESCRIPTUM_MEDIA_MAX_CONNECTIONS", + DEFAULT_MEDIA_MAX_CONNECTIONS, + ), + installed_token: optional("RESCRIPTUM_INSTALLED_TOKEN"), + boot_allow: optional("RESCRIPTUM_BOOT_ALLOW"), + boot_dir: optional("RESCRIPTUM_BOOT_DIR").map(PathBuf::from), + tftp_addr: optional("RESCRIPTUM_TFTP_ADDR"), + boot_timeout: Duration::from_secs(get_usize( + "RESCRIPTUM_BOOT_TIMEOUT_SECS", + DEFAULT_BOOT_TIMEOUT_SECS as usize, + ) as u64), + tftp_port_range: optional("RESCRIPTUM_TFTP_PORT_RANGE"), + tftp_blksize: optional("RESCRIPTUM_TFTP_BLKSIZE") + .and_then(|v| v.trim().parse::().ok()) + .filter(|n| *n > 0), + boot_unclaimed: optional("RESCRIPTUM_BOOT_UNCLAIMED"), + boot_logo: optional("RESCRIPTUM_BOOT_LOGO").map(PathBuf::from), + boot_title: optional("RESCRIPTUM_BOOT_TITLE"), + user: optional("RESCRIPTUM_USER"), + group: optional("RESCRIPTUM_GROUP"), } } } @@ -186,6 +282,8 @@ impl Config { /// silently came up without a token would let anyone who can reach it rewrite the /// root password and SSH keys of every machine subsequently installed. pub fn validate(&self) -> Result<(), String> { + self.validate_media()?; + let Some(addr) = &self.admin_addr else { return Ok(()); }; @@ -216,6 +314,229 @@ impl Config { Ok(()) } + /// The media half of `validate`. Same rule as everywhere else here: refuse only + /// what would not work or would not be safe, and warn about everything that can be + /// fixed while the server runs. + fn validate_media(&self) -> Result<(), String> { + // A binary built without the feature must say so rather than ignoring the + // directory it was pointed at. Same shape as `open_store` refusing + // `RESCRIPTUM_STORE=sqlite` without the `sqlite` feature: the variable stays + // described everywhere, and only the binary that cannot honour it objects. + #[cfg(not(feature = "boot"))] + if self.media_dir.is_some() { + return Err( + "RESCRIPTUM_MEDIA_DIR is set, but this binary was built without the `boot` \ + feature, so it can serve no media." + .to_string(), + ); + } + + // A host, never a URL. One port in the value would silently pin every generated + // script to one listener, and the symptom is a machine chaining into nowhere. + if let Some(host) = &self.public_host { + let wrong = if host.contains("://") { + Some("a scheme") + } else if host.contains('/') { + Some("a path") + } else if host.rsplit_once(':').is_some_and(|(head, tail)| { + // `[::1]` is an address, not a host with a port. Only a trailing + // `:digits` after something that is not a bracketed address is one. + !host.starts_with('[') + && !head.contains(':') + && tail.chars().all(|c| c.is_ascii_digit()) + }) { + Some("a port") + } else { + None + }; + if let Some(wrong) = wrong { + return Err(format!( + "RESCRIPTUM_PUBLIC_HOST is {host:?}, which carries {wrong}. It is a host \ + on its own — every generated URL appends its own listener's port." + )); + } + } + + if self.tftp_addr.is_some() && !self.tftp_is_off() && self.boot_dir.is_none() { + return Err(format!( + "RESCRIPTUM_TFTP_ADDR is set ({}), but RESCRIPTUM_BOOT_DIR is not. There would \ + be a listener with no loaders to hand out.", + self.tftp_addr.as_deref().unwrap_or_default() + )); + } + + if self.media_addr.is_some() && self.media_dir.is_none() { + return Err(format!( + "RESCRIPTUM_MEDIA_ADDR is set ({}), but RESCRIPTUM_MEDIA_DIR is not. There \ + would be a listener with nothing to serve.", + self.media_addr.as_deref().unwrap_or_default() + )); + } + + // Two listeners on one port: the second bind fails, and which one loses depends + // on start order. Saying so beats a race whose symptom is "it worked yesterday". + // + // Port zero is exempt, and not as a special case for tests: `:0` asks the kernel + // for *any* free port, so two of them are never the same port. Refusing them + // would refuse the one configuration that cannot collide. + if self.media_dir.is_some() && !ephemeral(&self.media_addr()) { + let media = self.media_addr(); + for (other, name) in [ + (Some(&self.listen_addr), "RESCRIPTUM_LISTEN_ADDR"), + (self.admin_addr.as_ref(), "RESCRIPTUM_ADMIN_ADDR"), + ] { + if other.is_some_and(|o| o == &media) { + return Err(format!( + "RESCRIPTUM_MEDIA_ADDR and {name} are both {media}. Media downloads \ + hold a connection for minutes and answers must not queue behind \ + them, which is why they are separate listeners." + )); + } + } + } + Ok(()) + } + + /// The TFTP listener's effective address, or `None` when TFTP is off. + /// + /// **`off` is a value, not an absence** — it is how an operator says the loader will + /// come from somebody else's TFTP server while rescriptum keeps serving the rest of + /// the chain. The loaders stay served over HTTP at `/boot/…` and stay checked by + /// `boot check`; only the listener is gone. + /// + /// **It is a deployment workaround, never a packaged default.** rescriptum *is* the + /// TFTP server; a build or a package that ships with `off` set has traded away the + /// thing it is for. Where the platform makes port 69 hard, the answer is to make one + /// of the three routes durable there — bind then drop, socket activation, `setcap` — + /// not to hand the port to another daemon. See `tftp_addr_is_named` for what happens + /// when the route has not been opened yet. + pub fn tftp_addr(&self) -> Option { + match self.tftp_addr.as_deref() { + Some(value) if is_off(value) => None, + Some(value) => Some(value.to_string()), + None => Some(DEFAULT_TFTP_ADDR.to_string()), + } + } + + /// Whether TFTP was turned off deliberately, as opposed to never asked for. Worth + /// telling apart: the first deserves a line at startup saying what will hand the + /// loader over instead. + pub fn tftp_is_off(&self) -> bool { + self.tftp_addr.as_deref().is_some_and(is_off) + } + + /// Whether a machine that no answer claims is sent straight to its own disk instead + /// of being offered the menu. + /// + /// **The default is the menu, and that is the project's thesis rather than an + /// oversight**: a machine nobody has decided anything about should end up somewhere a + /// human can decide, not silently do nothing. That is right for a machine being + /// provisioned, and wrong for a fleet already in production — where most machines are + /// installed, and showing every one of them a menu for fifteen seconds on every + /// reboot is noise at best and an accidental reinstall at worst. + /// + /// So `local` inverts what an answer file *means*. With the menu, a file claiming a + /// machine is how you say "leave this one alone"; with `local`, a file is how you say + /// "install this one", and its absence is the safe state. The second reading is the + /// one that scales, because the number of machines you want to reinstall is always + /// smaller than the number you do not. + pub fn unclaimed_boots_local(&self) -> bool { + self.boot_unclaimed + .as_deref() + .map(str::trim) + .is_some_and(|v| v.eq_ignore_ascii_case("local")) + } + + /// The ports a TFTP transfer may answer from, parsed from `first-last`. + /// + /// **This exists because of firewalls, and it is not a preference.** A TFTP transfer + /// does not continue on port 69: the server answers from a fresh port and the client + /// acknowledges to *that*. A firewall told to allow 69 lets the request in, lets the + /// answer out, and then drops the acknowledgement — which looks exactly like a client + /// that stopped caring, and is the single hardest failure in this protocol to read. + /// + /// Unset, the kernel picks and nothing needs opening on a host with no firewall. + /// Set, the range is what an operator opens, and it need only be as large as the + /// transfers that can overlap — `MAX_TRANSFERS` bounds those at 64. + /// + /// Anything unparseable is `None` rather than an error: a wrong value here must not + /// stop a server from booting a fleet, and the startup line says which it took. + pub fn tftp_port_range(&self) -> Option<(u16, u16)> { + let raw = self.tftp_port_range.as_deref()?.trim(); + let (a, b) = raw.split_once('-')?; + let first: u16 = a.trim().parse().ok()?; + let last: u16 = b.trim().parse().ok()?; + // Backwards is a typo, not an empty range, and 0 would ask the kernel to choose + // — which is what leaving this unset already means. + (first > 0 && last >= first).then_some((first, last)) + } + + /// The largest TFTP block to agree to, when a client asks for a bigger one. + /// + /// **1468 exactly fills a 1500-byte path and leaves nothing over**: 1468 of payload, + /// 4 of TFTP header, 8 of UDP, 20 of IP. iPXE asks for precisely that, and on a plain + /// untagged Ethernet it is right. Put one VLAN tag in the way and the frame is 1504, + /// which is dropped or fragmented — and a PXE ROM meeting either usually just stops, + /// with no message, having downloaded nothing. Same for PPPoE, and for any tunnel. + /// + /// So this exists to be lowered when a boot stalls at the first block. 1400 leaves 68 + /// bytes of headroom, which covers a tag and most tunnels; 512 is the RFC default and + /// always works. The default stays at what fits a clean path, because lowering it for + /// everybody costs every deployment throughput to fix a minority's network — but the + /// failure it causes is now loud enough to find. + /// + /// Behind the `boot` feature: the only caller is the TFTP server, and the constants + /// it clamps against live there too. + #[cfg(feature = "boot")] + pub fn tftp_blksize(&self) -> usize { + self.tftp_blksize + .unwrap_or(crate::boot::tftp::MAX_BLOCK) + .clamp( + crate::boot::tftp::DEFAULT_BLOCK, + crate::boot::tftp::MAX_BLOCK, + ) + } + + /// The menu timeout **in milliseconds**, which is the unit `choose` counts. The + /// conversion has exactly one place, and this is it. + pub fn boot_timeout_millis(&self) -> u64 { + self.boot_timeout.as_millis() as u64 + } + + /// The media listener's effective address. + pub fn media_addr(&self) -> String { + self.media_addr + .clone() + .unwrap_or_else(|| DEFAULT_MEDIA_ADDR.to_string()) + } + + /// The host this server names itself by, and where that name came from. + /// + /// Derivation opens a UDP socket toward a documentation address and reads back the + /// local address the routing table chose. **No packet is sent** — connecting a UDP + /// socket only picks a route. It is the standard trick, it costs nothing, and it is + /// wrong often enough on multi-homed and NAT hosts to be a warning rather than a + /// silent success. + pub fn public_host(&self) -> (String, bool) { + match &self.public_host { + Some(host) => (host.clone(), false), + None => ( + derive_public_host().unwrap_or_else(|| "127.0.0.1".to_string()), + true, + ), + } + } + + /// The two URLs a generated script needs, each with its own listener's port. + #[cfg(feature = "boot")] + pub fn endpoints(&self) -> crate::boot::stanza::Endpoints { + let (host, _) = self.public_host(); + crate::boot::stanza::Endpoints { + media: format!("http://{}", join(&host, &self.media_addr())), + answer: format!("http://{}", join(&host, &self.listen_addr)), + } + } + /// The answer endpoint's own check, separate because a short token there is worth a /// warning rather than a refusal: an installer's token format is not ours to choose, /// and refusing to start would leave a fleet unable to install. @@ -262,6 +583,126 @@ impl Config { } } +/// The spellings that mean "not at all", matching `RESCRIPTUM_LOG=off`. +fn is_off(value: &str) -> bool { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "off" | "none" | "disabled" + ) +} + +/// Whether an address asks the kernel to choose the port. Two such listeners never +/// collide, however identical the strings look. +fn ephemeral(addr: &str) -> bool { + addr.rsplit_once(':') + .is_some_and(|(_, port)| port.trim() == "0") +} + +/// A reachable host plus the port of a listen address, ready to go into a URL. +/// +/// Only `endpoints` calls this, and only a binary that can serve media has one. +#[cfg(feature = "boot")] +/// +/// The listen address is usually `0.0.0.0:8001`, which is not something anybody can +/// fetch from — the port is the only part of it worth keeping. +fn join(host: &str, listen_addr: &str) -> String { + let port = listen_addr + .rsplit_once(':') + .map(|(_, port)| port) + .unwrap_or("80"); + // An IPv6 literal needs brackets before a port can follow it. + if host.contains(':') && !host.starts_with('[') { + format!("[{host}]:{port}") + } else { + format!("{host}:{port}") + } +} + +/// Ask the routing table which of this host's addresses faces the outside world. +/// +/// 192.0.2.1 is TEST-NET-1, a documentation address that exists to be written down and +/// never answered. Connecting a UDP socket to it sends nothing; it only makes the +/// kernel choose a source address, which is the answer we are after — and on a host +/// with one interface it is simply the right one. +pub fn derive_public_host() -> Option { + choose_host(routed_address(), &local_addresses()) +} + +/// The choice itself, separated from the two syscalls that feed it so it can be tested. +fn choose_host(routed: Option, addresses: &[String]) -> Option { + if routed.is_some() { + return routed; + } + // No default route — an isolated provisioning segment, which is a perfectly ordinary + // way to run this. The routing table has nothing to say, but the interface list + // still does: with exactly one address there is no choice to get wrong. With + // several there is, and guessing one silently is worse than saying nothing. + (addresses.len() == 1).then(|| addresses[0].clone()) +} + +fn routed_address() -> Option { + let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?; + socket.connect("192.0.2.1:9").ok()?; + let address = socket.local_addr().ok()?.ip(); + (!address.is_unspecified()).then(|| address.to_string()) +} + +/// Every address this host actually has, loopback and link-local excluded. +/// +/// The derivation above picks the interface the *default route* uses, which is right on +/// a host with one address and a coin toss on a NAS with two NICs or a bond. Knowing +/// what else is available is what turns "this might be wrong" into something an +/// operator can act on without going to look — and looking is the step nobody takes +/// before a rack is already failing to boot. +pub fn local_addresses() -> Vec { + #[cfg(not(unix))] + { + Vec::new() + } + #[cfg(unix)] + { + use std::net::{Ipv4Addr, Ipv6Addr}; + + let mut list: *mut libc::ifaddrs = std::ptr::null_mut(); + if unsafe { libc::getifaddrs(&mut list) } != 0 { + return Vec::new(); + } + let mut found: Vec = Vec::new(); + let mut node = list; + while !node.is_null() { + let entry = unsafe { &*node }; + node = entry.ifa_next; + if entry.ifa_addr.is_null() { + continue; + } + let family = unsafe { (*entry.ifa_addr).sa_family } as i32; + let address = if family == libc::AF_INET { + let raw = unsafe { &*(entry.ifa_addr as *const libc::sockaddr_in) }; + let octets = u32::from_be(raw.sin_addr.s_addr); + let v4 = Ipv4Addr::from(octets); + (!v4.is_loopback() && !v4.is_link_local() && !v4.is_unspecified()) + .then(|| v4.to_string()) + } else if family == libc::AF_INET6 { + let raw = unsafe { &*(entry.ifa_addr as *const libc::sockaddr_in6) }; + let v6 = Ipv6Addr::from(raw.sin6_addr.s6_addr); + // No link-local: an fe80:: address needs a scope to be usable, and a + // scope is not something that survives being written into a script. + let link_local = v6.segments()[0] & 0xffc0 == 0xfe80; + (!v6.is_loopback() && !link_local && !v6.is_unspecified()).then(|| v6.to_string()) + } else { + None + }; + if let Some(address) = address + && !found.contains(&address) + { + found.push(address); + } + } + unsafe { libc::freeifaddrs(list) }; + found + } +} + /// One configuration variable, **described** rather than merely read. /// /// `from_lookup` above knows how to interpret each of these. This table is what anything @@ -282,7 +723,7 @@ pub struct Known { /// Every variable, in the order a person would want to meet them: what answers come /// from, where the server listens, how much it says, then the two credentials. -pub const KNOWN: [Known; 13] = [ +pub const KNOWN: [Known; 30] = [ Known { key: "RESCRIPTUM_STORE", default: Some("files"), @@ -363,6 +804,111 @@ pub const KNOWN: [Known; 13] = [ secret: true, help: "Bearer token for the write API. At least 16 characters, and required.", }, + Known { + key: "RESCRIPTUM_PUBLIC_HOST", + // Not a constant: the default is this host's own LAN address, which is only + // knowable at runtime. `settings()` fills it in, the way it does the CPU count. + default: None, + secret: false, + help: "The host this server names itself by. A host, never a URL. \ + Unset, the address of the interface that reaches the network is used.", + }, + Known { + key: "RESCRIPTUM_MEDIA_DIR", + default: None, + secret: false, + help: "Installer images. Unset means no media and no media listener.", + }, + Known { + key: "RESCRIPTUM_MEDIA_ADDR", + default: Some(DEFAULT_MEDIA_ADDR), + secret: false, + help: "The media listener, when there is a media directory. Loaders assume 8001.", + }, + Known { + key: "RESCRIPTUM_MEDIA_TIMEOUT_SECS", + default: Some("600"), + secret: false, + help: "Whole-transfer deadline for a download. Not the answer listener's 10.", + }, + Known { + key: "RESCRIPTUM_MEDIA_MAX_CONNECTIONS", + default: Some("16"), + secret: false, + help: "Concurrent transfers, low on purpose: each holds its permit for minutes.", + }, + Known { + key: "RESCRIPTUM_BOOT_ALLOW", + default: None, + secret: false, + help: "Client CIDRs allowed to fetch boot media. Unset means anyone who can reach it.", + }, + Known { + key: "RESCRIPTUM_BOOT_DIR", + default: None, + secret: false, + help: "Loaders and menus, handed out over TFTP. Unset means no TFTP at all.", + }, + Known { + key: "RESCRIPTUM_TFTP_ADDR", + default: Some(DEFAULT_TFTP_ADDR), + secret: false, + help: "The TFTP listener, or `off` for none. Port 69 is privileged; see RESCRIPTUM_USER.", + }, + Known { + key: "RESCRIPTUM_BOOT_TIMEOUT_SECS", + default: Some("15"), + secret: false, + help: "Seconds before the menu falls through to local disk. Rendered as milliseconds.", + }, + Known { + key: "RESCRIPTUM_INSTALLED_TOKEN", + default: None, + secret: true, + help: "Proxmox's post-installation-webhook token. Set it and POST /installed exists, which drops a machine's install claim when it reports success. Unset, there is no endpoint.", + }, + Known { + key: "RESCRIPTUM_TFTP_PORT_RANGE", + default: None, + secret: false, + help: "The ports transfers answer from, as `first-last` (e.g. 30000-30063). A TFTP transfer leaves port 69 immediately, so this is what a firewall has to allow besides 69/udp. Unset, the kernel picks.", + }, + Known { + key: "RESCRIPTUM_TFTP_BLKSIZE", + default: Some("1468"), + secret: false, + help: "The largest TFTP block to agree to. 1468 fills a 1500-byte path exactly; lower it (1400, or 512) if a boot stalls at the first block, which is what a VLAN tag or a tunnel does to it.", + }, + Known { + key: "RESCRIPTUM_BOOT_UNCLAIMED", + default: Some("menu"), + secret: false, + help: "What a machine no answer claims gets: `menu`, or `local` to send it straight to its own disk.", + }, + Known { + key: "RESCRIPTUM_BOOT_LOGO", + default: None, + secret: false, + help: "A PNG to show behind the menu, replacing the built-in one.", + }, + Known { + key: "RESCRIPTUM_BOOT_TITLE", + default: None, + secret: false, + help: "The menu's title bar, replacing the built-in one.", + }, + Known { + key: "RESCRIPTUM_USER", + default: None, + secret: false, + help: "Drop to this user after binding. Binding first is the point.", + }, + Known { + key: "RESCRIPTUM_GROUP", + default: None, + secret: false, + help: "Drop to this group after binding.", + }, ]; /// Which of the three places a value came from. @@ -435,6 +981,11 @@ pub fn settings( let default = match known.key { "RESCRIPTUM_WORKERS" => Some(default_workers().to_string()), + // **The other default that cannot be a constant.** The server derives + // this at startup, so a panel showing an empty field would be showing + // something other than what the server will use — and the operator has + // no way to tell whether the guess is right without reading a log. + "RESCRIPTUM_PUBLIC_HOST" => derive_public_host(), _ => known.default.map(str::to_string), }; let value = from_env.or(from_file).or_else(|| default.clone()); @@ -654,6 +1205,268 @@ mod tests { assert_eq!(c.listen_addr, "0.0.0.0:8080"); } + // ---- media and the public host --------------------------------------- + + #[test] + fn media_is_off_until_a_directory_is_named() { + // Nothing changes for an existing deployment: no directory, no listener. + let c = Config::from_lookup(lookup(&[])); + assert_eq!(c.media_dir, None); + assert_eq!(c.media_addr, None); + assert!(c.validate().is_ok()); + } + + #[test] + fn a_boot_directory_alone_still_starts_tftp() { + // The default has not moved: `off` is opt-in, and a plain Linux host that names + // a boot directory gets the TFTP server the plan calls core. + let c = Config::from_lookup(lookup(&[("RESCRIPTUM_BOOT_DIR", "/srv/boot")])); + assert_eq!(c.tftp_addr().as_deref(), Some("0.0.0.0:69")); + assert!(!c.tftp_is_off()); + } + + #[test] + fn the_media_listener_defaults_to_the_port_the_loaders_assume() { + let c = Config::from_lookup(lookup(&[("RESCRIPTUM_MEDIA_DIR", "/srv/media")])); + assert_eq!(c.media_addr(), "0.0.0.0:8001"); + // Pinned deliberately. The loader we ship embeds a script that chains to + // `${next-server}:8001` before any deployment exists, so this is a contract in + // the same way an answer URL baked into an ISO is. + assert_eq!(DEFAULT_MEDIA_ADDR, "0.0.0.0:8001"); + assert!(c.validate().is_ok()); + } + + #[test] + fn an_address_with_nothing_to_serve_is_refused() { + let c = Config::from_lookup(lookup(&[("RESCRIPTUM_MEDIA_ADDR", "0.0.0.0:8001")])); + let e = c.validate().expect_err("must refuse"); + assert!(e.contains("RESCRIPTUM_MEDIA_DIR"), "{e}"); + } + + #[test] + fn two_listeners_on_one_port_are_refused_rather_than_raced() { + // The second bind loses, and which one that is depends on start order. + let c = Config::from_lookup(lookup(&[ + ("RESCRIPTUM_MEDIA_DIR", "/srv/media"), + ("RESCRIPTUM_MEDIA_ADDR", "0.0.0.0:8000"), + ])); + let e = c.validate().expect_err("must refuse"); + assert!(e.contains("RESCRIPTUM_LISTEN_ADDR"), "{e}"); + + let c = Config::from_lookup(lookup(&[ + ("RESCRIPTUM_MEDIA_DIR", "/srv/media"), + ("RESCRIPTUM_MEDIA_ADDR", "127.0.0.1:8001"), + ("RESCRIPTUM_ADMIN_ADDR", "127.0.0.1:8001"), + ("RESCRIPTUM_STORE", "sqlite"), + ("RESCRIPTUM_ADMIN_TOKEN", "0123456789abcdef0"), + ])); + let e = c.validate().expect_err("must refuse"); + assert!(e.contains("RESCRIPTUM_ADMIN_ADDR"), "{e}"); + } + + #[test] + fn tftp_can_be_turned_off_without_giving_up_the_boot_directory() { + // **Off is a deployment workaround, never a packaged default.** It is how an + // operator says another daemon on this host hands the loader over while + // rescriptum serves the rest of the chain. rescriptum *is* the TFTP server; a + // package that shipped with this set would have traded away the thing it is for. + let c = Config::from_lookup(lookup(&[ + ("RESCRIPTUM_BOOT_DIR", "/srv/boot"), + ("RESCRIPTUM_TFTP_ADDR", "off"), + ])); + assert!(c.validate().is_ok(), "{:?}", c.validate()); + assert_eq!(c.tftp_addr(), None, "no listener"); + assert!( + c.tftp_is_off(), + "and deliberately so, not merely unasked for" + ); + // The directory is still configured, so /boot/ and `boot check` still work. + assert_eq!(c.boot_dir, Some(PathBuf::from("/srv/boot"))); + } + + #[test] + fn off_is_spelled_the_way_the_log_level_spells_it() { + for value in ["off", "OFF", "none", "disabled", " off "] { + let c = Config::from_lookup(|key| { + (key == "RESCRIPTUM_TFTP_ADDR").then(|| value.to_string()) + }); + assert_eq!(c.tftp_addr(), None, "{value:?}"); + } + // And an address is still an address. + let c = Config::from_lookup(lookup(&[ + ("RESCRIPTUM_BOOT_DIR", "/srv/boot"), + ("RESCRIPTUM_TFTP_ADDR", "0.0.0.0:6969"), + ])); + assert_eq!(c.tftp_addr().as_deref(), Some("0.0.0.0:6969")); + assert!(!c.tftp_is_off()); + } + + #[test] + fn turning_tftp_off_needs_no_boot_directory_to_justify_it() { + // Off is off: refusing this would be refusing somebody who said "definitely not" + // before they said where anything lives. + let c = Config::from_lookup(lookup(&[("RESCRIPTUM_TFTP_ADDR", "off")])); + assert!(c.validate().is_ok(), "{:?}", c.validate()); + // But naming a real address with nowhere to serve from is still refused. + let c = Config::from_lookup(lookup(&[("RESCRIPTUM_TFTP_ADDR", "0.0.0.0:69")])); + assert!(c.validate().is_err()); + } + + #[test] + fn two_ephemeral_ports_are_not_a_collision() { + // `:0` asks the kernel for any free port, so two of them are never the same + // port. Refusing them would refuse the one configuration that cannot collide — + // and it is the one every integration test uses. + let c = Config::from_lookup(lookup(&[ + ("RESCRIPTUM_MEDIA_DIR", "/srv/media"), + ("RESCRIPTUM_MEDIA_ADDR", "127.0.0.1:0"), + ("RESCRIPTUM_LISTEN_ADDR", "127.0.0.1:0"), + ])); + assert!(c.validate().is_ok(), "{:?}", c.validate()); + } + + #[test] + fn the_public_host_refuses_to_be_a_url() { + // It is written into URLs for *two* listeners plus a bare address in a DHCP + // snippet. A value carrying one port would pin every generated script to one + // listener, and the symptom is a machine chaining into nowhere. + for (value, wrong) in [ + ("http://192.0.2.10", "a scheme"), + ("192.0.2.10:8001", "a port"), + ("192.0.2.10/boot", "a path"), + ] { + let c = Config::from_lookup(|key| { + (key == "RESCRIPTUM_PUBLIC_HOST").then(|| value.to_string()) + }); + let e = c.validate().expect_err("must refuse {value}"); + assert!(e.contains(wrong), "{value}: {e}"); + } + } + + #[test] + fn a_plain_host_or_an_ipv6_literal_is_accepted() { + for value in [ + "192.0.2.10", + "boot.example.com", + "[2001:db8::1]", + "2001:db8::1", + ] { + let c = Config::from_lookup(|key| { + (key == "RESCRIPTUM_PUBLIC_HOST").then(|| value.to_string()) + }); + assert!(c.validate().is_ok(), "{value} must be accepted"); + assert_eq!(c.public_host(), (value.to_string(), false)); + } + } + + #[test] + fn the_settings_table_shows_the_address_the_server_would_actually_use() { + // **A panel with an empty field here is showing something other than what the + // server does.** The value is derived at startup, so the table has to derive it + // too — the same treatment the CPU count already gets, and for the same reason. + let s = settings(None, |_| None); + let host = setting(&s, "RESCRIPTUM_PUBLIC_HOST"); + assert!(host.set, "a derived value is still a value in force"); + assert_eq!( + host.value, host.default, + "unset means the derived default is what is in force" + ); + assert_eq!( + host.value, + Config::from_lookup(|_| None).public_host().0.into(), + "and it is the same address the server itself would pick" + ); + } + + #[test] + fn without_a_default_route_a_single_interface_still_answers() { + // An isolated provisioning segment has no default route, which is exactly the + // network this server is most often put on. One address there is not a guess. + let one = vec!["10.0.0.4".to_string()]; + assert_eq!(choose_host(None, &one), Some("10.0.0.4".to_string())); + + // Two, and there is a real choice — one that only the operator can make. + let two = vec!["10.0.0.4".to_string(), "192.168.1.4".to_string()]; + assert_eq!(choose_host(None, &two), None); + assert_eq!(choose_host(None, &[]), None); + + // A route beats the interface list even when the list is unambiguous: the + // kernel knows which way traffic actually leaves. + assert_eq!( + choose_host(Some("172.16.0.9".to_string()), &two), + Some("172.16.0.9".to_string()) + ); + } + + #[test] + fn the_host_knows_what_addresses_it_has() { + // Loopback and link-local are excluded: the first is not reachable from a + // machine, and the second needs a scope that does not survive being written + // into a script. + let addresses = local_addresses(); + for address in &addresses { + assert!(!address.starts_with("127."), "{address} is loopback"); + assert!(!address.starts_with("169.254."), "{address} is link-local"); + assert!(!address.starts_with("fe80:"), "{address} is link-local"); + assert_ne!(address, "::1"); + } + // The derived host, when there is one, is one of them — it is chosen from this + // set by the routing table rather than invented. + if let Some(derived) = derive_public_host() + && !addresses.is_empty() + { + assert!( + addresses.contains(&derived), + "derived {derived} is not among {addresses:?}" + ); + } + } + + #[test] + fn a_derived_public_host_is_reported_as_derived() { + // Derivation is wrong often enough on multi-homed and NAT hosts that it is a + // warning rather than a silent success — the flag is what makes it sayable. + let (_host, derived) = Config::from_lookup(lookup(&[])).public_host(); + assert!(derived); + } + + #[test] + #[cfg(feature = "boot")] + fn each_generated_url_carries_its_own_listeners_port() { + // The whole reason the variable is a host: one value, two listeners. + let c = Config::from_lookup(lookup(&[ + ("RESCRIPTUM_PUBLIC_HOST", "192.0.2.10"), + ("RESCRIPTUM_MEDIA_DIR", "/srv/media"), + ])); + let endpoints = c.endpoints(); + assert_eq!(endpoints.answer, "http://192.0.2.10:8000"); + assert_eq!(endpoints.media, "http://192.0.2.10:8001"); + } + + #[test] + #[cfg(feature = "boot")] + fn an_ipv6_host_is_bracketed_before_a_port_is_appended() { + let c = Config::from_lookup(lookup(&[ + ("RESCRIPTUM_PUBLIC_HOST", "2001:db8::1"), + ("RESCRIPTUM_MEDIA_DIR", "/srv/media"), + ])); + assert_eq!(c.endpoints().media, "http://[2001:db8::1]:8001"); + } + + #[test] + fn media_tuning_falls_back_the_way_everything_else_does() { + let c = Config::from_lookup(lookup(&[ + ("RESCRIPTUM_MEDIA_DIR", "/srv/media"), + ("RESCRIPTUM_MEDIA_TIMEOUT_SECS", "0"), + ("RESCRIPTUM_MEDIA_MAX_CONNECTIONS", "plenty"), + ])); + assert_eq!(c.media_timeout, Duration::from_secs(600)); + assert_eq!(c.media_max_connections, 16); + // And the default is deliberately not the answer listener's ten seconds: a + // 1.5 GB transfer is two minutes on 100 Mbit, and it would be killed mid-flight. + assert!(c.media_timeout > c.timeout); + } + // ---- the described surface ------------------------------------------- #[test] diff --git a/src/envfile.rs b/src/envfile.rs index 4762d8f..7ac9b18 100644 --- a/src/envfile.rs +++ b/src/envfile.rs @@ -28,7 +28,7 @@ use std::path::{Path, PathBuf}; pub const ENV_FILE: &str = "RESCRIPTUM_ENV_FILE"; /// Every variable this program reads, so a typo can be reported rather than ignored. -pub const KNOWN_KEYS: [&str; 13] = [ +pub const KNOWN_KEYS: [&str; 30] = [ "RESCRIPTUM_STORE", "RESCRIPTUM_ANSWERS_DIR", "RESCRIPTUM_DB_PATH", @@ -42,6 +42,23 @@ pub const KNOWN_KEYS: [&str; 13] = [ "RESCRIPTUM_CAPTURE_DIR", "RESCRIPTUM_LOG", "RESCRIPTUM_LOG_FILE", + "RESCRIPTUM_PUBLIC_HOST", + "RESCRIPTUM_MEDIA_DIR", + "RESCRIPTUM_MEDIA_ADDR", + "RESCRIPTUM_MEDIA_TIMEOUT_SECS", + "RESCRIPTUM_MEDIA_MAX_CONNECTIONS", + "RESCRIPTUM_BOOT_ALLOW", + "RESCRIPTUM_BOOT_DIR", + "RESCRIPTUM_TFTP_ADDR", + "RESCRIPTUM_BOOT_TIMEOUT_SECS", + "RESCRIPTUM_INSTALLED_TOKEN", + "RESCRIPTUM_TFTP_PORT_RANGE", + "RESCRIPTUM_TFTP_BLKSIZE", + "RESCRIPTUM_BOOT_UNCLAIMED", + "RESCRIPTUM_BOOT_LOGO", + "RESCRIPTUM_BOOT_TITLE", + "RESCRIPTUM_USER", + "RESCRIPTUM_GROUP", ]; /// A loaded file: the values it set, and anything worth saying about it out loud. diff --git a/src/format/mod.rs b/src/format/mod.rs index fff5e27..43fe574 100644 --- a/src/format/mod.rs +++ b/src/format/mod.rs @@ -120,6 +120,33 @@ pub fn endpoint_formats(segment: &str) -> Option<&'static [&'static str]> { }) } +/// The filename to write a new document under, before the extension. +/// +/// **The stem decides nothing.** A document's format is its extension, and its identity +/// is the directory it sits in; `proxmox.toml` and `answer.toml` are the same document +/// to this server. This only picks a readable name for one nobody has named themselves +/// — a document already on disk keeps whatever it is called. +/// +/// The names are the endpoint aliases wherever there is one, so a directory listing +/// reads the way the URLs do. `boot` is the exception and deliberately not an alias: an +/// `.ipxe` document is what boots the installer, not an operating system. +pub fn canonical_stem(ext: &str) -> &'static str { + match ext.to_ascii_lowercase().as_str() { + "toml" => "proxmox", + "yaml" | "yml" => "ubuntu", + "ign" => "flatcar", + "autoyast" => "suse", + "unattend" => "windows", + "ks" => "rhel", + "preseed" | "seed" => "debian", + "ipxe" => "boot", + // Nothing more specific to say: `json` is Ignition *or* a plain document, and + // `xml`/`cfg` name no single installer. `answer.json` beats `json.json`, and an + // extension we do not serve never reaches a write but is still owed a name. + _ => "answer", + } +} + #[derive(Debug, Clone)] enum Inner { Toml(toml_edit::DocumentMut), @@ -723,6 +750,30 @@ mod tests { } } + /// A canonical stem must never be an endpoint alias for a *different* format. + /// + /// The stem means nothing to this server — but somebody reading `ubuntu.yaml` in a + /// directory will read it as "this machine as Ubuntu", and a table that wrote + /// `debian.yaml` would be lying to them in the one place they look. Either the name + /// is an alias that resolves to this very extension, or it is not an alias at all. + #[test] + fn a_canonical_stem_never_names_another_format() { + for ext in [ + "toml", "yaml", "yml", "json", "ign", "xml", "autoyast", "unattend", "ks", "preseed", + "seed", "cfg", "ipxe", + ] { + let stem = canonical_stem(ext); + assert!(!stem.is_empty(), "{ext} has no name to be written under"); + if let Some(formats) = endpoint_formats(stem) { + assert!( + formats.contains(&ext), + ".{ext} would be written as {stem}.{ext}, and {stem:?} is the endpoint \ + for {formats:?}" + ); + } + } + } + /// Every alias the table names, with a format its installer would actually expect. /// /// The whole table rather than a sample: these are URLs baked into ISOs, so a diff --git a/src/installed.rs b/src/installed.rs new file mode 100644 index 0000000..e9f4d84 --- /dev/null +++ b/src/installed.rs @@ -0,0 +1,354 @@ +//! A machine reporting that it finished installing, and the claim on it being dropped. +//! +//! ## The loop this exists to close +//! +//! A machine is claimed for installation by an `.ipxe` answer named after it: the loader +//! asks `/ipxe/boot?mac=…`, gets a script that boots an installer, and installs. Then it +//! reboots — and if netboot is still first in its firmware's order, it arrives back at the +//! same question, gets the same script, and installs again. Forever, wiping the disk each +//! time. +//! +//! Every provisioning system answers this the same way: a machine is *armed* for install, +//! and something disarms it afterwards. The only question is who. Doing it by hand works +//! and is what this project shipped first, but it is a race — the machine reboots the +//! moment the installer finishes, so the window to rename a file is however long the +//! firmware takes to come back. +//! +//! **The machine is the one that knows.** Proxmox's answer format has a +//! `[post-installation-webhook]`, called with a JSON body **after a successful install and +//! before the reboot**, and that body carries the network interfaces — MAC addresses +//! included. So it is the same shape as the request that asked for the answer in the first +//! place, and `Facts` reads it with no new parsing at all. +//! +//! ## What it will and will not touch +//! +//! Narrow on purpose, because this is the one code path that changes the answer set in +//! response to something arriving over the network: +//! +//! - **Machine documents only.** Never a group, never a `default`. A group claims a whole +//! rack, and one machine finishing its install must never be able to disarm its +//! neighbours. This does not filter a resolution down to machines — it never looks at +//! groups at all. +//! - **Format `ipxe` only.** That is the document that boots an installer. A machine's +//! `.toml` is what the installer *reads once running*, and deleting it would take away +//! the record of how the machine was built. +//! - **Moved, not deleted.** The document is re-put under an `installed-` prefix, which no +//! longer matches the machine (the prefix is part of the normalized needle), and the +//! original is removed. Re-arming is moving it back. Nothing is destroyed, which +//! matters for a thing triggered by a network request. +//! +//! With a directory per identity that prefix names a **sibling directory** — +//! `installed-98-fa-9b-50-d8-10/boot.ipxe` — rather than a file inside the machine's +//! own. That is deliberate twice over: the machine's directory stays the machine's +//! configuration, and no new rule is needed to keep the disarmed document from +//! answering, because it is the directory name that identifies a machine and this one +//! identifies nothing. +//! +//! ## Off unless configured +//! +//! No token, no endpoint — not an open one, absent. The token is Proxmox's own +//! `auth-token`, which it puts in the body as a top-level `token` field rather than in a +//! header (unlike the answer token, which is a bearer). Compared in constant time, for the +//! same reason the admin API's is. + +use crate::facts::Facts; +use crate::select::{Answers, normalize}; +use crate::store::StoreWrite; +use std::io; + +/// The prefix a disarmed document is moved under. +/// +/// It has to be a **prefix**, not a suffix: matching is a substring test of the +/// normalized id against the normalized request, so `98fa9b50d810installed` would still +/// contain nothing the machine sends — but neither would a suffix survive somebody adding +/// a second one. A prefix reads as a state in a directory listing, which is what an +/// operator wants when they come to re-arm it. +pub const DISARMED: &str = "installed-"; + +/// What was done, for the log line and the response. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Disarmed { + /// The machine documents that were claiming this machine, and where each went. + pub moved: Vec<(String, String)>, +} + +impl Disarmed { + pub fn describe(&self) -> String { + if self.moved.is_empty() { + "nothing was claiming it".to_string() + } else { + self.moved + .iter() + .map(|(from, to)| format!("{from} -> {to} (ipxe)")) + .collect::>() + .join(", ") + } + } +} + +/// Whether the body's `token` field is the one configured, compared in constant time. +/// +/// An ordinary `==` returns on the first differing byte, which hands the token over one +/// byte at a time to anyone who can time the responses. The admin API learned this +/// already; there is no reason for a second place to learn it again. +pub fn token_matches(body: &[u8], expected: &str) -> bool { + let Some(found) = token_in(body) else { + return false; + }; + constant_time_eq(found.as_bytes(), expected.as_bytes()) +} + +/// The `token` field, read from the JSON body as an untyped value. +/// +/// No derive, no struct: the same rule the answer path follows. Proxmox documents this +/// body's contents as liable to grow, and a type here would be an assumption about a +/// schema that is not ours. +fn token_in(body: &[u8]) -> Option { + let value: serde_json::Value = serde_json::from_slice(body).ok()?; + value.get("token")?.as_str().map(str::to_string) +} + +fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { + // Length is not secret — a token's length leaks from the request size anyway — but + // the comparison still runs over the whole of the longer one so that an early return + // never depends on content. + let mut diff = (a.len() ^ b.len()) as u8; + let n = a.len().max(b.len()); + for i in 0..n { + let x = a.get(i).copied().unwrap_or(0); + let y = b.get(i).copied().unwrap_or(0); + diff |= x ^ y; + } + diff == 0 +} + +/// Which machine documents claim this machine for installation. +/// +/// The identity rule, and only the identity rule: a document whose normalized id appears +/// in what the machine sent. That is the same test selection uses, written out here +/// rather than borrowed, because borrowing `resolve` would bring groups, defaults and +/// format aliasing along with it — and every one of those is something this must not act +/// on. +fn claiming(answers: &Answers, facts: &Facts) -> io::Result> { + let haystack = facts.haystack(); + let mut out = Vec::new(); + for (id, format) in answers.machine_documents()? { + if format != "ipxe" { + continue; + } + let needle = normalize(id.as_bytes()); + // An empty needle would match everything, which is how one badly named document + // disarms a fleet. It cannot happen through the file store — a name that + // normalizes to nothing has no alphanumerics — but this is the wrong place to + // rely on that. + if !needle.is_empty() && haystack.contains(&needle) { + out.push((id, format)); + } + } + Ok(out) +} + +/// Drop the claim, having been told by the machine that it is installed. +/// +/// Returns what moved. **Nothing matching is a success, not an error**: the webhook is +/// allowed to arrive twice, and the second time there is simply nothing left to do. +pub fn disarm(answers: &Answers, store: &dyn StoreWrite, facts: &Facts) -> io::Result { + let mut moved = Vec::new(); + for (id, format) in claiming(answers, facts)? { + let body = read_machine(store, &id, &format)?; + let Some(body) = body else { continue }; + let to = format!("{DISARMED}{id}"); + // **Put before delete.** If the put fails the machine stays armed and the caller + // is told, which is the safe half of the failure: an install that happens twice + // is recoverable, an answer document that vanished is not. + store.put_machine(&to, &format, &body)?; + store.delete_machine(&id, &format)?; + moved.push((id, to)); + } + Ok(Disarmed { moved }) +} + +fn read_machine(store: &dyn StoreWrite, id: &str, format: &str) -> io::Result> { + Ok(store + .snapshot()? + .machines + .into_iter() + .find(|m| m.id == id && m.format == format) + .map(|m| m.body)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::store::file::FileStore; + use std::sync::Arc; + + fn scratch(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!( + "rescriptum-installed-{}-{name}-{:?}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.subsec_nanos()) + .unwrap_or(0) + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("scratch"); + dir + } + + /// A webhook body the shape Proxmox documents: the interfaces, with their MACs. + fn webhook(mac: &str) -> Vec { + format!( + r#"{{"token":"s3cr3t","fqdn":"node01.example.com", + "network_interfaces":[{{"name":"eno1","mac":"{mac}"}}], + "disks":[{{"path":"/dev/sda","size":512110190592}}]}}"# + ) + .into_bytes() + } + + /// Write one document where the layout keeps it: `.` names the machine and + /// the format, and the file inside its directory is named for us. + fn document(dir: &std::path::Path, name: &str, body: &str) -> std::path::PathBuf { + let named = std::path::Path::new(name); + let (id, ext) = ( + named.file_stem().unwrap().to_str().unwrap(), + named.extension().unwrap().to_str().unwrap(), + ); + let dir = dir.join(id); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join(format!("{}.{ext}", crate::format::canonical_stem(ext))); + std::fs::write(&path, body).unwrap(); + path + } + + fn answers_for(dir: &std::path::Path) -> (Answers, Arc) { + let store = Arc::new(FileStore::new(dir)); + (Answers::new(store.clone()), store) + } + + #[test] + fn the_machine_that_reported_stops_being_claimed() { + let dir = scratch("basic"); + document(&dir, "98-fa-9b-50-d8-10.ipxe", "#!ipxe\nchain x\n"); + document(&dir, "98-fa-9b-50-d8-10.toml", "[global]\n"); + let (answers, store) = answers_for(&dir); + + let facts = Facts::new(None, &webhook("98:fa:9b:50:d8:10")); + let done = disarm(&answers, store.as_ref(), &facts).expect("disarm"); + assert_eq!( + done.moved, + vec![( + "98-fa-9b-50-d8-10".to_string(), + "installed-98-fa-9b-50-d8-10".to_string() + )] + ); + + // The claim is gone… + assert!(!dir.join("98-fa-9b-50-d8-10/boot.ipxe").exists()); + // …the document is not, and re-arming is moving it back. + assert!(dir.join("installed-98-fa-9b-50-d8-10/boot.ipxe").exists()); + // **And the machine's own answer is untouched.** Deleting it would throw away the + // record of how this machine was built, and the installer is the thing that reads + // it — not the loader. It is also why the disarmed document goes to a directory + // of its own: the machine's directory is still the machine's. + assert!(dir.join("98-fa-9b-50-d8-10/proxmox.toml").exists()); + } + + #[test] + fn a_disarmed_document_no_longer_claims_the_machine() { + // The property the prefix exists for. Without it the rename is decoration and the + // machine reinstalls anyway, which is the whole failure being fixed. + let dir = scratch("nomatch"); + document(&dir, "98-fa-9b-50-d8-10.ipxe", "#!ipxe\n"); + let (answers, store) = answers_for(&dir); + let facts = Facts::new(None, &webhook("98:fa:9b:50:d8:10")); + + disarm(&answers, store.as_ref(), &facts).expect("disarm"); + let (answers, store) = answers_for(&dir); + let again = disarm(&answers, store.as_ref(), &facts).expect("second"); + assert!( + again.moved.is_empty(), + "the moved document still matches: {:?}", + again.moved + ); + } + + #[test] + fn a_group_is_never_touched_however_it_matches() { + // **The one that would be a disaster.** A group claims a rack; one machine + // finishing its install must not disarm its neighbours. This is why the lookup + // never consults groups rather than filtering them out afterwards. + let dir = scratch("group"); + document( + &dir.join("groups"), + "rack-a.ipxe", + "# answer: members = 98:fa:9b:50:d8:10\n#!ipxe\n", + ); + std::fs::create_dir_all(dir.join("default")).unwrap(); + std::fs::write(dir.join("default/boot.ipxe"), "#!ipxe\n").unwrap(); + let (answers, store) = answers_for(&dir); + + let facts = Facts::new(None, &webhook("98:fa:9b:50:d8:10")); + + // **First prove the fixture bites.** Without this the assertion below passes for + // a group that was never loaded, which would report a guarantee that does not + // exist — the exact shape of test this project has been caught by before. + let claimed = answers + .resolve(&facts) + .expect("resolve") + .expect("the group must claim this machine"); + assert_eq!(claimed.group.as_deref(), Some("rack-a")); + + let done = disarm(&answers, store.as_ref(), &facts).expect("disarm"); + assert!(done.moved.is_empty(), "{:?}", done.moved); + assert!(dir.join("groups/rack-a/boot.ipxe").exists()); + assert!(dir.join("default/boot.ipxe").exists()); + } + + #[test] + fn a_machine_nothing_claims_is_not_an_error() { + // The webhook may arrive twice, and a machine may have been installed from the + // menu rather than from a claim. Neither is a failure. + let dir = scratch("none"); + document(&dir, "aa-bb-cc-dd-ee-ff.ipxe", "#!ipxe\n"); + let (answers, store) = answers_for(&dir); + let facts = Facts::new(None, &webhook("98:fa:9b:50:d8:10")); + let done = disarm(&answers, store.as_ref(), &facts).expect("disarm"); + assert!(done.moved.is_empty()); + assert!(dir.join("aa-bb-cc-dd-ee-ff/boot.ipxe").exists()); + } + + #[test] + fn the_token_is_read_from_the_body_and_compared_whole() { + // Proxmox puts it in the JSON body as a top-level `token`, not in a header — + // unlike the answer token, which is a bearer. Fetched from the wiki rather than + // remembered, because getting this wrong is an endpoint nothing can authenticate. + assert!(token_matches(&webhook("98:fa:9b:50:d8:10"), "s3cr3t")); + assert!(!token_matches(&webhook("98:fa:9b:50:d8:10"), "s3cr3")); + assert!(!token_matches(&webhook("98:fa:9b:50:d8:10"), "s3cr3t ")); + assert!(!token_matches(&webhook("98:fa:9b:50:d8:10"), "")); + // Not JSON at all, and a body with no token: both are a refusal, never a pass. + assert!(!token_matches(b"not json", "s3cr3t")); + assert!(!token_matches(br#"{"fqdn":"x"}"#, "s3cr3t")); + } + + #[test] + fn constant_time_comparison_agrees_with_the_ordinary_one() { + for (a, b) in [ + ("", ""), + ("a", "a"), + ("a", "b"), + ("", "a"), + ("a", ""), + ("abcdef", "abcdeg"), + ("abcdef", "abcdef"), + ] { + assert_eq!( + constant_time_eq(a.as_bytes(), b.as_bytes()), + a == b, + "{a:?} vs {b:?}" + ); + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 635104a..baae7c6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,12 +5,16 @@ //! for the design constraints. pub mod admin; +#[cfg(feature = "boot")] +pub mod boot; pub mod capture; pub mod cli; pub mod config; pub mod envfile; pub mod facts; pub mod format; +/// A machine reporting that it finished installing, and the claim being dropped. +pub mod installed; pub mod log; pub mod merge; pub mod select; diff --git a/src/main.rs b/src/main.rs index 4302675..130dfaa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -84,6 +84,9 @@ fn main() -> ExitCode { Some((cmd, _)) if cmd == "check" => return cli::check(&cfg), Some((cmd, rest)) if cmd == "import" => return cli::import(&cfg, rest), Some((cmd, rest)) if cmd == "export" => return cli::export(&cfg, rest), + Some((cmd, rest)) if cmd == "migrate" => return cli::migrate(&cfg, rest), + Some((cmd, rest)) if cmd == "media" => return cli::media(&cfg, rest), + Some((cmd, rest)) if cmd == "boot" => return cli::boot(&cfg, rest), Some((cmd, _)) => { eprintln!("unknown argument {cmd:?}\n"); eprint!("{}", cli::USAGE); @@ -155,6 +158,157 @@ async fn serve(cfg: Arc) -> ExitCode { tokio::spawn(rescriptum::admin::serve(admin_listener, admin)); } + // The media listener, if a media directory was named. Its own socket, its own + // timeout and its own connection budget — see `boot::media` for why all three are + // forced rather than preferred. + #[cfg(feature = "boot")] + if let Some(dir) = cfg.media_dir.clone() { + let addr = cfg.media_addr(); + let media_listener = match TcpListener::bind(&addr).await { + Ok(l) => l, + Err(e) => { + log::server(&format!("cannot bind the media listener on {addr}: {e}")); + return ExitCode::FAILURE; + } + }; + let bound = media_listener + .local_addr() + .map(|a| a.to_string()) + .unwrap_or(addr); + + // Said out loud because it is the value every generated script is written + // against, and a wrong guess here produces a machine that boots, chains, and + // hangs on an address that does not exist. This log line is the only place the + // answer will ever appear. + let (host, derived) = cfg.public_host(); + if derived { + // Naming the alternatives is what makes this actionable. A generic "this + // might be wrong" sends an operator off to look at interfaces; a list they + // can read in place tells them in one glance whether the guess is the + // address their machines can reach. + let others: Vec = config::local_addresses() + .into_iter() + .filter(|a| *a != host) + .collect(); + if others.is_empty() { + log::server(&format!( + "RESCRIPTUM_PUBLIC_HOST is not set — using {host}, the only address this \ + host has. Every generated URL will name it." + )); + } else { + log::server(&format!( + "warning: RESCRIPTUM_PUBLIC_HOST is not set — derived {host}, which is \ + what every generated URL will name. This host also has {}. If the \ + machines reach it on one of those instead, set it explicitly.", + others.join(", ") + )); + } + } + log::server(&format!( + "media listening on {bound} — serving {} as http://{host}", + dir.display() + )); + + let catalog = Arc::new(rescriptum::boot::catalog::Catalog::new(dir.clone())); + // Load it now rather than on the first request, so a broken catalogue is known + // before a machine asks rather than at 3am. + for problem in catalog.problems().unwrap_or_default() { + log::server(&format!("warning: media: {problem}")); + } + let media = Arc::new(rescriptum::boot::media::Media { + cfg: Arc::clone(&cfg), + catalog, + }); + tokio::spawn(rescriptum::boot::media::serve(media_listener, media)); + } + + // TFTP, if a boot directory was named. It hands over **one file** — the loader — + // and everything after that is HTTP: at 1468 bytes a round-trip, an image would + // take twenty minutes where HTTP takes fifteen seconds. + #[cfg(feature = "boot")] + if let Some(dir) = cfg.boot_dir.clone() { + let tftp = match rescriptum::boot::tftp::Tftp::new(&dir, Arc::clone(&cfg)) { + Ok(tftp) => Arc::new(tftp), + // Fatal: a boot directory that cannot be resolved is not something that + // fixes itself, and every path check below compares against it. + Err(e) => { + log::server(&format!("configuration error: {e}")); + return ExitCode::FAILURE; + } + }; + // **Off is a value, not an absence.** It is how an operator says another daemon + // hands the loader over while rescriptum serves the rest of the chain — a + // deployment workaround, never what a package ships with. Said once at startup, + // because "where did my TFTP go" is otherwise a silent question. + match cfg.tftp_addr() { + None => log::server(&format!( + "tftp is off — {} is still served over HTTP at /boot/ and still checked \ + by `boot check`, but something else has to hand the loader over", + tftp.root().display() + )), + Some(addr) => { + let socket = match tokio::net::UdpSocket::bind(&addr).await { + Ok(socket) => Some(socket), + // **This is the one listener whose failure to bind does not end the + // server, and the reason is measured rather than argued.** Every + // other one here is fatal, on the rule that a server which accepts + // and never answers is worse than one that does not start. TFTP is + // where that rule inverts: port 69 is privileged — the only + // privileged port in the whole design — so this bind is the only one + // that can fail for a reason nobody configured. On a DSM 7.2.2 + // machine the capability is granted by a `setcap` outside the + // package, and **an upgrade replaces the binary and silently drops + // it**; 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 this degrades instead — loudly, in three places at + // once: this line, `boot check`'s non-zero exit, and the settings + // panel. **The failure mode being refused is the silent one, not the + // degraded one**, and a warning nobody can miss is not silent. + Err(e) => { + log::server(&format!( + "warning: cannot bind TFTP on {addr}: {e}{}. Answers and media \ + are unaffected and {} is still served over HTTP at /boot/, \ + but nothing here hands a loader over UDP — a machine sent to \ + this server by DHCP will ask and get nothing", + if addr.ends_with(":69") { + " — port 69 is privileged. Run as root and set \ + RESCRIPTUM_USER to drop afterwards, or grant the binary \ + cap_net_bind_service with setcap" + } else { + "" + }, + tftp.root().display() + )); + None + } + }; + if let Some(socket) = socket { + let bound = socket + .local_addr() + .map(|a| a.to_string()) + .unwrap_or_else(|_| addr.clone()); + log::server(&format!( + "tftp listening on {bound} — serving {}", + tftp.root().display() + )); + tokio::spawn(rescriptum::boot::tftp::serve(socket, tftp)); + } + } + } + } + + // **Bind everything first, then drop.** The other order works as root in testing + // and fails on deployment, which is the bug this ordering exists to prevent. + #[cfg(feature = "boot")] + if let Err(e) = rescriptum::boot::privileges::drop_to(cfg.user.as_deref(), cfg.group.as_deref()) + { + log::server(&format!("configuration error: {e}")); + return ExitCode::FAILURE; + } + // Report the address actually bound, not the one requested: with `:0` (used by the // integration tests, and handy for debugging) they differ. let bound = listener @@ -234,10 +388,11 @@ async fn serve(cfg: Arc) -> ExitCode { let cfg = Arc::clone(&cfg); let answers = Arc::clone(&answers); + let store = Arc::clone(&store); let capture = Arc::clone(&capture); tokio::spawn(async move { let _permit = permit; // released when the connection ends - connection(stream, peer.to_string(), cfg, answers, capture).await; + connection(stream, peer.to_string(), cfg, answers, store, capture).await; }); } } @@ -304,6 +459,7 @@ async fn connection( peer: String, cfg: Arc, answers: Arc, + store: Arc, capture: Arc>, ) { let timeout = cfg.timeout; @@ -312,9 +468,10 @@ async fn connection( let service = service_fn(move |req| { let cfg = Arc::clone(&cfg); let answers = Arc::clone(&answers); + let store = Arc::clone(&store); let capture = Arc::clone(&capture); let peer = peer.clone(); - async move { Ok::<_, Infallible>(handle(req, cfg, answers, capture, peer).await) } + async move { Ok::<_, Infallible>(handle(req, cfg, answers, store, capture, peer).await) } }); // `header_read_timeout` is the slowloris guard: a client that opens a socket and @@ -341,10 +498,81 @@ async fn connection( } } +/// `POST /installed` — a machine saying it finished, and its claim being dropped. +/// +/// Answers `200` whether or not anything was claiming it: the webhook may arrive twice, +/// and a machine installed from the menu was never claimed at all. Neither is a failure, +/// and a `4xx` here would read to an operator as "the thing did not work". +async fn installed( + req: Request, + expected: String, + bearer: bool, + query: Option, + answers: Arc, + store: Arc, + peer: String, +) -> Response { + // Read the body before answering, always: closing on a peer that is still writing + // earns a connection reset instead of the response. + let body = match Limited::new(req.into_body(), MAX_BODY).collect().await { + Ok(collected) => collected.to_bytes(), + Err(_) => { + log::request(&peer, 400, "POST /installed 400 body"); + return text(StatusCode::BAD_REQUEST, "400 Bad Request\n"); + } + }; + + // **Either credential, because the callers differ.** Proxmox puts its `auth-token` in + // the JSON body and cannot be told to send a header; a `curl` in a `%post` sends a + // header and would have to be talked into composing JSON. Both are the same secret, + // and both are compared without an early return. + if !bearer && !rescriptum::installed::token_matches(&body, &expected) { + // Logged and never rate-limited, for the reason the answer token is not: a rack + // sits behind one address, and shutting it out would turn a bad token into a + // fleet that reinstalls itself forever. + log::request(&peer, 401, "POST /installed 401 bad or missing token"); + return text(StatusCode::UNAUTHORIZED, "401 Unauthorized\n"); + } + + let facts = Facts::from_request(None, query.as_deref(), &body); + // Blocking: the file store reads and renames. Doing that on an async worker stalls + // every other connection that thread is driving. + let result = tokio::task::spawn_blocking(move || { + rescriptum::installed::disarm(&answers, store.as_ref(), &facts) + }) + .await; + + match result { + Ok(Ok(done)) => { + let said = done.describe(); + log::request(&peer, 200, &format!("POST /installed 200 {said}")); + text(StatusCode::OK, format!("{said}\n")) + } + // **Loud, because the consequence is otherwise silent.** A disarm that failed + // leaves the machine armed, so it installs again on its next boot — and this line + // is the only place that could be noticed. + Ok(Err(e)) => { + log::request(&peer, 500, &format!("POST /installed 500 still armed: {e}")); + text( + StatusCode::INTERNAL_SERVER_ERROR, + "500 Internal Server Error\n", + ) + } + Err(e) => { + log::request(&peer, 500, &format!("POST /installed 500 {e}")); + text( + StatusCode::INTERNAL_SERVER_ERROR, + "500 Internal Server Error\n", + ) + } + } +} + async fn handle( req: Request, cfg: Arc, answers: Arc, + store: Arc, capture: Arc>, peer: String, ) -> Response { @@ -361,6 +589,30 @@ async fn handle( return text(StatusCode::OK, "OK\n"); } + // **The install-finished webhook, checked before the bearer guard below on purpose.** + // Proxmox authenticates this callback with a token it puts *in the JSON body*, not in + // an Authorization header — a different credential, from a different caller, for a + // different purpose. Running it through the answer token's guard would reject every + // webhook the moment an operator set an answer token. + // + // The path is reserved **only when the feature is configured**, so a deployment that + // never sets the token keeps the "POST on any path is an answer request" contract + // whole. That contract is why a URL can be baked into an ISO. + if method == Method::POST + && path == "/installed" + && let Some(expected) = cfg.installed_token.clone() + { + // The query goes in as identity too, not only the body. Proxmox puts the machine's + // interfaces in its webhook body and needs nothing else; **every other family + // reports back from a shell script** — a kickstart `%post`, a preseed + // `late_command`, an autoinstall `late-commands`, an AutoYaST chroot script — and + // there `?mac=…` is a line somebody can write, where composing the same JSON is a + // line they will get wrong. + let query = req.uri().query().map(str::to_string); + let bearer = bearer_matches(&req, &expected); + return installed(req, expected, bearer, query, answers, store, peer).await; + } + // An installer that was given a token must present it. Proxmox does when its ISO // was prepared with `--answer-auth-token`; nothing else can, which is why this is // off unless configured. What it guards is the root password hash and the SSH keys diff --git a/src/select.rs b/src/select.rs index 8560ab1..7b24f70 100644 --- a/src/select.rs +++ b/src/select.rs @@ -596,9 +596,12 @@ fn build(snapshot: Snapshot) -> Listing { .machines .into_iter() .filter_map(|m| { - let kind = kind_of(&m.format, &m.id, &mut problems)?; + let kind = kind_of(&m.format, &m.origin, &mut problems)?; let format = m.format.clone(); - let doc = Doc::parse(kind, &m.body, &m.id); + // The origin, not the id: with a directory per identity the filename is the + // operator's to choose, so "98fa9b50d810" would not say which document in it + // failed to parse. + let doc = Doc::parse(kind, &m.body, &m.origin); if let Err(e) = &doc { problems.push(e.clone()); } @@ -644,7 +647,7 @@ fn build(snapshot: Snapshot) -> Listing { .fallbacks .into_iter() .filter_map(|d| { - let what = format!("default.{}", d.format); + let what = d.origin.clone(); let kind = kind_of(&d.format, &what, &mut problems)?; let doc = Doc::parse(kind, &d.body, &what); match &doc { @@ -696,21 +699,54 @@ mod tests { &self.0 } + /// Write one document, named the way a test thinks of it — `.` — into + /// the directory the layout actually stores it in. + /// + /// The translation lives here rather than in every test because what these + /// fixtures are about is *which machine, in which format*; the filename inside + /// the directory is not a thing any of them mean to assert. fn write(&self, name: &str, contents: &str) -> PathBuf { - let p = self.0.join(name); - fs::write(&p, contents).expect("write fixture"); - p + self.document(&self.0, name) + .tap(|p| fs::write(p, contents).expect("write fixture")) } fn group(&self, name: &str, contents: &str) -> PathBuf { let dir = self.0.join(crate::store::file::GROUPS_DIR); - fs::create_dir_all(&dir).expect("create groups dir"); - let p = dir.join(format!("{name}.toml")); - fs::write(&p, contents).expect("write group fixture"); - p + self.document(&dir, &format!("{name}.toml")) + .tap(|p| fs::write(p, contents).expect("write group fixture")) + } + + /// `.` under `parent` becomes `//.`. + fn document(&self, parent: &Path, name: &str) -> PathBuf { + let named = Path::new(name); + let (identity, file) = match (named.file_stem(), named.extension()) { + (Some(stem), Some(ext)) => { + let ext = ext.to_str().expect("utf-8 extension"); + ( + stem.to_str().expect("utf-8 stem").to_string(), + format!("{}.{ext}", crate::format::canonical_stem(ext)), + ) + } + // No extension at all: the whole name is the identity, and the document + // inside carries it too — so "this is not a candidate" still holds for + // the same reason it did. + _ => (name.to_string(), name.to_string()), + }; + let dir = parent.join(identity); + fs::create_dir_all(&dir).expect("create identity dir"); + dir.join(file) } } + /// Do something with a value and hand it back. Keeps a fixture one expression. + trait Tap: Sized { + fn tap(self, f: impl FnOnce(&Self)) -> Self { + f(&self); + self + } + } + impl Tap for PathBuf {} + impl Drop for TempDir { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.0); @@ -1111,7 +1147,7 @@ mod tests { } #[test] - fn a_removed_file_stops_being_served() { + fn a_removed_machine_stops_being_served() { let dir = TempDir::new(); let answers = Answers::from_dir(dir.path()); let path = dir.write("98fa9b50d810.toml", "[global]\nx = 1\n"); @@ -1123,7 +1159,10 @@ mod tests { .unwrap() .is_some() ); - fs::remove_file(&path).expect("remove fixture"); + // The whole identity, which is what removing a machine now means. Its directory + // leaving the answers directory moves that directory's mtime, so this is picked + // up at once rather than at the backstop. + fs::remove_dir_all(path.parent().unwrap()).expect("remove fixture"); assert!( answers .resolve(&Facts::new(None, body.as_bytes())) @@ -1132,6 +1171,39 @@ mod tests { ); } + /// One document leaving a machine that keeps others — the case the mtime cannot see. + /// + /// A directory's mtime moves when an entry is added or removed *in it*, and this + /// happens one level down, inside the machine's own directory. So the answers + /// directory looks untouched and only `RELOAD_BACKSTOP` notices, exactly as it + /// already did for a file edited in place. Worth a slow test: the alternative is a + /// stat per machine on every request, which is what the cache exists to avoid. + #[test] + fn a_document_removed_from_a_machine_stops_being_served_within_the_backstop() { + let dir = TempDir::new(); + let answers = Answers::from_dir(dir.path()); + let path = dir.write("98fa9b50d810.toml", "[global]\nx = 1\n"); + dir.write("98fa9b50d810.ipxe", "#!ipxe\nchain x\n"); + let body = body_with("98:fa:9b:50:d8:10"); + // Asked for on the Proxmox endpoint, so the machine's `.ipxe` cannot stand in + // for the document that was removed. + let toml = || Facts::from_request(Some("/proxmox/answer"), None, body.as_bytes()); + + assert!(answers.resolve(&toml()).unwrap().is_some()); + fs::remove_file(&path).expect("remove fixture"); + + // Poll rather than sleep exactly once, so a slow machine does not make this + // flaky and a fast one does not make it slow. + let deadline = Instant::now() + RELOAD_BACKSTOP * 3; + while Instant::now() < deadline { + if answers.resolve(&toml()).unwrap().is_none() { + return; + } + std::thread::sleep(Duration::from_millis(50)); + } + panic!("the removed document was still being served after the backstop"); + } + #[test] fn repeated_lookups_are_consistent() { let dir = TempDir::new(); diff --git a/src/store/file.rs b/src/store/file.rs index 4a55c66..9141e12 100644 --- a/src/store/file.rs +++ b/src/store/file.rs @@ -1,19 +1,52 @@ -//! A directory of TOML files — the original layout, and still the right one when +//! A directory of answer documents — the original layout, and still the right one when //! dropping a file onto a NAS is all the administration you need. +//! +//! **One directory per identity.** A machine is a directory named after it, holding one +//! document per format; groups and the fallback are the same shape, under names the +//! layout reserves: +//! +//! ```text +//! answers/ +//! groups/ +//! rack-a/ +//! proxmox.toml +//! debian.preseed +//! default/ +//! proxmox.toml +//! 98-fa-9b-50-d8-10/ +//! proxmox.toml the machine as Proxmox +//! debian.preseed the same hardware as Debian +//! boot.ipxe what boots the installer +//! ``` +//! +//! The **extension decides the format**, and the stem decides nothing at all — it is +//! there for whoever opens the directory, so `proxmox.toml` and `answer.toml` are the +//! same document to this server. That is precisely why two documents of one format in +//! one directory is a *reported problem* rather than a silent choice: there would be no +//! rule to pick between them that an operator could have predicted. +//! +//! A document left at the top of the answers directory — the layout before this one — +//! is reported and **not served**. Half-reading an old layout would mean a machine whose +//! answer moved silently between two files, which is the failure this server exists to +//! make impossible. use super::{ RawDefault, RawGroup, RawMachine, Snapshot, Store, StoreWrite, Version, invalid_format, - invalid_id, valid_format, valid_id, + invalid_id, invalid_machine_id, valid_format, valid_id, valid_machine_id, }; -use crate::format::Kind; +use crate::format::{Kind, canonical_stem}; use std::fs; use std::io; use std::path::{Path, PathBuf}; -/// Subdirectory holding group files. It is a subdirectory precisely so that groups are -/// never mistaken for machines by the filename match. +/// Subdirectory holding the groups. A subdirectory precisely so that a group is never +/// mistaken for a machine, and reserved so that a machine cannot claim the name. pub const GROUPS_DIR: &str = "groups"; +/// Subdirectory holding the fallback documents — one per format, since a TOML default +/// must not be handed to a client that asked for kickstart. +pub const DEFAULT_DIR: &str = "default"; + pub struct FileStore { dir: PathBuf, } @@ -27,21 +60,50 @@ impl FileStore { &self.dir } - /// Write one document. Copies of the same stem in *other* formats are left alone: - /// they are that machine's answer for a different operating system, not a stale - /// duplicate. - fn put(&self, dir: &Path, stem: &str, format: &str, body: &str) -> io::Result<()> { + /// Write one document into an identity's directory. + /// + /// A document of this format already there is **overwritten where it stands**, + /// whatever it is called: the stem is the operator's choice, not ours, and adding a + /// second file rather than replacing the first would leave two documents claiming + /// one format. Only when there is none does the canonical name get used. + /// + /// Other formats in the same directory are left alone — they are that identity's + /// answers for other operating systems, not stale duplicates. + fn put(&self, dir: &Path, format: &str, body: &str) -> io::Result<()> { if !valid_format(format) { return Err(invalid_format(format)); } - write_atomic(&dir.join(format!("{stem}.{format}")), body) + fs::create_dir_all(dir)?; + let path = existing(dir, format) + .unwrap_or_else(|| dir.join(format!("{}.{format}", canonical_stem(format)))); + write_atomic(&path, body) } - fn remove(&self, dir: &Path, stem: &str, format: &str) -> io::Result { + fn remove(&self, dir: &Path, format: &str) -> io::Result { if !valid_format(format) { return Err(invalid_format(format)); } - remove_if_present(&dir.join(format!("{stem}.{format}"))) + let Some(path) = existing(dir, format) else { + return Ok(false); + }; + let removed = remove_if_present(&path)?; + // An identity with no documents left is not an identity. `remove_dir` refuses a + // directory that still holds anything, which is exactly the test wanted — a + // README or an answer in another format keeps it. + let _ = fs::remove_dir(dir); + Ok(removed) + } + + fn machine_dir(&self, id: &str) -> PathBuf { + self.dir.join(id) + } + + fn group_dir(&self, name: &str) -> PathBuf { + self.dir.join(GROUPS_DIR).join(name) + } + + fn default_dir(&self) -> PathBuf { + self.dir.join(DEFAULT_DIR) } } @@ -51,7 +113,7 @@ fn write_atomic(path: &Path, body: &str) -> io::Result<()> { if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } - let tmp = path.with_extension(format!("toml.tmp.{}", std::process::id())); + let tmp = path.with_extension(format!("tmp.{}", std::process::id())); fs::write(&tmp, body)?; match fs::rename(&tmp, path) { Ok(()) => Ok(()), @@ -70,44 +132,224 @@ fn remove_if_present(path: &Path) -> io::Result { } } -/// The stem and format of a directory entry we can serve, or `None`. -fn answer_entry(entry: &fs::DirEntry, path: &Path) -> Option<(String, String)> { +/// What a directory entry actually is, following a symlink to find out. +#[derive(PartialEq, Eq)] +enum What { + Dir, + File, +} + +fn what(entry: &fs::DirEntry, path: &Path) -> Option { // `DirEntry::file_type` is free on Unix (it comes back with the readdir), while // `fs::metadata` is a stat syscall per entry. Only a symlink needs the stat, to // find out what it points at. let kind = entry.file_type().ok()?; - let is_file = if kind.is_file() { - true - } else if kind.is_symlink() { - fs::metadata(path).map(|m| m.is_file()).unwrap_or(false) - } else { - false - }; - if !is_file { - return None; + if kind.is_dir() { + return Some(What::Dir); } + if kind.is_file() { + return Some(What::File); + } + if kind.is_symlink() { + let target = fs::metadata(path).ok()?; + if target.is_dir() { + return Some(What::Dir); + } + if target.is_file() { + return Some(What::File); + } + } + None +} - // A hidden file is never somebody's answer, and one kind of hidden file is actively - // dangerous: macOS writes an AppleDouble `._` beside a file whose extended - // attributes the filesystem will not take, and `._98-fa-9b-50-d8-10.toml` normalizes to - // the same identity as the real `98-fa-9b-50-d8-10.toml`. It therefore claims the same - // machine, with a body that is binary — so the machine it was meant to configure gets a - // parse error instead of its answer. Found on a real NAS whose answers directory was - // being edited over SMB from a Mac. +/// The entry's name, unless it is hidden. +/// +/// A hidden entry is never somebody's answer, and one kind is actively dangerous: macOS +/// writes an AppleDouble `._` beside a file whose extended attributes the +/// filesystem will not take, and `._proxmox.toml` is a second `.toml` in the directory +/// with a body that is binary — so the machine it was meant to configure gets a parse +/// error instead of its answer. Found on a real NAS whose answers directory was being +/// edited over SMB from a Mac. +fn visible_name(entry: &fs::DirEntry) -> Option { let name = entry.file_name(); - if name.to_str().is_none_or(|n| n.starts_with('.')) { - return None; - } + let name = name.to_str()?; + (!name.starts_with('.')).then(|| name.to_string()) +} + +/// The same question without the allocation, for the paths that never need the name. +/// +/// A full reload reads every directory in the answers directory, so this runs once per +/// entry per second at the backstop — a `String` per entry is a lot of allocation to do +/// on a NAS for a name nobody reads. +fn is_visible(entry: &fs::DirEntry) -> bool { + entry + .file_name() + .to_str() + .is_some_and(|name| !name.starts_with('.')) +} +/// The format this path declares, if it is one we can serve. +fn servable(path: &Path) -> Option { let ext = path.extension()?.to_str()?.to_ascii_lowercase(); Kind::for_extension(&ext)?; - Some((path.file_stem()?.to_str()?.to_string(), ext)) + Some(ext) +} + +/// One document found on disk. +struct Found { + format: String, + body: String, + path: PathBuf, +} + +/// Every document in one identity's directory, at most one per format. +/// +/// Sorted by filename, so which document wins a duplicated format never depends on +/// readdir order — and the loser is reported rather than quietly dropped. +fn documents_in(dir: &Path, problems: &mut Vec) -> Vec { + let entries = match fs::read_dir(dir) { + Ok(entries) => entries, + Err(e) => { + problems.push(format!("{}: {e}", dir.display())); + return Vec::new(); + } + }; + + // Name and format kept alongside the path: the sort is by filename, and asking the + // path for its extension a second time inside the loop would be a second parse of + // every entry. + let mut candidates: Vec<(std::ffi::OsString, String, PathBuf)> = Vec::new(); + for entry in entries.flatten() { + let path = entry.path(); + if !is_visible(&entry) || what(&entry, &path) != Some(What::File) { + continue; + } + // An extension we do not serve is not a mistake — a README beside the answers is + // an ordinary thing to keep there. + let Some(format) = servable(&path) else { + continue; + }; + candidates.push((entry.file_name(), format, path)); + } + candidates.sort_by(|a, b| a.0.cmp(&b.0)); + + let mut found: Vec = Vec::new(); + for (_, format, path) in candidates { + if let Some(first) = found.iter().find(|f| f.format == format) { + problems.push(format!( + "{}: {} already answers for .{format} here — the stem is only a name, so \ + two of one format have no order between them; this one is ignored", + path.display(), + first.path.display() + )); + continue; + } + match fs::read_to_string(&path) { + Ok(body) => found.push(Found { format, body, path }), + // One unreadable document must not fail every install. + Err(e) => problems.push(format!("{}: {e}", path.display())), + } + } + found +} + +/// A servable document sitting where the old flat layout put it. +/// +/// Named, with the move spelled out: an operator meeting this is mid-upgrade, and the +/// one thing they need is the new path for this exact file. +fn stray(path: &Path, root: &Path) -> String { + let to = destination(path) + .map(|to| to.strip_prefix(root).unwrap_or(&to).display().to_string()) + .unwrap_or_default(); + format!( + "{}: an answer is a directory now — move it to {to} \ + (`rescriptum migrate` moves them all); it is not being served", + path.display(), + ) +} + +/// A document still in the flat layout, and where the layout keeps it now. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Move { + pub from: PathBuf, + pub to: PathBuf, +} + +/// Where a document named `.` belongs, given the directory it sits in. +/// +/// One rule with no exceptions: `default.toml` is a stem like any other, and so is a +/// group's name. The whole layout is this function. +fn destination(path: &Path) -> Option { + let format = servable(path)?; + let stem = path.file_stem()?.to_str()?; + Some( + path.parent()? + .join(stem) + .join(format!("{}.{format}", canonical_stem(&format))), + ) +} + +/// Every document still lying flat in an answers directory, with its destination. +/// +/// The store's own knowledge of where a document goes, so `migrate` cannot compute a +/// different answer from the one `snapshot` reads back — the two would then disagree +/// about whether a directory had been migrated at all. +pub fn pending_moves(dir: &Path) -> io::Result> { + let mut moves = Vec::new(); + for from in [dir.to_path_buf(), dir.join(GROUPS_DIR)] { + let entries = match fs::read_dir(&from) { + Ok(entries) => entries, + Err(e) if e.kind() == io::ErrorKind::NotFound => continue, + Err(e) => return Err(e), + }; + for entry in entries.flatten() { + let path = entry.path(); + if !is_visible(&entry) || what(&entry, &path) != Some(What::File) { + continue; + } + if let Some(to) = destination(&path) { + moves.push(Move { from: path, to }); + } + } + } + moves.sort_by(|a, b| a.from.cmp(&b.from)); + Ok(moves) +} + +/// The identity directories inside one directory, sorted, plus anything left flat. +fn identities(dir: &Path, root: &Path, problems: &mut Vec) -> Vec<(String, PathBuf)> { + let entries = match fs::read_dir(dir) { + Ok(entries) => entries, + Err(e) => { + problems.push(format!("{}: {e}", dir.display())); + return Vec::new(); + } + }; + + let mut found = Vec::new(); + for entry in entries.flatten() { + let path = entry.path(); + let Some(name) = visible_name(&entry) else { + continue; + }; + match what(&entry, &path) { + Some(What::Dir) => found.push((name, path)), + Some(What::File) if servable(&path).is_some() => { + problems.push(stray(&path, root)); + } + _ => {} + } + } + found.sort(); + found } impl Store for FileStore { fn version(&self) -> Version { - // The directory's mtime moves whenever a file is added or removed. Editing a - // file's *contents* does not move it — the reload backstop covers that. + // The directory's mtime moves whenever an identity is added or removed. A + // document appearing *inside* one moves only that directory's mtime, which + // this does not see — the reload backstop is what covers that, as it already + // did for a file whose contents changed under an unmoved name. fs::metadata(&self.dir) .ok() .and_then(|m| m.modified().ok()) @@ -118,57 +360,66 @@ impl Store for FileStore { fn snapshot(&self) -> io::Result { let mut snapshot = Snapshot::default(); - // Groups first; a missing groups/ directory just means there are none. - match fs::read_dir(self.dir.join(GROUPS_DIR)) { - Ok(entries) => { - for entry in entries.flatten() { - let path = entry.path(); - let Some((name, format)) = answer_entry(&entry, &path) else { - continue; - }; - match fs::read_to_string(&path) { - Ok(body) => snapshot.groups.push(RawGroup { - name, - format, - body, - origin: path.display().to_string(), - }), - Err(e) => snapshot.problems.push(format!("{}: {e}", path.display())), - } - } - } - Err(e) if e.kind() == io::ErrorKind::NotFound => {} - Err(e) => return Err(e), - } - let entries = match fs::read_dir(&self.dir) { Ok(entries) => entries, // A NAS that has not been set up yet should not look different from one - // with no matching file. + // with no matching answer. Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(snapshot), Err(e) => return Err(e), }; + let mut machines: Vec<(String, PathBuf)> = Vec::new(); + let mut groups: Option = None; + let mut fallbacks: Option = None; + for entry in entries.flatten() { let path = entry.path(); - let Some((stem, format)) = answer_entry(&entry, &path) else { + let Some(name) = visible_name(&entry) else { continue; }; - let body = match fs::read_to_string(&path) { - Ok(body) => body, - // One unreadable file must not fail every install. - Err(e) => { - snapshot.problems.push(format!("{}: {e}", path.display())); - continue; + match what(&entry, &path) { + Some(What::Dir) if name.eq_ignore_ascii_case(GROUPS_DIR) => groups = Some(path), + Some(What::Dir) if name.eq_ignore_ascii_case(DEFAULT_DIR) => fallbacks = Some(path), + Some(What::Dir) => machines.push((name, path)), + Some(What::File) if servable(&path).is_some() => { + snapshot.problems.push(stray(&path, &self.dir)); } - }; - if stem.eq_ignore_ascii_case("default") { - snapshot.fallbacks.push(RawDefault { format, body }); - } else { + _ => {} + } + } + machines.sort(); + + // Groups first; a missing groups/ directory just means there are none. + if let Some(dir) = groups { + for (name, path) in identities(&dir, &self.dir, &mut snapshot.problems) { + for doc in documents_in(&path, &mut snapshot.problems) { + snapshot.groups.push(RawGroup { + name: name.clone(), + format: doc.format, + body: doc.body, + origin: doc.path.display().to_string(), + }); + } + } + } + + if let Some(dir) = fallbacks { + for doc in documents_in(&dir, &mut snapshot.problems) { + snapshot.fallbacks.push(RawDefault { + format: doc.format, + body: doc.body, + origin: doc.path.display().to_string(), + }); + } + } + + for (id, path) in machines { + for doc in documents_in(&path, &mut snapshot.problems) { snapshot.machines.push(RawMachine { - id: stem, - format, - body, + id: id.clone(), + format: doc.format, + body: doc.body, + origin: doc.path.display().to_string(), }); } } @@ -181,49 +432,61 @@ impl Store for FileStore { } } +/// The document of this format already in `dir`, if there is one. +/// +/// Sorted, for the same reason `documents_in` sorts: with two of one format the one that +/// answers requests and the one a write replaces have to be the same file. +fn existing(dir: &Path, format: &str) -> Option { + let mut paths: Vec = Vec::new(); + for entry in fs::read_dir(dir).ok()?.flatten() { + let path = entry.path(); + if !is_visible(&entry) || what(&entry, &path) != Some(What::File) { + continue; + } + if servable(&path).is_some_and(|f| f == format) { + paths.push(path); + } + } + paths.sort(); + paths.into_iter().next() +} + impl StoreWrite for FileStore { fn put_machine(&self, id: &str, format: &str, body: &str) -> io::Result<()> { // Checked here as well as at the API boundary: this is the layer that turns an // identifier into a path, so this is the layer that must not be fooled. - if !valid_id(id) { - return Err(invalid_id(id)); + if !valid_machine_id(id) { + return Err(invalid_machine_id(id)); } - let dir = self.dir.clone(); - self.put(&dir, id, format, body) + self.put(&self.machine_dir(id), format, body) } fn delete_machine(&self, id: &str, format: &str) -> io::Result { - if !valid_id(id) { - return Err(invalid_id(id)); + if !valid_machine_id(id) { + return Err(invalid_machine_id(id)); } - let dir = self.dir.clone(); - self.remove(&dir, id, format) + self.remove(&self.machine_dir(id), format) } fn put_group(&self, name: &str, format: &str, body: &str) -> io::Result<()> { if !valid_id(name) { return Err(invalid_id(name)); } - let dir = self.dir.join(GROUPS_DIR); - fs::create_dir_all(&dir)?; - self.put(&dir, name, format, body) + self.put(&self.group_dir(name), format, body) } fn delete_group(&self, name: &str, format: &str) -> io::Result { if !valid_id(name) { return Err(invalid_id(name)); } - let dir = self.dir.join(GROUPS_DIR); - self.remove(&dir, name, format) + self.remove(&self.group_dir(name), format) } fn put_default(&self, format: &str, body: &str) -> io::Result<()> { - let dir = self.dir.clone(); - self.put(&dir, "default", format, body) + self.put(&self.default_dir(), format, body) } fn delete_default(&self, format: &str) -> io::Result { - let dir = self.dir.clone(); - self.remove(&dir, "default", format) + self.remove(&self.default_dir(), format) } } diff --git a/src/store/mod.rs b/src/store/mod.rs index d6dad36..2ce560b 100644 --- a/src/store/mod.rs +++ b/src/store/mod.rs @@ -17,6 +17,10 @@ pub struct RawMachine { /// The document's format, as a file extension: `toml`, `yaml`, `ks`, … pub format: String, pub body: String, + /// Where this came from, for diagnostics — a path, or a database URL. With a + /// directory per identity the filename is the operator's to choose, so the + /// identifier alone no longer says which document failed to parse. + pub origin: String, } /// One group document. `members`, `extends` and `match` are read from the body itself, @@ -35,6 +39,8 @@ pub struct RawGroup { pub struct RawDefault { pub format: String, pub body: String, + /// Where this came from, for diagnostics — a path, or a database URL. + pub origin: String, } /// Everything needed to answer requests, as of one point in time. @@ -71,6 +77,37 @@ pub fn valid_id(id: &str) -> bool { .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | ':')) } +/// Names the file store keeps for itself, at the top of the answers directory. +/// +/// A machine called `groups` would be a directory called `groups`, which is where the +/// groups live — so its documents would come back as a rack's. Refused in **both** +/// stores rather than only the one that has the problem: a database that accepted the +/// name would export into a directory that cannot represent it, and `export` has to +/// stay a way out. +pub const RESERVED_MACHINE_IDS: [&str; 2] = ["groups", "default"]; + +/// Is this usable as a machine id? `valid_id`, minus the names the layout reserves. +pub fn valid_machine_id(id: &str) -> bool { + valid_id(id) + && !RESERVED_MACHINE_IDS + .iter() + .any(|r| id.eq_ignore_ascii_case(r)) +} + +/// The error to return when `valid_machine_id` says no. +pub fn invalid_machine_id(id: &str) -> io::Error { + if valid_id(id) { + return io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "{id:?} is reserved: the answers directory keeps {} for itself", + RESERVED_MACHINE_IDS.join(" and ") + ), + ); + } + invalid_id(id) +} + /// The error to return when `valid_id` says no. pub fn invalid_id(id: &str) -> io::Error { io::Error::new( @@ -96,9 +133,9 @@ pub trait Store: Send + Sync { /// The write half, used by the admin API. Separate from `Store` because serving /// answers never needs it — a read-only deployment simply does not provide one. /// A document is keyed by **what it is for**, which is a machine *and* an operating -/// system — `98fa9b50d810.toml` is that machine as Proxmox, `98fa9b50d810.preseed` is -/// the same hardware as Debian. They are two answers to two different questions and -/// both may exist at once, so every operation names a format. +/// system — one document is that machine as Proxmox, another is the same hardware as +/// Debian. They are two answers to two different questions and both may exist at once, +/// so every operation names a format. pub trait StoreWrite: Store { fn put_machine(&self, id: &str, format: &str, body: &str) -> io::Result<()>; fn delete_machine(&self, id: &str, format: &str) -> io::Result; diff --git a/src/store/sqlite.rs b/src/store/sqlite.rs index 7cf79b1..6b54fec 100644 --- a/src/store/sqlite.rs +++ b/src/store/sqlite.rs @@ -6,7 +6,7 @@ use super::{ RawDefault, RawGroup, RawMachine, Snapshot, Store, StoreWrite, Version, invalid_format, - invalid_id, valid_format, valid_id, + invalid_id, invalid_machine_id, valid_format, valid_id, valid_machine_id, }; use rusqlite::Connection; use std::io; @@ -177,9 +177,12 @@ impl Store for SqliteStore { .map_err(to_io)?; let rows = stmt .query_map([], |r| { + let id: String = r.get(0)?; + let format: String = r.get(1)?; Ok(RawMachine { - id: r.get(0)?, - format: r.get(1)?, + origin: format!("db:machines/{id}.{format}"), + id, + format, body: r.get(2)?, }) }) @@ -211,8 +214,10 @@ impl Store for SqliteStore { .map_err(to_io)?; let rows = stmt .query_map([DEFAULT_KEY], |r| { + let format: String = r.get(0)?; Ok(RawDefault { - format: r.get(0)?, + origin: format!("db:default.{format}"), + format, body: r.get(1)?, }) }) @@ -231,8 +236,11 @@ impl Store for SqliteStore { impl StoreWrite for SqliteStore { fn put_machine(&self, id: &str, format: &str, body: &str) -> io::Result<()> { - if !valid_id(id) { - return Err(invalid_id(id)); + // The reserved names are a *file* layout's problem, refused here too: a database + // that accepted one would export into a directory that cannot represent it, and + // `export` has to stay a way out. + if !valid_machine_id(id) { + return Err(invalid_machine_id(id)); } if !valid_format(format) { return Err(invalid_format(format)); @@ -249,8 +257,8 @@ impl StoreWrite for SqliteStore { } fn delete_machine(&self, id: &str, format: &str) -> io::Result { - if !valid_id(id) { - return Err(invalid_id(id)); + if !valid_machine_id(id) { + return Err(invalid_machine_id(id)); } let n = self .lock() diff --git a/tests/admin.rs b/tests/admin.rs index e7d1a21..373da21 100644 --- a/tests/admin.rs +++ b/tests/admin.rs @@ -1,6 +1,8 @@ //! The admin API, against the real binary: authentication, writes, and the promise that //! a write can never leave the answer set broken. +mod common; + use std::fs; use std::io::{BufRead, BufReader, Read, Write}; use std::net::TcpStream; @@ -48,10 +50,9 @@ impl Server { fn start_seeded(files: &[(&str, &str)]) -> Server { let dir = scratch("seeded"); let answers = dir.join("answers"); + fs::create_dir_all(&answers).expect("answers dir"); for (name, contents) in files { - let path = answers.join(name); - fs::create_dir_all(path.parent().expect("a parent")).expect("subdirectory"); - fs::write(&path, contents).expect("fixture"); + common::seed(&answers, name, contents); } let imported = Command::new(env!("CARGO_BIN_EXE_rescriptum")) .env("RESCRIPTUM_STORE", "sqlite") diff --git a/tests/cli.rs b/tests/cli.rs index 1ebc51d..219416e 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -6,6 +6,8 @@ //! answer before a rack does; the stdout/stderr split is what makes `render … > answer` //! usable, so that is a contract too. +mod common; + use std::fs; use std::path::{Path, PathBuf}; use std::process::{Command, Output}; @@ -35,12 +37,18 @@ impl Case { } fn write(&self, files: &[(&str, &str)]) { + for (name, contents) in files { + common::seed(&self.dir, name, contents); + } + } + + /// Write a document exactly where the name says, without the layout's opinion — + /// which is how an answers directory from before the layout actually looks. + fn write_flat(&self, files: &[(&str, &str)]) { for (name, contents) in files { let path = self.dir.join(name); - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).expect("subdirectory"); - } - fs::write(&path, contents).expect("fixture"); + fs::create_dir_all(path.parent().expect("a parent")).expect("subdirectory"); + fs::write(&path, contents).expect("flat fixture"); } } @@ -252,7 +260,10 @@ fn check_succeeds_on_a_healthy_set() { assert!(r.ok, "{r}"); assert!(r.stdout.contains("ok — everything renders"), "{r}"); // The count is of documents that name a machine; `default` is a fallback, not one. - assert!(r.stdout.contains("1 group(s), 1 machine file(s)"), "{r}"); + assert!( + r.stdout.contains("1 group(s), 1 machine document(s)"), + "{r}" + ); } #[test] @@ -263,7 +274,8 @@ fn check_catches_a_broken_default_even_though_it_names_no_machine() { let c = Case::new(&[("default.toml", broken)]); let r = c.run(&["check"]); assert!(!r.ok, "{broken:?}: {r}"); - assert!(r.stdout.contains("default.toml"), "{broken:?}: {r}"); + // Named by its path, which is where the operator has to go and fix it. + assert!(r.stdout.contains("default/proxmox.toml"), "{broken:?}: {r}"); } } @@ -385,8 +397,13 @@ fn a_directory_survives_a_round_trip_through_the_database_byte_for_byte() { "98fa9b50d810.preseed", "default.toml", ] { - let before = fs::read(c.dir.join(name)).unwrap_or_else(|e| panic!("{name}: {e}")); - let after = fs::read(out.join(name)).unwrap_or_else(|e| panic!("{name} exported: {e}")); + // Same path on both sides as well as the same bytes: `export` writing a + // document somewhere `import` would not look for it is the failure that makes + // the database unsafe to leave. + let before = + fs::read(common::document_path(&c.dir, name)).unwrap_or_else(|e| panic!("{name}: {e}")); + let after = fs::read(common::document_path(&out, name)) + .unwrap_or_else(|e| panic!("{name} exported: {e}")); assert_eq!(before, after, "{name} changed crossing the database"); } } @@ -971,3 +988,333 @@ fn config_value_prints_one_setting_but_never_a_credential() { assert!(!u.ok, "{u}"); assert!(u.stdout.is_empty(), "{u}"); } + +// ---- boot ------------------------------------------------------------------ + +/// A snippet is generated so an operator can *copy* rather than compose, so the shape +/// of what comes out is the contract — and stdout has to be the file, with everything +/// else on stderr, for `> dhcp.conf` to work. +#[cfg(feature = "boot")] +fn snippet(case: &Case, args: &[&str]) -> Run { + let mut all = vec!["boot", "dhcp-snippet"]; + all.extend_from_slice(args); + case.run_env( + &[ + ("RESCRIPTUM_ANSWERS_DIR", case.dir.as_path()), + ("RESCRIPTUM_PUBLIC_HOST", Path::new("192.0.2.10")), + ], + &all, + ) +} + +#[test] +#[cfg(feature = "boot")] +fn every_dhcp_format_names_the_loaders_the_tftp_table_actually_serves() { + // **The two are generated from one table precisely so a test can pin them + // together.** A snippet naming a loader the server does not hand out fails + // silently, at the ROM, with nothing on any console — it is the least diagnosable + // failure in the whole chain, and nothing else would catch it. + let case = Case::new(&[]); + let served = rescriptum::boot::loaders::loaders(); + + for format in ["dnsmasq", "isc", "kea", "powershell", "pfsense", "mikrotik"] { + let r = snippet(&case, &["--format", format]); + assert!(r.ok, "{format}: {r}"); + assert!(r.stdout.contains("192.0.2.10"), "{format}: {r}"); + + for loader in &served { + // pfSense and RouterOS are interfaces rather than files, and both say in + // their own output which architectures they cannot express. + if matches!(format, "pfsense" | "mikrotik") && loader.contains("arm64") { + continue; + } + assert!( + r.stdout.contains(loader), + "{format} does not name {loader}: {r}" + ); + } + } +} + +#[test] +#[cfg(feature = "boot")] +fn a_snippet_goes_to_stdout_and_warnings_go_to_stderr() { + // `boot dhcp-snippet > dhcpd.conf` has to produce a file that can be included. + let case = Case::new(&[]); + let r = snippet(&case, &["--format", "isc"]); + assert!(r.ok, "{r}"); + assert!(r.stdout.starts_with("# rescriptum "), "{r}"); + assert!( + r.stderr.is_empty(), + "nothing on stderr when nothing is wrong: {r}" + ); +} + +#[test] +#[cfg(feature = "boot")] +fn a_derived_public_host_warns_on_stderr_without_spoiling_the_snippet() { + // A DHCP server handing out an address the machines cannot reach is the hardest + // failure in this chain to diagnose, so it is said — but on stderr, so the snippet + // is still usable when redirected. + let case = Case::new(&[]); + let r = case.run_env( + &[("RESCRIPTUM_ANSWERS_DIR", case.dir.as_path())], + &["boot", "dhcp-snippet"], + ); + assert!(r.ok, "{r}"); + assert!(r.stderr.contains("RESCRIPTUM_PUBLIC_HOST"), "{r}"); + assert!(r.stdout.starts_with("# rescriptum "), "{r}"); +} + +#[test] +#[cfg(feature = "boot")] +fn an_unknown_dhcp_format_lists_the_ones_that_exist() { + let case = Case::new(&[]); + let r = snippet(&case, &["--format", "bind"]); + assert!(!r.ok, "{r}"); + assert!( + r.stderr.contains("dnsmasq"), + "the error must name what does work: {r}" + ); + // `netsh` is deliberately not an alias, because it cannot express this. + let r = snippet(&case, &["--format", "netsh"]); + assert!(!r.ok, "{r}"); +} + +#[test] +#[cfg(feature = "boot")] +fn boot_check_fails_when_a_snippet_names_a_loader_that_is_not_there() { + // The exit code is a contract, like `check`'s: `deploy.sh` keys on it. + // + // **TFTP off, deliberately.** This test is about the loaders, and `boot check` also + // probes the TFTP address — which defaults to the privileged port 69. macOS lets an + // unprivileged process bind UDP 69 and Linux does not, so leaving it on makes the + // verdict depend on the platform and on who is running the suite. Turning it off + // isolates what is being measured; `tests/tftp.rs` covers the unbindable port. + let case = Case::new(&[]); + let boot_dir = case.dir.join("boot"); + fs::create_dir_all(&boot_dir).expect("boot dir"); + + let r = case.run_env( + &[ + ("RESCRIPTUM_ANSWERS_DIR", case.dir.as_path()), + ("RESCRIPTUM_BOOT_DIR", boot_dir.as_path()), + ("RESCRIPTUM_TFTP_ADDR", Path::new("off")), + ], + &["boot", "check"], + ); + assert!(!r.ok, "an empty boot directory must fail: {r}"); + assert!(r.stdout.contains("MISSING"), "{r}"); + assert!( + r.stdout.contains("get nothing, and stop"), + "the reason has to say what the machine will do: {r}" + ); + + // Put every loader there and it passes. + for loader in rescriptum::boot::loaders::loaders() { + fs::write(boot_dir.join(loader), b"not really a loader").expect("write"); + } + let r = case.run_env( + &[ + ("RESCRIPTUM_ANSWERS_DIR", case.dir.as_path()), + ("RESCRIPTUM_BOOT_DIR", boot_dir.as_path()), + ("RESCRIPTUM_TFTP_ADDR", Path::new("off")), + ], + &["boot", "check"], + ); + assert!(r.ok, "{r}"); + assert!( + r.stdout + .contains("ok — the loaders a snippet names are all here"), + "{r}" + ); +} + +#[test] +#[cfg(feature = "boot")] +fn boot_check_says_nothing_is_wrong_when_boot_assets_are_simply_off() { + // Off is a normal state, not a failure: media can be served with no TFTP at all. + let case = Case::new(&[]); + let r = case.run(&["boot", "check"]); + assert!(r.ok, "{r}"); + assert!(r.stdout.contains("boot assets are off"), "{r}"); +} + +#[test] +#[cfg(feature = "boot")] +fn boot_check_warns_when_the_media_port_is_not_the_one_loaders_embed() { + // The embedded script in every loader already shipped chains to a fixed port and + // can read no configuration — it is baked in before any deployment exists. + let case = Case::new(&[]); + let boot_dir = case.dir.join("boot"); + let media_dir = case.dir.join("media"); + fs::create_dir_all(&boot_dir).expect("boot dir"); + fs::create_dir_all(&media_dir).expect("media dir"); + for loader in rescriptum::boot::loaders::loaders() { + fs::write(boot_dir.join(loader), b"x").expect("write"); + } + + let r = case.run_env( + &[ + ("RESCRIPTUM_ANSWERS_DIR", case.dir.as_path()), + ("RESCRIPTUM_BOOT_DIR", boot_dir.as_path()), + ("RESCRIPTUM_MEDIA_DIR", media_dir.as_path()), + ("RESCRIPTUM_MEDIA_ADDR", Path::new("0.0.0.0:9999")), + // Off for the same reason as above: this asserts on the media port, and a + // TFTP probe that fails only on Linux would make it pass for two reasons on + // one platform and one on the other. + ("RESCRIPTUM_TFTP_ADDR", Path::new("off")), + ], + &["boot", "check"], + ); + assert!(!r.ok, "{r}"); + assert!(r.stdout.contains("8001"), "{r}"); + assert!(r.stdout.contains("already shipped"), "{r}"); +} + +#[test] +#[cfg(feature = "boot")] +fn the_bootstrap_and_the_menu_can_be_printed_for_review() { + // Everything a machine will execute has to be readable by a human before it runs on + // a rack, which is the same argument `render` makes for answers. + let case = Case::new(&[]); + let r = case.run_env( + &[ + ("RESCRIPTUM_ANSWERS_DIR", case.dir.as_path()), + ("RESCRIPTUM_PUBLIC_HOST", Path::new("192.0.2.10")), + ], + &["boot", "bootstrap"], + ); + assert!(r.ok, "{r}"); + assert!(r.stdout.starts_with("#!ipxe\n"), "{r}"); + assert!(r.stdout.contains("${netX/mac}"), "{r}"); + + let media_dir = case.dir.join("media"); + fs::create_dir_all(&media_dir).expect("media dir"); + let r = case.run_env( + &[ + ("RESCRIPTUM_ANSWERS_DIR", case.dir.as_path()), + ("RESCRIPTUM_MEDIA_DIR", media_dir.as_path()), + ("RESCRIPTUM_PUBLIC_HOST", Path::new("192.0.2.10")), + ], + &["boot", "menu"], + ); + assert!(r.ok, "{r}"); + assert!(r.stdout.contains("item local"), "{r}"); + assert!(r.stdout.is_ascii(), "a BIOS text console is not UTF-8: {r}"); +} + +// --------------------------------------------------------------------------- +// migrate — the way out of the layout that came before +// --------------------------------------------------------------------------- + +/// A directory from before the layout: named, unchanged, and told what to type. +#[test] +fn migrate_shows_the_moves_and_changes_nothing_until_told_to() { + let c = Case::new(&[]); + c.write_flat(&[ + ("98fa9b50d810.toml", "marker = \"machine\"\n"), + ("98fa9b50d810.ipxe", "#!ipxe\n"), + ("groups/rack-a.toml", "members = [\"98:fa:9b:50:d8:10\"]\n"), + ("default.toml", "[global]\nkeyboard = \"us\"\n"), + ("README.md", "notes\n"), + ]); + + let r = c.run(&["migrate"]); + assert!(r.ok, "{r}"); + for line in [ + "98fa9b50d810.toml -> 98fa9b50d810/proxmox.toml", + "98fa9b50d810.ipxe -> 98fa9b50d810/boot.ipxe", + "groups/rack-a.toml -> groups/rack-a/proxmox.toml", + "default.toml -> default/proxmox.toml", + ] { + assert!(r.stdout.contains(line), "missing {line:?}: {r}"); + } + assert!( + !r.stdout.contains("README"), + "an unservable file is not ours: {r}" + ); + assert!( + r.stdout.contains("nothing has been changed"), + "a dry run has to say so: {r}" + ); + // **And it really did nothing.** The dry run is the default, so this is the + // assertion that matters most in the whole command. + assert!( + c.dir.join("98fa9b50d810.toml").is_file(), + "the dry run moved a file" + ); + assert!( + !c.dir.join("98fa9b50d810").exists(), + "the dry run created a directory" + ); +} + +#[test] +fn migrate_apply_moves_them_and_the_answers_work_afterwards() { + let c = Case::new(&[]); + c.write_flat(&[ + ("98fa9b50d810.toml", "marker = \"machine\"\n"), + ( + "groups/rack-a.toml", + "members = [\"98:fa:9b:50:d8:10\"]\n[global]\nx = 1\n", + ), + ("default.toml", "[global]\nkeyboard = \"us\"\n"), + ]); + + // Before: the documents are there, and none of them is being served. + let before = c.run(&["check"]); + assert!(before.stdout.contains("rescriptum migrate"), "{before}"); + + let r = c.run(&["migrate", "--apply"]); + assert!(r.ok, "{r}"); + assert!(r.stdout.contains("moved 3 document(s)"), "{r}"); + assert!(c.dir.join("98fa9b50d810/proxmox.toml").is_file()); + assert!(c.dir.join("groups/rack-a/proxmox.toml").is_file()); + assert!(c.dir.join("default/proxmox.toml").is_file()); + assert!( + !c.dir.join("98fa9b50d810.toml").exists(), + "the original was left behind" + ); + + // After: clean, and composing exactly as it did before the move. + let after = c.run(&["check"]); + assert!(after.ok, "{after}"); + let rendered = c.run(&["render", "98:fa:9b:50:d8:10"]); + assert!(rendered.ok, "{rendered}"); + assert!(rendered.stdout.contains("machine"), "{rendered}"); + assert!( + rendered.stdout.contains("x = 1"), + "the group stopped applying: {rendered}" + ); + + // Running it again is a no-op that says so, not an error. + let again = c.run(&["migrate", "--apply"]); + assert!(again.ok, "{again}"); + assert!(again.stdout.contains("nothing to move"), "{again}"); +} + +/// A flat document whose destination is taken. Nothing moves, including the ones that +/// could have — a half-migrated directory is the state nobody can reason about. +#[test] +fn migrate_refuses_the_whole_run_when_a_destination_is_taken() { + let c = Case::new(&[("98fa9b50d810.toml", "marker = \"already here\"\n")]); + c.write_flat(&[ + ("98fa9b50d810.toml", "marker = \"flat\"\n"), + ("aabbccddeeff.toml", "marker = \"could have moved\"\n"), + ]); + + let r = c.run(&["migrate", "--apply"]); + assert!(!r.ok, "a blocked migration has to fail: {r}"); + assert!(r.stdout.contains("BLOCKED"), "{r}"); + assert!(r.stdout.contains("nothing has been changed"), "{r}"); + assert_eq!( + fs::read_to_string(c.dir.join("98fa9b50d810/proxmox.toml")).unwrap(), + "marker = \"already here\"\n", + "the existing document was overwritten" + ); + assert!( + c.dir.join("aabbccddeeff.toml").is_file(), + "an unrelated document moved during a run that failed" + ); +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs new file mode 100644 index 0000000..c63f514 --- /dev/null +++ b/tests/common/mod.rs @@ -0,0 +1,80 @@ +//! What the test suites share. Today: writing an answer document where the layout keeps +//! it. +//! +//! One copy rather than one per suite, for the reason `loaders.rs` is one table read by +//! both TFTP and the DHCP snippet — four copies of a mapping are four chances for a +//! fixture to land somewhere the server does not look, and a test that seeds nothing +//! passes for the wrong reason. +#![allow(dead_code)] + +use std::fs; +use std::path::Path; + +/// Seed one answer document from the way a test names it. +/// +/// `98fa9b50d810.toml` is that machine as Proxmox, `groups/rack-a.toml` is a group, and +/// `default.toml` is the fallback — the vocabulary the tests were written in, and the +/// vocabulary an operator uses. Where it actually lands is the store's business, so this +/// goes through `StoreWrite` rather than reimplementing the mapping: a fixture then sits +/// exactly where a write from the admin API would put it, and cannot drift from it. +/// +/// A name the store would refuse — an extension nobody serves — is written literally +/// where it was asked for. Those fixtures exist precisely to prove a stray file answers +/// nothing, and rewriting them would take the point away. +pub fn seed(root: &Path, name: &str, body: &str) { + use rescriptum::store::{FileStore, StoreWrite}; + + let named = Path::new(name); + let stem = named + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or_default(); + let ext = named + .extension() + .and_then(|e| e.to_str()) + .unwrap_or_default(); + let in_groups = named + .parent() + .and_then(|p| p.file_name()) + .and_then(|p| p.to_str()) + == Some(rescriptum::store::file::GROUPS_DIR); + + if rescriptum::format::Kind::for_extension(ext).is_some() && !stem.is_empty() { + let store = FileStore::new(root); + let written = if in_groups { + store.put_group(stem, ext, body) + } else if stem.eq_ignore_ascii_case(rescriptum::store::file::DEFAULT_DIR) { + store.put_default(ext, body) + } else { + store.put_machine(stem, ext, body) + }; + if written.is_ok() { + return; + } + } + + let path = root.join(name); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("fixture directory"); + } + fs::write(&path, body).expect("fixture"); +} + +/// Where `seed` put one, so a test can edit or remove it afterwards. +pub fn document_path(root: &Path, name: &str) -> std::path::PathBuf { + let named = Path::new(name); + let stem = named + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or_default(); + let ext = named + .extension() + .and_then(|e| e.to_str()) + .unwrap_or_default(); + let parent = named.parent().filter(|p| !p.as_os_str().is_empty()); + let identity = match parent { + Some(p) => root.join(p).join(stem), + None => root.join(stem), + }; + identity.join(format!("{}.{ext}", rescriptum::format::canonical_stem(ext))) +} diff --git a/tests/guards.rs b/tests/guards.rs index b659152..813f7cf 100644 --- a/tests/guards.rs +++ b/tests/guards.rs @@ -1,5 +1,7 @@ //! The answer endpoint's own token, and the capture of what machines really send. +mod common; + use std::fs; use std::io::{BufRead, BufReader, Read, Write}; use std::net::TcpStream; @@ -31,7 +33,7 @@ impl Server { let dir = std::env::temp_dir().join(format!("pve-guard-{}-{n}", std::process::id())); let _ = fs::remove_dir_all(&dir); fs::create_dir_all(&dir).expect("scratch"); - fs::write(dir.join("default.toml"), "marker = \"served\"\n").expect("fixture"); + common::seed(&dir, "default.toml", "marker = \"served\"\n"); let mut cmd = Command::new(env!("CARGO_BIN_EXE_rescriptum")); cmd.env("RESCRIPTUM_ANSWERS_DIR", &dir) diff --git a/tests/integration.rs b/tests/integration.rs index 0f0b8e2..9952bdb 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -4,6 +4,8 @@ //! this project actually has to avoid — an unattended install that hangs at 3am — //! lives in the wiring, not in the pure functions. +mod common; + use std::fs; use std::io::{BufRead, BufReader, Read, Write}; use std::net::TcpStream; @@ -42,12 +44,9 @@ impl Server { )); fs::create_dir_all(&dir).expect("create answers dir"); for (name, contents) in files { - let path = dir.join(name); - // Names may be nested, e.g. "groups/rack-a.toml". - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).expect("create answer subdirectory"); - } - fs::write(&path, contents).expect("write answer file"); + // Named the way an operator thinks of them — "98fa9b50d810.toml", + // "groups/rack-a.toml" — and put where the layout keeps them. + common::seed(&dir, name, contents); } let mut cmd = Command::new(env!("CARGO_BIN_EXE_rescriptum")); @@ -124,6 +123,19 @@ impl Server { out } + /// A POST to a specific path, which is what the install-finished webhook needs: the + /// answer endpoint takes any path, so only a test that names one can tell a reserved + /// route from an ordinary answer request. + fn post_to(&self, path: &str, body: &str) -> String { + self.raw( + format!( + "POST {path} HTTP/1.1\r\nHost: nas\r\nContent-Length: {}\r\n\r\n{body}", + body.len() + ) + .as_bytes(), + ) + } + fn post(&self, body: &str) -> String { self.raw( format!( @@ -208,11 +220,14 @@ fn a_file_dropped_in_later_is_picked_up_without_a_restart() { let body = installer_body("98:fa:9b:50:d8:10"); assert!(status_line(&s.post(&body)).starts_with("HTTP/1.1 404")); - fs::write( - s.dir().join("98fa9b50d810.toml"), + // A machine that was not there at all, so its directory arrives too — which is what + // moves the answers directory's mtime and makes this immediate rather than a wait + // for the backstop. + common::seed( + s.dir(), + "98fa9b50d810.toml", "marker = \"added-at-runtime\"\n", - ) - .unwrap(); + ); let r = s.post(&body); assert!(status_line(&r).starts_with("HTTP/1.1 200"), "{r}"); assert!(body_of(&r).contains("added-at-runtime"), "{r}"); @@ -541,7 +556,7 @@ fn a_group_edited_in_place_is_picked_up_without_a_restart() { assert!(body_of(&r).contains("\"fr\""), "{r}"); fs::write( - s.dir().join("groups/rack-a.toml"), + common::document_path(s.dir(), "groups/rack-a.toml"), "members = [\"98:fa:9b:50:d8:10\"]\n[global]\nkeyboard = \"us\"\n", ) .unwrap(); @@ -558,7 +573,7 @@ fn a_broken_answer_file_is_a_500_not_a_wrong_install() { assert!(status_line(&r).starts_with("HTTP/1.1 500"), "{r}"); // And the server keeps serving everyone else. - fs::write(s.dir().join("default.toml"), "marker = \"ok\"\n").unwrap(); + common::seed(s.dir(), "default.toml", "marker = \"ok\"\n"); let r = s.post(&installer_body("11:22:33:44:55:66")); assert!(status_line(&r).starts_with("HTTP/1.1 200"), "{r}"); } @@ -1150,3 +1165,140 @@ fn a_request_that_never_reached_a_status_counts_as_a_problem() { let log = s.startup_log(); assert!(log.contains("connection timed out"), "{log}"); } + +/// **A machine reporting that it installed, and its claim being dropped.** +/// +/// The loop this closes: a machine claimed by an `.ipxe` answer installs, reboots, is +/// claimed again, and installs again — wiping its disk every time. Proxmox's +/// `[post-installation-webhook]` fires after a successful install and before that reboot, +/// with the interfaces in its body, so the machine is the one that knows. +/// +/// Everything below is against the real binary over a real socket, because the parts that +/// can be wrong here are wiring: which guard runs first, whether the body is read before +/// answering, and whether the endpoint exists at all. +#[test] +fn a_machine_can_report_that_it_is_installed_and_stop_being_claimed() { + let s = Server::start_env( + &[ + ("98-fa-9b-50-d8-10.ipxe", "#!ipxe\nchain installer\n"), + ("98-fa-9b-50-d8-10.toml", "[global]\nkeyboard = \"fr\"\n"), + ], + "5", + &[ + ("RESCRIPTUM_INSTALLED_TOKEN", "nas:s3cr3t"), + // Set deliberately: the webhook's credential is in the body, so the bearer + // guard must not be what answers it. Without the route running first, every + // webhook would 401 the moment somebody protected their answers. + ("RESCRIPTUM_ANSWER_TOKEN", "nas:answer-token-long-enough"), + ], + ); + + let body = r#"{"token":"nas:s3cr3t","fqdn":"node01.z29k.fr", + "network_interfaces":[{"name":"eno1","mac":"98:fa:9b:50:d8:10"}]}"#; + + // A wrong token is refused, and refused *without* disarming anything. + let bad = body.replace("s3cr3t", "s3cr3x"); + let response = s.post_to("/installed", &bad); + assert!(response.starts_with("HTTP/1.1 401"), "{response}"); + + let response = s.post_to("/installed", body); + assert!(response.starts_with("HTTP/1.1 200"), "{response}"); + assert!( + response.contains("installed-98-fa-9b-50-d8-10"), + "{response}" + ); + + // The claim is gone… + assert!(!common::document_path(s.dir(), "98-fa-9b-50-d8-10.ipxe").exists()); + // …the document is not, so re-arming is a rename… + assert!(common::document_path(s.dir(), "installed-98-fa-9b-50-d8-10.ipxe").exists()); + // …and the machine's own answer, which the installer reads, is untouched. + assert!(common::document_path(s.dir(), "98-fa-9b-50-d8-10.toml").exists()); + + // Twice is not an error: the webhook may be retried, and a machine installed from the + // menu was never claimed at all. + let response = s.post_to("/installed", body); + assert!(response.starts_with("HTTP/1.1 200"), "{response}"); + assert!(response.contains("nothing was claiming it"), "{response}"); + + // And the server still answers, which is the assertion that matters in this file. + // With its own credential — the bearer the *installer* presents, which is a different + // one from the webhook's and is the whole reason these two guards are separate. + let payload = r#"{"mac":"98:fa:9b:50:d8:10"}"#; + let answer = s.raw( + format!( + "POST /proxmox/answer HTTP/1.1\r\nHost: nas\r\n\ + Authorization: Bearer nas:answer-token-long-enough\r\n\ + Content-Length: {}\r\n\r\n{payload}", + payload.len() + ) + .as_bytes(), + ); + assert!( + answer.contains("keyboard"), + "the answer endpoint stopped working: {answer}" + ); +} + +/// Without the token there is no endpoint — not an open one, **absent**. So `/installed` +/// is an ordinary answer request like any other path, which is what keeps "POST on any +/// path is an answer request" true for everybody who does not use this. +#[test] +fn without_a_token_installed_is_just_another_answer_path() { + let s = Server::start(&[("default.toml", "[global]\nkeyboard = \"fr\"\n")]); + let response = s.post_to("/installed", r#"{"token":"anything"}"#); + assert!(response.starts_with("HTTP/1.1 200"), "{response}"); + assert!( + response.contains("keyboard"), + "it answered as an endpoint rather than serving the default: {response}" + ); +} + +/// **Every other family reports back from a shell script, not from a webhook.** A +/// kickstart `%post`, a preseed `late_command`, an autoinstall `late-commands`, an +/// AutoYaST chroot script — all of them can run one `curl`, and none of them will compose +/// Proxmox's JSON body. So the endpoint takes the machine's identity from the query string +/// and its credential from an ordinary bearer header, which is what that one line can send. +#[test] +fn a_kickstart_or_a_preseed_can_report_installed_with_one_curl() { + let s = Server::start_env( + &[ + ("98-fa-9b-50-d8-10.ipxe", "#!ipxe\nchain installer\n"), + ("98-fa-9b-50-d8-10.ks", "%post\n"), + ], + "5", + &[("RESCRIPTUM_INSTALLED_TOKEN", "nas:s3cr3t")], + ); + + // Exactly what a `%post` writes: + // curl -X POST -H "Authorization: Bearer nas:s3cr3t" \ + // "http://server:8000/installed?mac=$(cat /sys/class/net/eth0/address)" + // No body at all — there is nothing it needs to say beyond who it is. + let response = s.raw( + concat!( + "POST /installed?mac=98:fa:9b:50:d8:10 HTTP/1.1\r\nHost: nas\r\n", + "Authorization: Bearer nas:s3cr3t\r\n", + "Content-Length: 0\r\n\r\n", + ) + .as_bytes(), + ); + assert!(response.starts_with("HTTP/1.1 200"), "{response}"); + assert!( + response.contains("installed-98-fa-9b-50-d8-10"), + "{response}" + ); + assert!(!common::document_path(s.dir(), "98-fa-9b-50-d8-10.ipxe").exists()); + // The kickstart itself is not an `.ipxe` and is left exactly where it was. + assert!(common::document_path(s.dir(), "98-fa-9b-50-d8-10.ks").exists()); + + // And a wrong bearer is refused, with nothing left to disarm anyway. + let response = s.raw( + concat!( + "POST /installed?mac=aa:bb:cc:dd:ee:ff HTTP/1.1\r\nHost: nas\r\n", + "Authorization: Bearer nas:wrong\r\n", + "Content-Length: 0\r\n\r\n", + ) + .as_bytes(), + ); + assert!(response.starts_with("HTTP/1.1 401"), "{response}"); +} diff --git a/tests/media.rs b/tests/media.rs new file mode 100644 index 0000000..318af3d --- /dev/null +++ b/tests/media.rs @@ -0,0 +1,1287 @@ +//! The media listener, end to end: the real binary, a real socket, real images. +//! +//! The unit tests stop at the module boundary, and the failures that matter here do +//! not. A range that comes back with the wrong `Content-Range`, a `HEAD` that carries a +//! body, an image download that starves the answer endpoint — none of those is visible +//! from inside a function. +//! +//! **Every abuse case ends by proving the server still answers.** That last assertion is +//! the one that matters: a listener that survives one bad request and then serves +//! nothing has failed the only test a provisioning server has to pass. + +// There is nothing here to test in a binary built without the feature, and compiling to +// nothing is a clearer answer than a wall of unresolved imports. +#![cfg(feature = "boot")] + +mod common; + +use rescriptum::boot::iso::build; +use std::fs; +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::TcpStream; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +/// A server with both listeners up, plus the two directories it serves. +struct Server { + child: Child, + answer_addr: String, + media_addr: String, + media_dir: PathBuf, + answers_dir: PathBuf, + log: Arc>>, +} + +impl Server { + fn start(images: &[(&str, Vec)]) -> Server { + Server::start_env(images, &[]) + } + + fn start_env(images: &[(&str, Vec)], env: &[(&str, &str)]) -> Server { + static N: AtomicUsize = AtomicUsize::new(0); + let n = N.fetch_add(1, Ordering::Relaxed); + let base = + std::env::temp_dir().join(format!("rescriptum-media-{}-{n}", std::process::id())); + let media_dir = base.join("media"); + let answers_dir = base.join("answers"); + fs::create_dir_all(&media_dir).expect("media dir"); + fs::create_dir_all(&answers_dir).expect("answers dir"); + for (name, bytes) in images { + fs::write(media_dir.join(name), bytes).expect("write image"); + } + + let mut cmd = Command::new(env!("CARGO_BIN_EXE_rescriptum")); + cmd.env("RESCRIPTUM_LISTEN_ADDR", "127.0.0.1:0") + .env("RESCRIPTUM_ANSWERS_DIR", &answers_dir) + .env("RESCRIPTUM_MEDIA_DIR", &media_dir) + .env("RESCRIPTUM_MEDIA_ADDR", "127.0.0.1:0") + .env("RESCRIPTUM_PUBLIC_HOST", "127.0.0.1") + .env("RESCRIPTUM_TIMEOUT_SECS", "5") + .stderr(Stdio::piped()) + .stdout(Stdio::null()); + for (key, value) in env { + cmd.env(key, value); + } + let mut child = cmd.spawn().expect("spawn server"); + + // Both listeners announce the address they actually bound, so there is no port + // race — and both have to be seen before a test can talk to either. + let stderr = child.stderr.take().expect("piped stderr"); + let mut lines = BufReader::new(stderr).lines(); + let log: Arc>> = Arc::new(Mutex::new(Vec::new())); + let (mut answer_addr, mut media_addr) = (None, None); + for _ in 0..16 { + let Some(Ok(line)) = lines.next() else { break }; + if let Some(rest) = line.split("listening on ").nth(1) { + let bound = rest + .split_whitespace() + .next() + .unwrap_or_default() + .to_string(); + if line.contains("media listening on") { + media_addr = Some(bound); + } else { + answer_addr = Some(bound); + } + } + log.lock().unwrap().push(line); + if answer_addr.is_some() && media_addr.is_some() { + break; + } + } + + let collected = Arc::clone(&log); + std::thread::spawn(move || { + for line in lines.map_while(Result::ok) { + collected.lock().unwrap().push(line); + } + }); + + let seen = || format!("{:#?}", log.lock().unwrap()); + Server { + answer_addr: answer_addr.unwrap_or_else(|| panic!("no answer address; saw {}", seen())), + media_addr: media_addr.unwrap_or_else(|| panic!("no media address; saw {}", seen())), + child, + media_dir, + answers_dir, + log: Arc::clone(&log), + } + } + + fn media_dir(&self) -> &Path { + &self.media_dir + } + + fn startup_log(&self) -> String { + self.log.lock().unwrap().join("\n") + } + + /// A media request, returning the raw bytes: an image is not UTF-8. + fn get(&self, path: &str) -> Vec { + self.get_with(path, &[]) + } + + fn get_with(&self, path: &str, headers: &[(&str, &str)]) -> Vec { + self.request("GET", path, headers) + } + + fn request(&self, method: &str, path: &str, headers: &[(&str, &str)]) -> Vec { + let mut request = format!("{method} {path} HTTP/1.1\r\nHost: boot\r\n"); + for (name, value) in headers { + request.push_str(&format!("{name}: {value}\r\n")); + } + request.push_str("Connection: close\r\n\r\n"); + + let mut sock = TcpStream::connect(&self.media_addr).expect("connect to media"); + sock.set_read_timeout(Some(Duration::from_secs(20))) + .unwrap(); + sock.write_all(request.as_bytes()).expect("write"); + sock.flush().unwrap(); + let mut out = Vec::new(); + sock.read_to_end(&mut out).expect("read"); + out + } + + /// Ask the *answer* endpoint something, to prove it is unaffected. + fn answer(&self, body: &str) -> String { + let mut sock = TcpStream::connect(&self.answer_addr).expect("connect to answers"); + sock.set_read_timeout(Some(Duration::from_secs(10))) + .unwrap(); + sock.write_all( + format!( + "POST /answer HTTP/1.1\r\nHost: nas\r\nContent-Length: {}\r\n\r\n{body}", + body.len() + ) + .as_bytes(), + ) + .expect("write"); + sock.flush().unwrap(); + let mut out = String::new(); + let _ = sock.read_to_string(&mut out); + out + } + + /// Run a subcommand of the same binary against the same configuration. + fn run(&self, args: &[&str]) -> std::process::Output { + Command::new(env!("CARGO_BIN_EXE_rescriptum")) + .args(args) + .env("RESCRIPTUM_MEDIA_DIR", &self.media_dir) + .env("RESCRIPTUM_ANSWERS_DIR", &self.answers_dir) + .env("RESCRIPTUM_PUBLIC_HOST", "192.0.2.10") + .env("RESCRIPTUM_MEDIA_ADDR", "0.0.0.0:8001") + .output() + .expect("run subcommand") + } +} + +impl Drop for Server { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + let _ = fs::remove_dir_all(self.media_dir.parent().unwrap_or(&self.media_dir)); + } +} + +// ---- helpers -------------------------------------------------------------- + +fn head_of(response: &[u8]) -> String { + let split = find(response, b"\r\n\r\n").unwrap_or(response.len()); + String::from_utf8_lossy(&response[..split]).to_ascii_lowercase() +} + +fn body_of(response: &[u8]) -> &[u8] { + match find(response, b"\r\n\r\n") { + Some(at) => &response[at + 4..], + None => &[], + } +} + +fn find(haystack: &[u8], needle: &[u8]) -> Option { + haystack.windows(needle.len()).position(|w| w == needle) +} + +fn status(response: &[u8]) -> String { + String::from_utf8_lossy(&response[..response.len().min(32)]) + .lines() + .next() + .unwrap_or("") + .to_string() +} + +/// A header's value, from a lowercased copy: hyper emits header names lowercased, which +/// is correct, so the assertions test the contract rather than the casing. +fn header(response: &[u8], name: &str) -> Option { + head_of(response) + .lines() + .find_map(|line| line.strip_prefix(&format!("{name}: ")).map(str::to_string)) +} + +fn bzimage() -> Vec { + let mut kernel = vec![0u8; 0x400]; + kernel[0x202..0x206].copy_from_slice(b"HdrS"); + // Something recognisable at the front, so a range test can say where it landed. + kernel[..6].copy_from_slice(b"KERNEL"); + kernel +} + +/// A Proxmox image, complete enough to probe and to boot. +fn pve_image() -> Vec { + build::Builder::new() + .volume("PVE") + .file("/boot/linux26", &bzimage()) + .file("/boot/initrd.img", b"\x1f\x8bINITRD-BYTES") + .file( + "/.disk/info", + b"PRODUCTLONG='Proxmox Virtual Environment'\nRELEASE='8.4'\nISORELEASE='1'\nARCH='amd64'\n", + ) + .build() +} + +fn image_for(family: &str) -> Vec { + let builder = build::Builder::new().volume("TEST"); + match family { + "proxmox" => return pve_image(), + "ubuntu" => builder + .file("/casper/vmlinuz", &bzimage()) + .file("/casper/initrd", b"initrd"), + "debian" => builder + .file("/install.amd/vmlinuz", &bzimage()) + .file("/install.amd/initrd.gz", b"initrd"), + "rhel" => builder + .file("/images/pxeboot/vmlinuz", &bzimage()) + .file("/images/pxeboot/initrd.img", b"initrd"), + "suse" => builder + .file("/boot/x86_64/loader/linux", &bzimage()) + .file("/boot/x86_64/loader/initrd", b"initrd"), + "coreos" => builder + .file("/images/pxeboot/vmlinuz", &bzimage()) + .file("/images/pxeboot/initrd.img", b"initrd") + .file("/images/pxeboot/rootfs.img", b"live root"), + other => panic!("no fixture for {other}"), + } + .build() +} + +// ---- the catalogue -------------------------------------------------------- + +#[test] +fn the_catalogue_lists_what_the_directory_holds() { + let s = Server::start(&[("pve-8.4.iso", pve_image())]); + let r = s.get("/"); + assert!(status(&r).starts_with("HTTP/1.1 200"), "{}", head_of(&r)); + + let body = String::from_utf8_lossy(body_of(&r)).to_string(); + assert!(body.contains("pve-8.4"), "{body}"); + assert!(body.contains("proxmox"), "{body}"); + assert!(body.contains("bootable"), "{body}"); +} + +#[test] +fn the_catalogue_answers_json_when_asked_for_it() { + let s = Server::start(&[("pve-8.4.iso", pve_image())]); + let r = s.get_with("/", &[("Accept", "application/json")]); + let body = String::from_utf8_lossy(body_of(&r)).to_string(); + let parsed: serde_json::Value = serde_json::from_str(&body).expect("valid JSON"); + assert_eq!(parsed["media"][0]["id"], "pve-8.4"); + assert_eq!(parsed["media"][0]["family"], "proxmox"); + assert_eq!(parsed["media"][0]["arch"], "x86_64"); + assert_eq!(parsed["media"][0]["bootable"], true); +} + +#[test] +fn an_image_dropped_in_later_appears_without_a_restart() { + // Discovered, not declared — the same guarantee the answers directory gives. + let s = Server::start(&[]); + assert!( + String::from_utf8_lossy(body_of(&s.get("/"))).contains("no images"), + "starts empty" + ); + + fs::write(s.media_dir().join("late.iso"), pve_image()).expect("write"); + std::thread::sleep(Duration::from_millis(1200)); + let body = String::from_utf8_lossy(body_of(&s.get("/"))).to_string(); + assert!(body.contains("late"), "{body}"); +} + +// ---- serving bytes -------------------------------------------------------- + +#[test] +fn an_image_is_served_whole_with_a_validator_and_a_length() { + let image = pve_image(); + let s = Server::start(&[("pve-8.4.iso", image.clone())]); + let r = s.get("/pve-8.4/iso"); + + assert!(status(&r).starts_with("HTTP/1.1 200"), "{}", head_of(&r)); + assert_eq!(header(&r, "accept-ranges").as_deref(), Some("bytes")); + assert_eq!( + header(&r, "content-length").as_deref(), + Some(image.len().to_string().as_str()) + ); + assert!(header(&r, "etag").is_some(), "{}", head_of(&r)); + assert_eq!(body_of(&r), image.as_slice(), "the bytes must be the image"); +} + +#[test] +fn a_kernel_is_streamed_from_inside_the_image_without_extraction() { + // The property the whole listener rests on: a file in an ISO9660 image is one + // contiguous extent, so this is a seek — nothing is unpacked and nothing is copied. + let s = Server::start(&[("pve-8.4.iso", pve_image())]); + + let kernel = s.get("/pve-8.4/kernel"); + assert!( + status(&kernel).starts_with("HTTP/1.1 200"), + "{}", + head_of(&kernel) + ); + assert_eq!(body_of(&kernel), bzimage().as_slice()); + + let initrd = s.get("/pve-8.4/initrd"); + assert_eq!(body_of(&initrd), b"\x1f\x8bINITRD-BYTES"); +} + +#[test] +fn a_file_inside_the_image_is_reachable_by_path() { + let s = Server::start(&[("pve-8.4.iso", pve_image())]); + let r = s.get("/pve-8.4/file/.disk/info"); + assert!(status(&r).starts_with("HTTP/1.1 200"), "{}", head_of(&r)); + assert!( + String::from_utf8_lossy(body_of(&r)).contains("Proxmox"), + "{:?}", + body_of(&r) + ); +} + +#[test] +fn traversal_out_of_the_image_is_refused_and_the_server_still_answers() { + // The guard is structural — an ISO9660 image is its own root, so no such record + // exists — and `..` is refused outright on top of that. + let s = Server::start(&[("pve-8.4.iso", pve_image())]); + for path in [ + "/pve-8.4/file/../../../etc/passwd", + "/pve-8.4/file/..%2f..%2fetc/passwd", + "/../etc/passwd", + ] { + let r = s.get(path); + assert!( + !String::from_utf8_lossy(body_of(&r)).contains("root:"), + "{path} leaked something" + ); + } + assert!(status(&s.get("/pve-8.4/kernel")).starts_with("HTTP/1.1 200")); +} + +#[test] +fn initrd_plus_iso_is_synthesised_rather_than_stored() { + // The initrd, then a cpio header naming proxmox.iso, then the image. Old loaders + // cannot do `initrd ` themselves, and building a 1.5 GB file on disk to + // work around that is what this avoids. + let image = pve_image(); + let s = Server::start(&[("pve-8.4.iso", image.clone())]); + let r = s.get("/pve-8.4/initrd+iso"); + + assert!(status(&r).starts_with("HTTP/1.1 200"), "{}", head_of(&r)); + let body = body_of(&r); + assert!( + body.starts_with(b"\x1f\x8bINITRD-BYTES"), + "the initrd comes first" + ); + assert!(find(body, b"070701").is_some(), "a cpio header follows"); + assert!(find(body, b"proxmox.iso\0").is_some(), "naming the image"); + assert!(find(body, b"TRAILER!!!").is_some(), "and the archive ends"); + // The image itself is in there, whole. + assert!( + find(body, &image[32768..32800]).is_some(), + "the image is appended" + ); + + // Nothing resumes an initrd, and the header says so rather than letting a client + // discover it by having a range quietly ignored. + assert_eq!(header(&r, "accept-ranges").as_deref(), Some("none")); + // And the declared length is exact arithmetic, not a guess. + let declared: usize = header(&r, "content-length").unwrap().parse().unwrap(); + assert_eq!(declared, body.len()); +} + +// ---- ranges --------------------------------------------------------------- + +#[test] +fn the_three_range_forms_come_back_as_partial_content() { + // Five of the seven installers range-fetch — casper and anaconda both do — so this + // is not a nicety. + let image = pve_image(); + let s = Server::start(&[("pve-8.4.iso", image.clone())]); + let total = image.len(); + + for (spec, expected_range, expected_bytes) in [ + ("bytes=0-99", format!("bytes 0-99/{total}"), &image[0..100]), + ( + "bytes=32768-32867", + format!("bytes 32768-32867/{total}"), + &image[32768..32868], + ), + ( + "bytes=-100", + format!("bytes {}-{}/{total}", total - 100, total - 1), + &image[total - 100..], + ), + ] { + let r = s.get_with("/pve-8.4/iso", &[("Range", spec)]); + assert!( + status(&r).starts_with("HTTP/1.1 206"), + "{spec}: {}", + head_of(&r) + ); + assert_eq!( + header(&r, "content-range").as_deref(), + Some(expected_range.as_str()) + ); + assert_eq!(body_of(&r), expected_bytes, "{spec}"); + } + + // `a-` to the end, checked separately because the body is large. + let r = s.get_with("/pve-8.4/iso", &[("Range", "bytes=32768-")]); + assert!(status(&r).starts_with("HTTP/1.1 206"), "{}", head_of(&r)); + assert_eq!(body_of(&r), &image[32768..]); +} + +#[test] +fn a_range_inside_a_file_inside_the_image_lands_in_the_right_place() { + // The offset has to move *within the extent*, which is what makes a resumed kernel + // fetch work at all — and getting it wrong serves plausible bytes from the wrong + // part of the image. + let s = Server::start(&[("pve-8.4.iso", pve_image())]); + let r = s.get_with("/pve-8.4/kernel", &[("Range", "bytes=0-5")]); + assert!(status(&r).starts_with("HTTP/1.1 206"), "{}", head_of(&r)); + assert_eq!(body_of(&r), b"KERNEL"); + + let r = s.get_with("/pve-8.4/kernel", &[("Range", "bytes=514-517")]); + assert_eq!(body_of(&r), b"HdrS", "0x202 is where the magic lives"); +} + +#[test] +fn a_range_past_the_end_is_416_carrying_the_real_length() { + let image = pve_image(); + let s = Server::start(&[("pve-8.4.iso", image.clone())]); + let r = s.get_with( + "/pve-8.4/iso", + &[("Range", format!("bytes={}-", image.len()).as_str())], + ); + + assert!(status(&r).starts_with("HTTP/1.1 416"), "{}", head_of(&r)); + assert_eq!( + header(&r, "content-range").as_deref(), + Some(format!("bytes */{}", image.len()).as_str()), + "a 416 has to say how long the entity actually is" + ); +} + +#[test] +fn a_multi_range_request_is_answered_whole() { + // Permitted, and far better than half-implementing multipart/byteranges for a + // client that does not exist. + let image = pve_image(); + let s = Server::start(&[("pve-8.4.iso", image.clone())]); + let r = s.get_with("/pve-8.4/iso", &[("Range", "bytes=0-99,200-299")]); + + assert!(status(&r).starts_with("HTTP/1.1 200"), "{}", head_of(&r)); + assert_eq!(body_of(&r).len(), image.len()); +} + +#[test] +fn a_resumed_transfer_that_raced_a_replacement_restarts() { + // Splicing two images together produces a file that is neither, and the failure + // surfaces as an install that goes wrong much later. + let s = Server::start(&[("pve-8.4.iso", pve_image())]); + let etag = header(&s.get("/pve-8.4/iso"), "etag").expect("a validator"); + + // Same validator: the range is honoured. + let r = s.get_with( + "/pve-8.4/iso", + &[("Range", "bytes=0-99"), ("If-Range", &etag)], + ); + assert!(status(&r).starts_with("HTTP/1.1 206"), "{}", head_of(&r)); + + // A stale one: the whole entity comes back instead. + let r = s.get_with( + "/pve-8.4/iso", + &[("Range", "bytes=0-99"), ("If-Range", "\"something-else\"")], + ); + assert!(status(&r).starts_with("HTTP/1.1 200"), "{}", head_of(&r)); + assert!(body_of(&r).len() > 100, "the whole entity, not the range"); +} + +#[test] +fn head_is_answered_with_the_headers_and_no_body() { + // UEFI HTTP Boot asks before it fetches, and a `HEAD` that carries a body is a + // protocol violation the firmware reads as a broken server. + let image = pve_image(); + let s = Server::start(&[("pve-8.4.iso", image.clone())]); + let r = s.request("HEAD", "/pve-8.4/iso", &[]); + + assert!(status(&r).starts_with("HTTP/1.1 200"), "{}", head_of(&r)); + assert_eq!( + header(&r, "content-length").as_deref(), + Some(image.len().to_string().as_str()), + "the length is the entity's, not the body's" + ); + assert!(body_of(&r).is_empty(), "a HEAD carries no body"); +} + +// ---- refusals, and surviving them ----------------------------------------- + +#[test] +fn an_unknown_id_is_404_and_the_server_still_answers() { + let s = Server::start(&[("pve-8.4.iso", pve_image())]); + for path in ["/nope/iso", "/pve-8.4/nonsense", "/pve-8.4"] { + let r = s.get(path); + assert!( + status(&r).starts_with("HTTP/1.1 404"), + "{path}: {}", + head_of(&r) + ); + } + assert!(status(&s.get("/pve-8.4/iso")).starts_with("HTTP/1.1 200")); +} + +#[test] +fn an_image_with_no_kernel_says_so_rather_than_serving_nothing() { + let s = Server::start(&[("mystery.iso", vec![0u8; 128 * 1024])]); + let r = s.get("/mystery/kernel"); + assert!(status(&r).starts_with("HTTP/1.1 404"), "{}", head_of(&r)); + assert!( + String::from_utf8_lossy(body_of(&r)).contains("no kernel"), + "{:?}", + String::from_utf8_lossy(body_of(&r)) + ); + // But the image itself is still servable: not describable is not the same as not + // usable — `sanboot` takes one, and so does a USB stick. + assert!(status(&s.get("/mystery/iso")).starts_with("HTTP/1.1 200")); +} + +#[test] +fn writing_is_refused_because_the_listener_is_read_only() { + let s = Server::start(&[("pve-8.4.iso", pve_image())]); + for method in ["PUT", "DELETE", "POST"] { + let r = s.request(method, "/pve-8.4/iso", &[]); + assert!( + status(&r).starts_with("HTTP/1.1 405"), + "{method}: {}", + head_of(&r) + ); + assert!(head_of(&r).contains("allow: get, head"), "{}", head_of(&r)); + } + assert!(status(&s.get("/pve-8.4/iso")).starts_with("HTTP/1.1 200")); +} + +#[test] +fn a_truncated_download_does_not_stop_the_server() { + // The client hangs up mid-transfer, which is what a rebooting machine does. The + // blocking reader has to notice and stop rather than finishing an image nobody is + // receiving. + let s = Server::start(&[("pve-8.4.iso", pve_image())]); + { + let mut sock = TcpStream::connect(&s.media_addr).expect("connect"); + sock.write_all(b"GET /pve-8.4/iso HTTP/1.1\r\nHost: boot\r\nConnection: close\r\n\r\n") + .expect("write"); + sock.flush().unwrap(); + let mut first = [0u8; 64]; + let _ = sock.read(&mut first); + // Dropped here, mid-transfer. + } + + assert!( + status(&s.get("/pve-8.4/iso")).starts_with("HTTP/1.1 200"), + "the server still answers" + ); +} + +#[test] +fn an_allowlist_refuses_a_peer_outside_it() { + // Boot traffic is unauthenticated by necessity — a PXE ROM has no credentials — so + // this is the only control that can say "not you". + let s = Server::start_env( + &[("pve-8.4.iso", pve_image())], + &[("RESCRIPTUM_BOOT_ALLOW", "10.99.0.0/16")], + ); + let r = s.get("/pve-8.4/iso"); + assert!(status(&r).starts_with("HTTP/1.1 403"), "{}", head_of(&r)); + + let s = Server::start_env( + &[("pve-8.4.iso", pve_image())], + &[("RESCRIPTUM_BOOT_ALLOW", "127.0.0.0/8, 10.0.0.0/8")], + ); + assert!(status(&s.get("/pve-8.4/iso")).starts_with("HTTP/1.1 200")); +} + +// ---- the assertion that matters ------------------------------------------- + +#[test] +fn image_downloads_never_starve_the_answer_endpoint() { + // **This is the one that could veto the design.** A download holds its permit for + // minutes; if answers shared that budget, a rollout would starve its own installs. + // Two listeners, two budgets — and this is what proves it rather than hoping. + let s = Server::start(&[("pve-8.4.iso", pve_image())]); + common::seed( + &s.answers_dir, + "default.toml", + "[global]\nkeyboard = \"fr\"\n", + ); + std::thread::sleep(Duration::from_millis(1200)); + + // Four transfers in flight, each deliberately left unread so it stays open. + let mut holding = Vec::new(); + for _ in 0..4 { + let mut sock = TcpStream::connect(&s.media_addr).expect("connect"); + sock.write_all(b"GET /pve-8.4/iso HTTP/1.1\r\nHost: boot\r\nConnection: close\r\n\r\n") + .expect("write"); + sock.flush().unwrap(); + holding.push(sock); + } + + let answer = s.answer(r#"{"mac":"98:fa:9b:50:d8:10"}"#); + assert!( + answer.starts_with("HTTP/1.1 200"), + "answers must keep working while media transfers run: {answer}" + ); + assert!(answer.contains("\"fr\""), "{answer}"); + + drop(holding); + assert!(status(&s.get("/pve-8.4/iso")).starts_with("HTTP/1.1 200")); +} + +#[test] +fn the_media_listener_has_its_own_health_and_its_own_socket() { + let s = Server::start(&[]); + assert!(status(&s.get("/health")).starts_with("HTTP/1.1 200")); + assert_ne!(s.media_addr, s.answer_addr, "never the same socket"); +} + +// ---- the stanzas, per family ---------------------------------------------- + +#[test] +fn every_family_gets_its_own_boot_stanza() { + // **A stanza proven for one family claims six**, the way `tests/stores.rs` asserts + // per store. Proxmox is the founding case and the odd one out — a parameter that + // selects the automated path, and the image carried as a second initrd, where every + // other family takes a URL on the command line. + let cases: &[(&str, &str)] = &[ + ("proxmox", "proxmox-start-auto-installer"), + ("debian", "preseed/url=http://192.0.2.10:8000/debian"), + ("ubuntu", "ds=nocloud-net;s=http://192.0.2.10:8000/ubuntu/"), + ("rhel", "inst.ks=http://192.0.2.10:8000/rhel"), + ("suse", "autoyast=http://192.0.2.10:8000/suse"), + ( + "coreos", + "ignition.config.url=http://192.0.2.10:8000/coreos", + ), + ]; + + let images: Vec<(&str, Vec)> = cases + .iter() + .map(|(family, _)| (*family, image_for(family))) + .collect(); + let named: Vec<(&str, Vec)> = images + .iter() + .map(|(family, bytes)| { + ( + match *family { + "proxmox" => "proxmox.iso", + "debian" => "debian.iso", + "ubuntu" => "ubuntu.iso", + "rhel" => "rhel.iso", + "suse" => "suse.iso", + _ => "coreos.iso", + }, + bytes.clone(), + ) + }) + .collect(); + let s = Server::start(&named); + + for (family, needle) in cases { + let out = s.run(&["media", "ipxe", family]); + assert!( + out.status.success(), + "{family}: {:?}", + String::from_utf8_lossy(&out.stderr) + ); + let script = String::from_utf8_lossy(&out.stdout); + assert!(script.starts_with("#!ipxe\n"), "{family}: {script}"); + assert!(script.contains(needle), "{family}: {script}"); + // stdout is the script and stderr is everything else, so `> answer.ipxe` works. + assert!(!script.contains("# warning"), "{family}: {script}"); + // Every stanza names the media listener's own port, never the answer one's. + assert!( + script.contains("http://192.0.2.10:8001/"), + "{family}: {script}" + ); + } +} + +#[test] +fn a_generated_stanza_is_an_ordinary_answer_document() { + // The altitude that keeps the model intact: `media ipxe` prints a script, it does + // not install one. Saved into the answers directory it is selected, layered and + // templated like anything else — which is what this proves by serving it. + let s = Server::start(&[("pve-8.4.iso", pve_image())]); + let out = s.run(&["media", "ipxe", "pve-8.4"]); + assert!(out.status.success()); + + common::seed( + &s.answers_dir, + "98-fa-9b-50-d8-10.ipxe", + String::from_utf8_lossy(&out.stdout).as_ref(), + ); + std::thread::sleep(Duration::from_millis(1200)); + + let served = s.answer(r#"{"mac":"98:fa:9b:50:d8:10"}"#); + assert!(served.starts_with("HTTP/1.1 200"), "{served}"); + assert!(served.contains("proxmox-start-auto-installer"), "{served}"); + assert!( + served.contains("initrd http://192.0.2.10:8001/pve-8.4/iso proxmox.iso"), + "{served}" + ); +} + +// ---- the CLI half --------------------------------------------------------- + +#[test] +fn media_add_records_a_digest_and_media_check_re_verifies_it() { + let s = Server::start(&[("pve-8.4.iso", pve_image())]); + let image = s.media_dir().join("pve-8.4.iso"); + + let out = s.run(&["media", "add", image.to_str().unwrap()]); + assert!( + out.status.success(), + "{:?}", + String::from_utf8_lossy(&out.stderr) + ); + let printed = String::from_utf8_lossy(&out.stdout).to_string(); + assert!(printed.contains("pve-8.4"), "{printed}"); + assert!(printed.contains("proxmox"), "{printed}"); + assert!( + s.media_dir().join("pve-8.4.media").is_file(), + "a sidecar is written" + ); + + let out = s.run(&["media", "check"]); + assert!( + out.status.success(), + "{:?}", + String::from_utf8_lossy(&out.stdout) + ); + assert!( + String::from_utf8_lossy(&out.stdout).contains("1 verified"), + "{}", + String::from_utf8_lossy(&out.stdout) + ); +} + +#[test] +fn media_check_fails_when_an_image_changed_under_its_digest() { + // The one failure that silently installs something nobody reviewed. Its exit code + // is a contract, like `check`'s — `deploy.sh` keys on it. + let s = Server::start(&[("pve-8.4.iso", pve_image())]); + let image = s.media_dir().join("pve-8.4.iso"); + assert!( + s.run(&["media", "add", image.to_str().unwrap()]) + .status + .success() + ); + + let mut changed = pve_image(); + let last = changed.len() - 1; + changed[last] ^= 0xff; + fs::write(&image, changed).expect("replace the image"); + + let out = s.run(&["media", "check"]); + assert!(!out.status.success(), "a drifted image must fail the check"); + let printed = String::from_utf8_lossy(&out.stdout).to_string(); + assert!(printed.contains("no longer matches"), "{printed}"); +} + +#[test] +fn media_add_refuses_a_digest_that_does_not_match() { + // A mismatch is a truncated download or the wrong file, and both install the wrong + // thing on every machine that asks. + let s = Server::start(&[("pve-8.4.iso", pve_image())]); + let image = s.media_dir().join("pve-8.4.iso"); + + let out = s.run(&[ + "media", + "add", + image.to_str().unwrap(), + "--sha256", + &"a".repeat(64), + ]); + assert!(!out.status.success()); + assert!( + String::from_utf8_lossy(&out.stderr).contains("digest mismatch"), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + assert!( + !s.media_dir().join("pve-8.4.media").exists(), + "nothing may be recorded when the digest is wrong" + ); +} + +#[test] +fn media_list_shows_what_the_catalogue_holds() { + let s = Server::start(&[("pve-8.4.iso", pve_image())]); + let out = s.run(&["media", "list"]); + assert!(out.status.success()); + let printed = String::from_utf8_lossy(&out.stdout).to_string(); + assert!(printed.contains("pve-8.4"), "{printed}"); + assert!(printed.contains("proxmox"), "{printed}"); + assert!(printed.contains("x86_64"), "{printed}"); +} + +#[test] +fn a_derived_public_host_is_announced_loudly() { + // A wrong guess here produces a machine that boots, chains, and hangs on an address + // that does not exist — and this log line is the only place the answer appears. + let s = Server::start_env(&[], &[("RESCRIPTUM_PUBLIC_HOST", "")]); + let log = s.startup_log(); + assert!(log.contains("RESCRIPTUM_PUBLIC_HOST is not set"), "{log}"); + assert!(log.contains("derived"), "{log}"); +} + +// ---- the generated scripts ------------------------------------------------- + +#[test] +fn the_bootstrap_is_served_when_the_answer_set_is_empty() { + // **This is the whole reason it lives on the media listener.** It has to work + // before anybody has written a single answer, which is the state every new install + // starts in — and it is what puts a MAC in the query string, without which the + // selection engine matches nothing but the default. + let s = Server::start(&[]); + let r = s.get("/ipxe/bootstrap"); + + assert!(status(&r).starts_with("HTTP/1.1 200"), "{}", head_of(&r)); + let script = String::from_utf8_lossy(body_of(&r)).to_string(); + assert!(script.starts_with("#!ipxe\n"), "{script}"); + assert!(script.contains("mac=${netX/mac}"), "{script}"); + assert!(script.contains("/ipxe/boot?"), "{script}"); + // And an unclaimed machine falls through to the menu, which is `default.toml`'s + // job description applied to a different format. + assert!(script.contains("|| chain"), "{script}"); + assert!(script.contains("/ipxe/menu"), "{script}"); +} + +#[test] +fn the_menu_is_rendered_from_the_catalogue_at_request_time() { + // Not a file kept in sync: drop an ISO in the directory and it is in the menu on + // the next fetch. That is the same instinct as answers being discovered. + let s = Server::start(&[("pve-8.4.iso", pve_image())]); + let script = String::from_utf8_lossy(body_of(&s.get("/ipxe/menu"))).to_string(); + assert!(script.contains("item pve-8.4"), "{script}"); + assert!(script.contains(":pve-8.4"), "a label to goto: {script}"); + + fs::write(s.media_dir().join("late.iso"), pve_image()).expect("write"); + std::thread::sleep(Duration::from_millis(1200)); + let script = String::from_utf8_lossy(body_of(&s.get("/ipxe/menu"))).to_string(); + assert!(script.contains("item late"), "{script}"); +} + +#[test] +fn the_menu_is_never_cached() { + // A cached menu shows yesterday's images, and the operator who just dropped an ISO + // in has no way to tell that from a broken catalogue. + let s = Server::start(&[]); + let r = s.get("/ipxe/menu"); + assert_eq!(header(&r, "cache-control").as_deref(), Some("no-store")); +} + +#[test] +fn the_menu_falls_through_to_the_local_disk() { + // **The safety behaviour that must not be lost.** A machine that PXE-boots by + // accident, and that nothing claims, ends up on its own disk after a few seconds — + // it never sits at a menu waiting for a human who is not coming, and it never + // installs anything. + let s = Server::start(&[]); + let script = String::from_utf8_lossy(body_of(&s.get("/ipxe/menu"))).to_string(); + assert!( + script.contains("--default local target || goto local"), + "{script}" + ); + assert!(script.contains(":local\n"), "{script}"); +} + +#[test] +fn a_boot_asset_is_served_over_http_for_uefi_http_boot() { + // Firmware that HTTP-boots fetches its loader here rather than over TFTP — the + // shortest chain there is, and it skips TFTP entirely. + let s = Server::start_env(&[], &[]); + let boot_dir = s.media_dir().parent().expect("base").join("boot"); + fs::create_dir_all(&boot_dir).expect("boot dir"); + fs::write(boot_dir.join("ipxe-x86_64.efi"), b"a loader, pretend").expect("write"); + + // A fresh server, now with the boot directory named. + let s = Server::start_env(&[], &[("RESCRIPTUM_BOOT_DIR", boot_dir.to_str().unwrap())]); + let r = s.get("/boot/ipxe-x86_64.efi"); + assert!(status(&r).starts_with("HTTP/1.1 200"), "{}", head_of(&r)); + assert_eq!(body_of(&r), b"a loader, pretend"); + + // And nothing outside that directory. + for path in ["/boot/../../etc/passwd", "/boot/nope.efi"] { + let r = s.get(path); + assert!( + status(&r).starts_with("HTTP/1.1 404"), + "{path}: {}", + head_of(&r) + ); + } + let _ = fs::remove_dir_all(&boot_dir); +} + +#[test] +fn a_boot_asset_route_with_no_boot_directory_says_which_setting_is_missing() { + let s = Server::start(&[]); + let r = s.get("/boot/ipxe-x86_64.efi"); + assert!(status(&r).starts_with("HTTP/1.1 404"), "{}", head_of(&r)); + assert!( + String::from_utf8_lossy(body_of(&r)).contains("RESCRIPTUM_BOOT_DIR"), + "a 404 that names the setting beats one that does not: {:?}", + String::from_utf8_lossy(body_of(&r)) + ); +} + +// ---- preparing an image ---------------------------------------------------- + +#[test] +fn a_prepared_entry_is_a_sidecar_rather_than_a_second_image() { + // **The whole point of Phase 4.** Two hundred bytes stand in for 1.5 GB: nothing is + // copied, the source is never modified, and its published digest stays verifiable. + let s = Server::start(&[("pve-8.4.iso", pve_image())]); + let before = fs::read(s.media_dir().join("pve-8.4.iso")).expect("read"); + + let out = s.run(&["media", "prepare", "pve-8.4"]); + assert!( + out.status.success(), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + let printed = String::from_utf8_lossy(&out.stdout).to_string(); + assert!(printed.contains("pve-8.4-http"), "{printed}"); + assert!(printed.contains("Nothing was copied"), "{printed}"); + + // A sidecar, and no second image. + assert!(s.media_dir().join("pve-8.4-http.media").is_file()); + assert!(!s.media_dir().join("pve-8.4-http.iso").exists()); + assert_eq!( + fs::read(s.media_dir().join("pve-8.4.iso")).expect("read"), + before, + "the source must never be modified" + ); + assert!( + fs::metadata(s.media_dir().join("pve-8.4-http.media")) + .expect("stat") + .len() + < 1024, + "a sidecar is a note, not an image" + ); +} + +#[test] +fn a_prepared_image_is_served_with_the_mode_file_in_it() { + // The injection happens on the wire. What a machine downloads must be a readable + // ISO9660 filesystem exposing one more file — under the name the installer looks + // for, which is not a legal ISO9660 identifier at all. + let s = Server::start(&[("pve-8.4.iso", pve_image())]); + assert!( + s.run(&[ + "media", + "prepare", + "pve-8.4", + "--url", + "http://192.0.2.10:8000/proxmox" + ]) + .status + .success() + ); + std::thread::sleep(Duration::from_millis(1200)); + + let r = s.get("/pve-8.4-http/iso"); + assert!(status(&r).starts_with("HTTP/1.1 200"), "{}", head_of(&r)); + let served = body_of(&r).to_vec(); + + // Longer than the source by exactly what was appended, and still an image. + let source = fs::read(s.media_dir().join("pve-8.4.iso")).expect("read"); + assert!( + served.len() > source.len(), + "the mode file has to be in there" + ); + assert_eq!(&served[..32768], &source[..32768], "the system area moved"); + + // Written out and re-read as an image, which is the only assertion that matters. + let path = s.media_dir().join("served.iso"); + fs::write(&path, &served).expect("write"); + let mut iso = rescriptum::boot::iso::Iso::open(&path).expect("still an image"); + let mode = iso + .read("/auto-installer-mode.toml", 4096) + .expect("readable") + .expect("the installer must find it"); + let mode = String::from_utf8_lossy(&mode).to_string(); + assert!(mode.contains("mode = \"http\""), "{mode}"); + assert!(mode.contains("http://192.0.2.10:8000/proxmox"), "{mode}"); +} + +#[test] +fn a_range_over_a_prepared_image_lands_where_it_should() { + // Ranges have to work over a virtual file, because five of the seven installers + // range-fetch and casper does it over the image itself. + let s = Server::start(&[("pve-8.4.iso", pve_image())]); + assert!(s.run(&["media", "prepare", "pve-8.4"]).status.success()); + std::thread::sleep(Duration::from_millis(1200)); + + let whole = body_of(&s.get("/pve-8.4-http/iso")).to_vec(); + // A window that straddles the join between the source and the appended tail is the + // one that would break if the arithmetic were off by a sector. + let source_len = fs::metadata(s.media_dir().join("pve-8.4.iso")) + .expect("stat") + .len(); + let from = source_len - 100; + let to = source_len + 99; + let r = s.get_with( + "/pve-8.4-http/iso", + &[("Range", &format!("bytes={from}-{to}"))], + ); + assert!(status(&r).starts_with("HTTP/1.1 206"), "{}", head_of(&r)); + assert_eq!( + body_of(&r), + &whole[from as usize..=to as usize], + "a window across the join disagreed with the whole" + ); +} + +#[test] +fn exporting_a_prepared_entry_writes_what_the_listener_would_have_served() { + // One code path, deliberately: a stick written differently from what a machine + // downloads is a second implementation to keep honest, and the difference would only + // show on somebody's desk. + let s = Server::start(&[("pve-8.4.iso", pve_image())]); + assert!(s.run(&["media", "prepare", "pve-8.4"]).status.success()); + std::thread::sleep(Duration::from_millis(1200)); + + let served = body_of(&s.get("/pve-8.4-http/iso")).to_vec(); + let to = s.media_dir().parent().expect("base").join("exported.iso"); + let out = s.run(&["media", "export", "pve-8.4-http", to.to_str().unwrap()]); + assert!( + out.status.success(), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + + assert_eq!( + fs::read(&to).expect("read"), + served, + "the exported file and the served bytes must be identical" + ); +} + +#[test] +fn a_prepared_entry_whose_source_changed_is_refused_rather_than_served_wrong() { + // **A stale prepared entry is invisible otherwise.** The injection offsets are + // computed against one image; a source that changed underneath would be patched in + // the wrong place, producing something that mounts and is wrong. + let s = Server::start(&[("pve-8.4.iso", pve_image())]); + assert!(s.run(&["media", "prepare", "pve-8.4"]).status.success()); + + let mut longer = pve_image(); + longer.extend_from_slice(&vec![0u8; 2048]); + fs::write(s.media_dir().join("pve-8.4.iso"), longer).expect("replace"); + std::thread::sleep(Duration::from_millis(1200)); + + let out = s.run(&["media", "check"]); + let printed = String::from_utf8_lossy(&out.stdout).to_string(); + assert!(!out.status.success(), "{printed}"); + assert!(printed.contains("no longer apply"), "{printed}"); + assert!( + printed.contains("media prepare"), + "the way out has to be named: {printed}" + ); +} + +#[test] +fn preparing_a_family_that_reads_no_mode_file_is_refused_with_the_alternative() { + // Every other family takes the URL on the kernel command line, where `media ipxe` + // already puts it. Injecting a file they never read would be a no-op that looks + // like a step. + let s = Server::start(&[("ubuntu.iso", image_for("ubuntu"))]); + let out = s.run(&["media", "prepare", "ubuntu"]); + assert!(!out.status.success()); + let printed = String::from_utf8_lossy(&out.stderr).to_string(); + assert!(printed.contains("only Proxmox"), "{printed}"); + assert!(printed.contains("media ipxe ubuntu"), "{printed}"); +} + +// ---- getting an image in --------------------------------------------------- + +#[test] +fn media_add_fetches_a_url_into_the_directory() { + // **No base image is in this repository or in a release**, so the server has to be + // able to go and get one. There is no TLS in the binary — forty crates and a + // megabyte on armv7 for a job the host already does — so this shells out, and the + // test serves the image over a plain local socket to prove the whole path. + let s = Server::start(&[]); + let image = pve_image(); + let (addr, done) = serve_one_file(image.clone()); + + let digest = rescriptum::boot::sha256::hex(&image); + let out = s.run(&[ + "media", + "add", + &format!("http://{addr}/proxmox-ve_8.4-1.iso"), + "--sha256", + &digest, + ]); + let _ = done.join(); + assert!( + out.status.success(), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + + // The image is in the directory, byte for byte, under the name the URL implied. + let landed = s.media_dir().join("proxmox-ve_8.4-1.iso"); + assert!( + landed.is_file(), + "{:?}", + fs::read_dir(s.media_dir()).unwrap().count() + ); + assert_eq!(fs::read(&landed).expect("read"), image); + // And no `.part` survives: a partial download must never become a catalogue entry. + assert!(!s.media_dir().join("proxmox-ve_8.4-1.iso.part").exists()); + + // It is registered, so the catalogue can see it. + assert!(s.media_dir().join("proxmox-ve_8.4-1.media").is_file()); +} + +#[test] +fn a_fetched_image_that_does_not_match_its_digest_is_deleted() { + // A mismatch is a corrupted transfer, the wrong file, or a mirror that is not what + // it claims. Leaving it on disk means somebody registers it by hand tomorrow. + let s = Server::start(&[]); + let (addr, done) = serve_one_file(pve_image()); + + let out = s.run(&[ + "media", + "add", + &format!("http://{addr}/pve.iso"), + "--sha256", + &"a".repeat(64), + ]); + let _ = done.join(); + assert!(!out.status.success()); + let printed = String::from_utf8_lossy(&out.stderr).to_string(); + assert!(printed.contains("digest mismatch"), "{printed}"); + assert!(printed.contains("was deleted"), "{printed}"); + assert!( + !s.media_dir().join("pve.iso").exists(), + "nothing may be left behind" + ); + assert!(!s.media_dir().join("pve.iso.part").exists()); +} + +#[test] +fn fetching_without_a_digest_has_to_be_asked_for() { + // This decides what every machine on the network installs. An image pulled off a + // mirror with nothing checking it is the one place that would be a shrug, so the + // unsafe path is a deliberate flag rather than the default. + let s = Server::start(&[]); + let out = s.run(&["media", "add", "http://192.0.2.1/pve.iso"]); + assert!(!out.status.success()); + let printed = String::from_utf8_lossy(&out.stderr).to_string(); + assert!(printed.contains("--sha256"), "{printed}"); + assert!( + printed.contains("--unverified"), + "the way out has to be named: {printed}" + ); + assert!( + printed.contains("SHA256SUMS"), + "and where to find one: {printed}" + ); +} + +#[test] +fn a_fetch_never_overwrites_an_image_machines_may_be_booting() { + let s = Server::start(&[("pve.iso", pve_image())]); + let out = s.run(&[ + "media", + "add", + "http://192.0.2.1/pve.iso", + "--sha256", + &"a".repeat(64), + ]); + assert!(!out.status.success()); + assert!( + String::from_utf8_lossy(&out.stderr).contains("already exists"), + "{}", + String::from_utf8_lossy(&out.stderr) + ); +} + +#[test] +fn the_listing_says_which_entries_are_the_archive_and_which_derive_from_it() { + // **The base image is the archive**: what the vendor published, on disk, never + // modified. A prepared entry is a few hundred bytes over it. Seeing which is which + // is the difference between a directory and an archive somebody can reason about. + let s = Server::start(&[("pve-8.4.iso", pve_image())]); + assert!(s.run(&["media", "prepare", "pve-8.4"]).status.success()); + std::thread::sleep(Duration::from_millis(1200)); + + let out = s.run(&["media", "list"]); + assert!(out.status.success()); + let printed = String::from_utf8_lossy(&out.stdout).to_string(); + assert!(printed.contains("SOURCE"), "{printed}"); + + let base = printed + .lines() + .find(|l| l.starts_with("pve-8.4 ")) + .unwrap_or_else(|| panic!("{printed}")); + assert!(base.contains("base"), "the source image says so: {base}"); + + let derived = printed + .lines() + .find(|l| l.starts_with("pve-8.4-http")) + .unwrap_or_else(|| panic!("{printed}")); + assert!( + derived.contains("pve-8.4 ->"), + "and the derived one names it: {derived}" + ); +} + +/// A one-shot HTTP server that hands over `body` and exits. Enough to prove the fetch +/// path end to end without reaching the internet from a test. +fn serve_one_file(body: Vec) -> (String, std::thread::JoinHandle<()>) { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); + let addr = listener.local_addr().expect("addr").to_string(); + let handle = std::thread::spawn(move || { + let Ok((mut sock, _)) = listener.accept() else { + return; + }; + // Read the request line and headers, then answer. `curl --continue-at -` sends + // a Range header for a zero-length target, which a 200 satisfies. + let mut request = Vec::new(); + let mut byte = [0u8; 1]; + while let Ok(1) = sock.read(&mut byte) { + request.push(byte[0]); + if request.ends_with(b"\r\n\r\n") { + break; + } + } + let head = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\n\ + Content-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + let _ = sock.write_all(head.as_bytes()); + let _ = sock.write_all(&body); + let _ = sock.flush(); + }); + (addr, handle) +} diff --git a/tests/stores.rs b/tests/stores.rs index 16eb2b9..d0184d6 100644 --- a/tests/stores.rs +++ b/tests/stores.rs @@ -7,6 +7,8 @@ //! //! Anything store-specific (atomic renames, the schema) is tested at the bottom. +mod common; + use rescriptum::facts::Facts; use rescriptum::select::{Answers, Resolution}; use rescriptum::store::{FileStore, SqliteStore, Store, StoreWrite}; @@ -370,10 +372,225 @@ fn matching_is_deterministic_whatever_the_store_order() { }); } +/// The names the layout keeps for itself, refused by **both** stores. +/// +/// A machine called `groups` is a directory called `groups`, which is where the racks +/// live. The database could hold one happily — and that is the trap: it would then +/// `export` into a directory that cannot represent it, and `export` is the way out of +/// the database. So the refusal belongs to both. +#[test] +fn a_machine_cannot_take_a_name_the_layout_reserves() { + for_each_store(|label, store| { + for reserved in ["groups", "default", "GROUPS", "Default"] { + let refused = store.put_machine(reserved, "toml", "marker = \"x\"\n"); + assert!( + refused.is_err(), + "{label}: {reserved:?} was accepted as a machine id" + ); + let message = refused.unwrap_err().to_string(); + assert!( + message.contains("reserved"), + "{label}: {reserved:?} was refused, but not for the reason an operator \ + needs to hear: {message}" + ); + } + // A *group* may still be called `default` — nothing is reserved down there. + store + .put_group("default", "toml", "members = [\"98:fa:9b:50:d8:10\"]\n") + .unwrap_or_else(|e| panic!("{label}: a group called default: {e}")); + }); +} + +/// One machine, one directory, several operating systems — the whole point of the +/// layout, asserted through the endpoint that has to tell them apart. +#[test] +fn one_machine_answers_in_every_format_it_holds() { + for_each_store(|label, store| { + for (format, body) in [ + ("toml", "marker = \"proxmox\"\n"), + ("preseed", "d-i marker string debian\n"), + ("ks", "# marker rhel\n"), + ("ipxe", "#!ipxe\n# marker boot\n"), + ] { + store + .put_machine("98fa9b50d810", format, body) + .unwrap_or_else(|e| panic!("{label}: put .{format}: {e}")); + } + + for (segment, expected) in [ + ("/proxmox/answer", "proxmox"), + ("/debian/preseed", "debian"), + ("/rhel/ks", "rhel"), + ("/ipxe/boot", "boot"), + ] { + let facts = + Facts::from_request(Some(segment), None, body("98:fa:9b:50:d8:10").as_bytes()); + let r = answers(store) + .resolve(&facts) + .expect("resolve") + .unwrap_or_else(|| panic!("{label}: {segment} answered nothing")); + assert!( + r.body.contains(expected), + "{label}: {segment} served the wrong document: {}", + r.body + ); + } + }); +} + // --------------------------------------------------------------------------- // Store-specific // --------------------------------------------------------------------------- +/// The layout before this one, met head on. +/// +/// An upgrade finds these, and the one thing that must not happen is half-serving them: +/// a machine whose answer silently moved between two files is the failure this server +/// exists to make impossible. So it is reported, by name, with the destination spelled +/// out — and it answers nothing in the meantime. +#[test] +fn the_file_store_reports_a_document_left_in_the_old_flat_layout() { + let dir = scratch("flat"); + fs::create_dir_all(dir.join("groups")).unwrap(); + fs::write(dir.join("98fa9b50d810.toml"), "marker = \"flat\"\n").unwrap(); + fs::write(dir.join("groups/rack-a.toml"), "[global]\nx = 1\n").unwrap(); + // Not everything flat is a mistake: a README beside the answers is ordinary. + fs::write(dir.join("README.md"), "notes\n").unwrap(); + + let store = FileStore::new(&dir); + let snapshot = store.snapshot().unwrap(); + assert!(snapshot.machines.is_empty(), "a flat file was served"); + assert!(snapshot.groups.is_empty(), "a flat group was served"); + assert_eq!(snapshot.problems.len(), 2, "{:?}", snapshot.problems); + + let reported = snapshot.problems.join("\n"); + // The destination, not just a complaint — this is read by somebody mid-upgrade. + assert!( + reported.contains("98fa9b50d810/proxmox.toml"), + "the machine's new path has to be in the message:\n{reported}" + ); + assert!( + reported.contains("groups/rack-a/proxmox.toml"), + "the group's new path has to be in the message:\n{reported}" + ); + assert!( + reported.contains("rescriptum migrate"), + "the way out has to be named:\n{reported}" + ); + assert!( + !reported.contains("README"), + "an unservable file is not a problem:\n{reported}" + ); + let _ = fs::remove_dir_all(&dir); +} + +/// Two documents of one format in one directory. +/// +/// The stem means nothing, so there is no rule that picks between them an operator could +/// have predicted. Sorted order decides — anything else would depend on readdir — and +/// the loser is *reported*, because silently serving one of two is how the wrong +/// operating system gets installed. +#[test] +fn the_file_store_reports_two_documents_of_one_format_and_takes_the_first() { + let dir = scratch("duplicate"); + let machine = dir.join("98fa9b50d810"); + fs::create_dir_all(&machine).unwrap(); + fs::write(machine.join("zzz-second.toml"), "marker = \"second\"\n").unwrap(); + fs::write(machine.join("aaa-first.toml"), "marker = \"first\"\n").unwrap(); + // A different format in the same directory is not a duplicate at all. + fs::write(machine.join("debian.preseed"), "d-i marker string x\n").unwrap(); + + let store = FileStore::new(&dir); + let snapshot = store.snapshot().unwrap(); + let toml: Vec<_> = snapshot + .machines + .iter() + .filter(|m| m.format == "toml") + .collect(); + assert_eq!(toml.len(), 1, "both documents claimed the format"); + assert!(toml[0].body.contains("first"), "{:?}", toml[0].body); + assert_eq!( + snapshot.machines.len(), + 2, + "the preseed is a second answer, not a duplicate" + ); + assert!( + snapshot.problems.iter().any(|p| p.contains("zzz-second")), + "the ignored document has to be named: {:?}", + snapshot.problems + ); + let _ = fs::remove_dir_all(&dir); +} + +/// A write replaces the document that is there, whatever the operator called it. +/// +/// The alternative is a second file under the canonical name, which turns one answer +/// into two of one format — the state the check above reports. A write must not be able +/// to create it. +#[test] +fn the_file_store_overwrites_a_document_under_the_name_it_already_has() { + let dir = scratch("rename"); + let machine = dir.join("98fa9b50d810"); + fs::create_dir_all(&machine).unwrap(); + fs::write(machine.join("pve9.toml"), "marker = \"original\"\n").unwrap(); + + let store = FileStore::new(&dir); + store + .put_machine("98fa9b50d810", "toml", "marker = \"updated\"\n") + .expect("put over an operator's own name"); + + assert_eq!( + fs::read_to_string(machine.join("pve9.toml")).unwrap(), + "marker = \"updated\"\n", + "the write went somewhere else" + ); + assert!( + !machine.join("proxmox.toml").exists(), + "a second .toml was created beside the first" + ); + assert!(store.snapshot().unwrap().problems.is_empty()); + + // And a format that is not there yet does get the canonical name. + store + .put_machine("98fa9b50d810", "ipxe", "#!ipxe\n") + .expect("put a new format"); + assert!(machine.join("boot.ipxe").is_file()); + let _ = fs::remove_dir_all(&dir); +} + +/// A machine with nothing left in it is not a machine. +#[test] +fn the_file_store_takes_the_directory_away_with_the_last_document() { + let dir = scratch("empty"); + let store = FileStore::new(&dir); + store + .put_machine("98fa9b50d810", "toml", "x = 1\n") + .unwrap(); + store + .put_machine("98fa9b50d810", "ipxe", "#!ipxe\n") + .unwrap(); + + assert!(store.delete_machine("98fa9b50d810", "toml").unwrap()); + assert!( + dir.join("98fa9b50d810").is_dir(), + "the directory went while it still held an answer" + ); + assert!(store.delete_machine("98fa9b50d810", "ipxe").unwrap()); + assert!( + !dir.join("98fa9b50d810").exists(), + "an empty identity directory was left behind" + ); + + // Anything else in there keeps it — a note an operator left is not ours to delete. + store + .put_machine("aabbccddeeff", "toml", "x = 1\n") + .unwrap(); + fs::write(dir.join("aabbccddeeff/NOTES.md"), "why this box\n").unwrap(); + assert!(store.delete_machine("aabbccddeeff", "toml").unwrap()); + assert!(dir.join("aabbccddeeff/NOTES.md").is_file()); + let _ = fs::remove_dir_all(&dir); +} + #[test] fn the_file_store_ignores_what_a_mac_leaves_in_a_shared_folder() { // An answers directory edited over SMB from a Mac collects two kinds of litter. @@ -384,24 +601,32 @@ fn the_file_store_ignores_what_a_mac_leaves_in_a_shared_folder() { // body that is binary. The machine that was being configured then gets a parse error // instead of its answer. Found on a real NAS. let dir = scratch("appledouble"); - fs::create_dir_all(dir.join("groups")).unwrap(); + common::seed(&dir, "98-fa-9b-50-d8-10.toml", "[global]\nfqdn = \"m\"\n"); + common::seed( + &dir, + "groups/rack.toml", + "members = [\"98:fa:9b:50:d8:10\"]\n\n[global]\nkeyboard = \"fr\"\n", + ); + fs::write(dir.join(".DS_Store"), b"Mac OS X\x00\x02binary").unwrap(); + // **Beside the document, inside the machine's own directory** — which is worse than + // it was when answers were flat. `._proxmox.toml` is a second `.toml` in a directory + // that may hold only one, and it sorts *before* the real one, so a rule that picked + // the first would hand every request a binary body. fs::write( - dir.join("98-fa-9b-50-d8-10.toml"), - "[global]\nfqdn = \"m\"\n", + dir.join("98-fa-9b-50-d8-10/._proxmox.toml"), + b"Mac OS X\x00\x02binary", ) .unwrap(); fs::write( - dir.join("groups/rack.toml"), - "members = [\"98:fa:9b:50:d8:10\"]\n\n[global]\nkeyboard = \"fr\"\n", + dir.join("98-fa-9b-50-d8-10/.DS_Store"), + b"Mac OS X\x00\x02binary", ) .unwrap(); - fs::write(dir.join(".DS_Store"), b"Mac OS X\x00\x02binary").unwrap(); fs::write( - dir.join("._98-fa-9b-50-d8-10.toml"), + dir.join("groups/rack/._proxmox.toml"), b"Mac OS X\x00\x02binary", ) .unwrap(); - fs::write(dir.join("groups/._rack.toml"), b"Mac OS X\x00\x02binary").unwrap(); let store = FileStore::new(&dir); let snapshot = store.snapshot().unwrap(); @@ -412,6 +637,17 @@ fn the_file_store_ignores_what_a_mac_leaves_in_a_shared_folder() { "hidden files were taken for answer documents" ); assert_eq!(snapshot.groups.len(), 1, "hidden files reached the groups"); + // Not merely excluded — not even *reported* as a second document of one format. + assert!( + snapshot.problems.is_empty(), + "litter was reported as a conflict: {:?}", + snapshot.problems + ); + assert!( + snapshot.machines[0].body.contains("fqdn"), + "the AppleDouble answered instead of the document: {:?}", + snapshot.machines[0].body + ); let _ = fs::remove_dir_all(&dir); } @@ -427,15 +663,23 @@ fn the_file_store_writes_atomically_and_leaves_no_scratch_files() { .unwrap(); // A reader must never meet a half-written answer, so nothing temporary survives. - let stray: Vec<_> = fs::read_dir(&dir) - .unwrap() - .flatten() - .map(|e| e.file_name().to_string_lossy().to_string()) - .filter(|n| n.contains("tmp")) - .collect(); + // Swept recursively: the scratch file is written beside the document, which is one + // level down now — a check of the top directory alone would pass without looking. + let mut stray = Vec::new(); + let mut walk = vec![dir.clone()]; + while let Some(next) = walk.pop() { + for entry in fs::read_dir(&next).unwrap().flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + if entry.path().is_dir() { + walk.push(entry.path()); + } else if name.contains("tmp") { + stray.push(entry.path().display().to_string()); + } + } + } assert!(stray.is_empty(), "left temporary files behind: {stray:?}"); - assert!(dir.join("98fa9b50d810.toml").is_file()); - assert!(dir.join("groups/rack-a.toml").is_file()); + assert!(dir.join("98fa9b50d810/proxmox.toml").is_file()); + assert!(dir.join("groups/rack-a/proxmox.toml").is_file()); let _ = fs::remove_dir_all(&dir); } @@ -1055,11 +1299,7 @@ fn an_answers_directory_that_appears_later_is_served_on_the_next_request() { // Well inside the one-second backstop, so the version change is what has to do it. fs::create_dir_all(&answers_dir).unwrap(); - fs::write( - answers_dir.join("98fa9b50d810.toml"), - "marker = \"appeared\"\n", - ) - .unwrap(); + common::seed(&answers_dir, "98fa9b50d810.toml", "marker = \"appeared\"\n"); let r = resolver .resolve(&facts_for("98:fa:9b:50:d8:10")) diff --git a/tests/tftp.rs b/tests/tftp.rs new file mode 100644 index 0000000..21c2272 --- /dev/null +++ b/tests/tftp.rs @@ -0,0 +1,1193 @@ +//! TFTP against the real binary, over real UDP. +//! +//! Nothing here can be proved from inside a function. A transfer is a conversation — +//! blocks, acknowledgements, retransmission, the empty packet that ends it — and every +//! bug this file exists to catch lives in the turn-taking rather than in the parsing. +//! +//! The one that motivated writing it: **a file whose length is an exact multiple of the +//! block size must end with an empty data packet.** Leave it out and the client waits +//! forever for a final block that never comes. The unit tests could not see it; the +//! first run of `a_file_that_is_an_exact_multiple_of_the_block_size_still_ends` did. + +#![cfg(feature = "boot")] + +mod common; + +use std::fs; +use std::io::{BufRead, BufReader}; +use std::net::UdpSocket; +use std::path::PathBuf; +use std::process::{Child, Command, Stdio}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +const OP_RRQ: u16 = 1; +const OP_WRQ: u16 = 2; +const OP_DATA: u16 = 3; +const OP_ACK: u16 = 4; +const OP_ERROR: u16 = 5; +const OP_OACK: u16 = 6; + +struct Server { + child: Child, + tftp_addr: String, + boot_dir: PathBuf, + log: Arc>>, +} + +impl Server { + fn start(files: &[(&str, Vec)]) -> Server { + Server::start_env(files, &[]) + } + + fn start_env(files: &[(&str, Vec)], env: &[(&str, &str)]) -> Server { + static N: AtomicUsize = AtomicUsize::new(0); + let n = N.fetch_add(1, Ordering::Relaxed); + let base = + std::env::temp_dir().join(format!("rescriptum-tftp-it-{}-{n}", std::process::id())); + let boot_dir = base.join("boot"); + let answers_dir = base.join("answers"); + fs::create_dir_all(&boot_dir).expect("boot dir"); + fs::create_dir_all(&answers_dir).expect("answers dir"); + for (name, bytes) in files { + let path = boot_dir.join(name); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("nested"); + } + fs::write(&path, bytes).expect("write"); + } + + let mut cmd = Command::new(env!("CARGO_BIN_EXE_rescriptum")); + cmd.env("RESCRIPTUM_LISTEN_ADDR", "127.0.0.1:0") + .env("RESCRIPTUM_ANSWERS_DIR", &answers_dir) + .env("RESCRIPTUM_BOOT_DIR", &boot_dir) + // Port 69 is privileged and the test suite is not root. + .env("RESCRIPTUM_TFTP_ADDR", "127.0.0.1:0") + .stderr(Stdio::piped()) + .stdout(Stdio::null()); + for (key, value) in env { + cmd.env(key, value); + } + let mut child = cmd.spawn().expect("spawn server"); + + let stderr = child.stderr.take().expect("piped stderr"); + let mut lines = BufReader::new(stderr).lines(); + let log: Arc>> = Arc::new(Mutex::new(Vec::new())); + let mut tftp_addr = None; + for _ in 0..16 { + let Some(Ok(line)) = lines.next() else { break }; + if line.contains("tftp listening on") + && let Some(rest) = line.split("listening on ").nth(1) + { + tftp_addr = Some( + rest.split_whitespace() + .next() + .unwrap_or_default() + .to_string(), + ); + } + let done = tftp_addr.is_some() && line.contains("rescriptum "); + log.lock().unwrap().push(line); + if done { + break; + } + } + let collected = Arc::clone(&log); + std::thread::spawn(move || { + for line in lines.map_while(Result::ok) { + collected.lock().unwrap().push(line); + } + }); + + let tftp_addr = + tftp_addr.unwrap_or_else(|| panic!("no tftp address; saw {:#?}", log.lock().unwrap())); + Server { + child, + tftp_addr, + boot_dir, + log, + } + } + + /// What the server has said. Worth surfacing in an assertion: a TFTP failure that + /// only shows as silence is indistinguishable from a dead server, and that is as + /// true for a test as it is at power-on. + fn log(&self) -> String { + self.log.lock().unwrap().join("\n") + } + + fn client(&self) -> Client { + let socket = UdpSocket::bind("127.0.0.1:0").expect("bind client"); + socket + .set_read_timeout(Some(Duration::from_secs(5))) + .expect("timeout"); + Client { + socket, + server: self.tftp_addr.clone(), + peer: None, + } + } +} + +impl Drop for Server { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + let _ = fs::remove_dir_all(self.boot_dir.parent().unwrap_or(&self.boot_dir)); + } +} + +/// A TFTP client, hand-rolled, because the point is to speak the protocol rather than +/// to trust a library that speaks it the same way the server does. +struct Client { + socket: UdpSocket, + server: String, + /// The ephemeral port the server answered from. RFC 1350 requires the data to come + /// from a *new* port, and the rest of the conversation goes there. + peer: Option, +} + +impl Client { + fn request(&mut self, opcode: u16, filename: &str, mode: &str, options: &[(&str, &str)]) { + let mut packet = Vec::new(); + packet.extend_from_slice(&opcode.to_be_bytes()); + packet.extend_from_slice(filename.as_bytes()); + packet.push(0); + packet.extend_from_slice(mode.as_bytes()); + packet.push(0); + for (name, value) in options { + packet.extend_from_slice(name.as_bytes()); + packet.push(0); + packet.extend_from_slice(value.as_bytes()); + packet.push(0); + } + self.socket + .send_to(&packet, &self.server) + .expect("send request"); + } + + fn read(&mut self, filename: &str, options: &[(&str, &str)]) { + self.request(OP_RRQ, filename, "octet", options); + } + + /// One packet from the server, and remember which port it came from. + fn receive(&mut self) -> Option<(u16, Vec)> { + let mut buffer = vec![0u8; 2048]; + let (n, from) = self.socket.recv_from(&mut buffer).ok()?; + self.peer = Some(from); + if n < 2 { + return None; + } + let opcode = u16::from_be_bytes([buffer[0], buffer[1]]); + Some((opcode, buffer[2..n].to_vec())) + } + + fn ack(&mut self, block: u16) { + let mut packet = Vec::with_capacity(4); + packet.extend_from_slice(&OP_ACK.to_be_bytes()); + packet.extend_from_slice(&block.to_be_bytes()); + let peer = self.peer.expect("the server has answered"); + self.socket.send_to(&packet, peer).expect("send ack"); + } + + /// Run a whole transfer, acknowledging as a real client would, and return what came + /// back plus how many data packets it took. + fn fetch( + &mut self, + filename: &str, + options: &[(&str, &str)], + ) -> Result<(Vec, usize), String> { + self.read(filename, options); + let mut body = Vec::new(); + let mut packets = 0usize; + let mut expected: u16 = 1; + + loop { + let Some((opcode, payload)) = self.receive() else { + return Err(format!("no reply after {packets} data packet(s)")); + }; + match opcode { + OP_OACK => { + // Options accepted: acknowledge the set with block zero, then data + // starts. + self.ack(0); + continue; + } + OP_ERROR => { + let code = u16::from_be_bytes([payload[0], payload[1]]); + let message = String::from_utf8_lossy(&payload[2..]) + .trim_end_matches('\0') + .to_string(); + return Err(format!("error {code}: {message}")); + } + OP_DATA => { + let block = u16::from_be_bytes([payload[0], payload[1]]); + let data = &payload[2..]; + assert_eq!(block, expected, "blocks must arrive in order"); + packets += 1; + body.extend_from_slice(data); + self.ack(block); + // A short block ends the transfer, and "short" includes empty. + let block_size = options + .iter() + .find(|(k, _)| *k == "blksize") + .and_then(|(_, v)| v.parse::().ok()) + .unwrap_or(512); + if data.len() < block_size { + return Ok((body, packets)); + } + expected = expected.wrapping_add(1); + } + other => return Err(format!("unexpected opcode {other}")), + } + } + } +} + +fn loader(size: usize) -> Vec { + // Not zeros: a truncated or spliced transfer has to be visible in the bytes. + (0..size).map(|i| (i % 251) as u8).collect() +} + +// ---- the transfer itself -------------------------------------------------- + +#[test] +fn a_loader_comes_back_byte_for_byte() { + let bytes = loader(3000); + let s = Server::start(&[("ipxe-undionly.kpxe", bytes.clone())]); + let (body, packets) = s + .client() + .fetch("ipxe-undionly.kpxe", &[]) + .expect("a transfer"); + + assert_eq!(body, bytes); + // 3000 bytes at 512 a block: five full blocks and a short one. + assert_eq!(packets, 6); +} + +#[test] +fn a_file_that_is_an_exact_multiple_of_the_block_size_still_ends() { + // **The bug this file was written for.** A short block ends a transfer, and a file + // that divides exactly has no short block — so the transfer has to end with an + // *empty* one. Leave it out and a real ROM waits forever for a final block that + // never comes, which looks like a dead server rather than an off-by-one. + for size in [512usize, 1024, 1468 * 2] { + let block = if size % 512 == 0 { 512 } else { 1468 }; + let options = [("blksize", block.to_string())]; + let options: Vec<(&str, &str)> = options.iter().map(|(k, v)| (*k, v.as_str())).collect(); + + let bytes = loader(size); + let s = Server::start(&[("loader.kpxe", bytes.clone())]); + let (body, packets) = s + .client() + .fetch("loader.kpxe", &options) + .unwrap_or_else(|e| panic!("{size} bytes at {block}: {e}")); + + assert_eq!(body, bytes, "{size} bytes"); + assert_eq!( + packets, + size / block + 1, + "{size} bytes at {block} must end with an empty packet" + ); + } +} + +#[test] +fn an_empty_file_is_one_empty_packet() { + let s = Server::start(&[("empty.kpxe", Vec::new())]); + let (body, packets) = s.client().fetch("empty.kpxe", &[]).expect("a transfer"); + assert!(body.is_empty()); + assert_eq!(packets, 1); +} + +// ---- options -------------------------------------------------------------- + +#[test] +fn the_options_real_roms_need_are_negotiated() { + // RFC 1350 alone is not enough. `blksize` because 512-byte blocks make a megabyte + // 2,000 round-trips, and `tsize` because a number of ROMs will not proceed without + // being told the size up front. + let bytes = loader(5000); + let s = Server::start(&[("ipxe.kpxe", bytes.clone())]); + let mut client = s.client(); + client.read("ipxe.kpxe", &[("blksize", "1468"), ("tsize", "0")]); + + let (opcode, payload) = client.receive().expect("a reply"); + assert_eq!(opcode, OP_OACK, "options must be acknowledged before data"); + + let fields: Vec = payload + .split(|b| *b == 0) + .filter(|f| !f.is_empty()) + .map(|f| String::from_utf8_lossy(f).to_string()) + .collect(); + let pairs: Vec<(&str, &str)> = fields + .chunks(2) + .filter(|c| c.len() == 2) + .map(|c| (c[0].as_str(), c[1].as_str())) + .collect(); + assert!(pairs.contains(&("blksize", "1468")), "{pairs:?}"); + assert!( + pairs.contains(&("tsize", "5000")), + "tsize must be the real length: {pairs:?}" + ); +} + +#[test] +fn an_oversized_block_is_clamped_to_one_ethernet_frame() { + // 1500 − 20 (IP) − 8 (UDP) − 4 (TFTP) = 1468. Anything larger invites + // fragmentation, and a fragmented transfer to a PXE ROM is a coin toss. + let s = Server::start(&[("ipxe.kpxe", loader(4000))]); + let mut client = s.client(); + client.read("ipxe.kpxe", &[("blksize", "9000")]); + + let (opcode, payload) = client.receive().expect("a reply"); + assert_eq!(opcode, OP_OACK); + assert!( + String::from_utf8_lossy(&payload).contains("1468"), + "{:?}", + String::from_utf8_lossy(&payload) + ); +} + +#[test] +fn an_option_we_do_not_implement_is_simply_left_out() { + // RFC 2347's way of declining: name only what you accepted. + let s = Server::start(&[("ipxe.kpxe", loader(100))]); + let mut client = s.client(); + client.read("ipxe.kpxe", &[("blksize", "1024"), ("mtftp", "yes")]); + + let (opcode, payload) = client.receive().expect("a reply"); + assert_eq!(opcode, OP_OACK); + let text = String::from_utf8_lossy(&payload); + assert!(text.contains("1024"), "{text}"); + assert!(!text.contains("mtftp"), "{text}"); +} + +// ---- refusals ------------------------------------------------------------- + +#[test] +fn a_write_request_is_refused_and_the_server_keeps_serving() { + // Read-only is the posture, and writing a loader over unauthenticated UDP would be + // a way to change what every machine on the segment boots. + let s = Server::start(&[("ipxe.kpxe", loader(100))]); + let mut client = s.client(); + client.request(OP_WRQ, "evil.kpxe", "octet", &[]); + + let (opcode, payload) = client.receive().expect("a reply"); + assert_eq!(opcode, OP_ERROR); + assert_eq!( + u16::from_be_bytes([payload[0], payload[1]]), + 2, + "access violation" + ); + + assert!(s.client().fetch("ipxe.kpxe", &[]).is_ok(), "still serving"); +} + +#[test] +fn netascii_is_refused_rather_than_corrupting_a_binary() { + let s = Server::start(&[("ipxe.kpxe", loader(100))]); + let mut client = s.client(); + client.request(OP_RRQ, "ipxe.kpxe", "netascii", &[]); + + let (opcode, payload) = client.receive().expect("a reply"); + assert_eq!(opcode, OP_ERROR); + assert!( + String::from_utf8_lossy(&payload).contains("octet"), + "the reason has to name the mode we do take" + ); +} + +#[test] +fn nothing_outside_the_root_can_be_fetched() { + let s = Server::start(&[("ipxe.kpxe", loader(100))]); + for name in [ + "../../../etc/passwd", + "/etc/passwd", + "subdir/../../etc/passwd", + ] { + let e = s + .client() + .fetch(name, &[]) + .expect_err(&format!("{name} must be refused")); + assert!(e.contains("error 1"), "{name}: {e}"); + } + assert!(s.client().fetch("ipxe.kpxe", &[]).is_ok(), "still serving"); +} + +#[test] +fn a_missing_file_is_an_error_rather_than_silence() { + // Silence would look identical to a dead server, and at power-on nobody can tell. + let s = Server::start(&[("ipxe.kpxe", loader(100))]); + let e = s.client().fetch("nope.kpxe", &[]).expect_err("must refuse"); + assert!(e.contains("error 1"), "{e}"); +} + +#[test] +fn a_nonsense_packet_does_not_stop_the_server() { + let s = Server::start(&[("ipxe.kpxe", loader(100))]); + { + let socket = UdpSocket::bind("127.0.0.1:0").expect("bind"); + for junk in [ + &b""[..], + &b"\x00"[..], + &b"\xff\xff\xff\xff"[..], + &[0u8; 900][..], + ] { + let _ = socket.send_to(junk, &s.tftp_addr); + } + } + assert!( + s.client().fetch("ipxe.kpxe", &[]).is_ok(), + "still serving; the server said:\n{}", + s.log() + ); +} + +// ---- the conversation's own hazards --------------------------------------- + +#[test] +fn a_duplicate_acknowledgement_is_never_answered() { + // **The Sorcerer's Apprentice bug.** Answering a duplicate acknowledgement with a + // duplicate data packet makes both sides echo each other, and the transfer's + // traffic doubles for as long as it lasts. The fix is to ignore it — which means + // acknowledging block 1 twice must produce exactly one block 2, not two. + let s = Server::start(&[("ipxe.kpxe", loader(2000))]); + let mut client = s.client(); + client.read("ipxe.kpxe", &[]); + + let (opcode, payload) = client.receive().expect("block 1"); + assert_eq!(opcode, OP_DATA); + assert_eq!(u16::from_be_bytes([payload[0], payload[1]]), 1); + + client.ack(1); + client.ack(1); // the duplicate + + let (opcode, payload) = client.receive().expect("block 2"); + assert_eq!(opcode, OP_DATA); + assert_eq!( + u16::from_be_bytes([payload[0], payload[1]]), + 2, + "the next block, not a repeat of the first" + ); + + // And nothing else is in flight: the next thing to arrive is block 3, after we ask. + client.ack(2); + let (_, payload) = client.receive().expect("block 3"); + assert_eq!( + u16::from_be_bytes([payload[0], payload[1]]), + 3, + "a second block 2 would mean the duplicate was answered" + ); +} + +#[test] +fn a_lost_acknowledgement_is_recovered_by_retransmission() { + // UDP correctness never exercised under loss is a hope, not a property. Here the + // loss is induced: the client simply does not acknowledge block 1 the first time, + // and the server has to send it again. + let s = Server::start(&[("ipxe.kpxe", loader(2000))]); + let mut client = s.client(); + client.read("ipxe.kpxe", &[]); + + let (_, first) = client.receive().expect("block 1"); + assert_eq!(u16::from_be_bytes([first[0], first[1]]), 1); + // Say nothing. The server's retry timer must fire and resend the same block. + + let (opcode, again) = client.receive().expect("block 1, again"); + assert_eq!(opcode, OP_DATA); + assert_eq!( + u16::from_be_bytes([again[0], again[1]]), + 1, + "the same block" + ); + assert_eq!(again, first, "byte for byte, not a fresh read"); + + // And acknowledging it now still finishes the transfer. + client.ack(1); + let (_, payload) = client.receive().expect("block 2"); + assert_eq!(u16::from_be_bytes([payload[0], payload[1]]), 2); +} + +#[test] +fn the_data_comes_from_a_fresh_port() { + // RFC 1350 requires it, and it is what lets the server hold many transfers at once + // on one well-known port. A server answering from port 69 would work against one + // client and fail against two. + let s = Server::start(&[("ipxe.kpxe", loader(100))]); + let mut client = s.client(); + client.read("ipxe.kpxe", &[]); + client.receive().expect("a reply"); + + let from = client.peer.expect("answered"); + let listener: std::net::SocketAddr = s.tftp_addr.parse().expect("address"); + assert_ne!(from.port(), listener.port(), "the data port must be fresh"); +} + +#[test] +fn two_transfers_at_once_do_not_cross_over() { + // The failure this pins is the one that installs the wrong machine: two clients, + // two files, and each must receive only its own bytes. + let a = loader(1500); + let b: Vec = loader(1500).iter().map(|x| x ^ 0xff).collect(); + let s = Server::start(&[("a.kpxe", a.clone()), ("b.kpxe", b.clone())]); + + let mut first = s.client(); + let mut second = s.client(); + first.read("a.kpxe", &[]); + second.read("b.kpxe", &[]); + + let mut got_a = Vec::new(); + let mut got_b = Vec::new(); + for _ in 0..4 { + if let Some((OP_DATA, payload)) = first.receive() { + got_a.extend_from_slice(&payload[2..]); + let block = u16::from_be_bytes([payload[0], payload[1]]); + first.ack(block); + } + if let Some((OP_DATA, payload)) = second.receive() { + got_b.extend_from_slice(&payload[2..]); + let block = u16::from_be_bytes([payload[0], payload[1]]); + second.ack(block); + } + } + assert_eq!(got_a, a, "the first client got its own file"); + assert_eq!(got_b, b, "and the second got its own"); +} + +#[test] +fn a_rom_that_retransmits_its_request_is_not_locked_out() { + // **This is the "works by hand, never after a reboot" failure.** A PXE ROM that + // does not hear an answer quickly — a NAS with a sleeping disk is enough — sends + // its read request again, and again. Each retransmission is a fresh transfer from + // the same address, so a per-peer cap set to the three or four a suspicious mind + // suggests would shut the machine out of the very server it was retrying to reach. + let s = Server::start(&[("ipxe.kpxe", loader(100))]); + + let mut clients: Vec = Vec::new(); + for _ in 0..6 { + let mut client = s.client(); + client.read("ipxe.kpxe", &[]); + clients.push(client); + } + for (i, client) in clients.iter_mut().enumerate() { + let (opcode, _) = client + .receive() + .unwrap_or_else(|| panic!("retransmission {i} went unanswered:\n{}", s.log())); + assert_eq!(opcode, OP_DATA, "retransmission {i}"); + } +} + +#[test] +fn malformed_packets_do_not_use_up_a_peers_transfers() { + // A slot is for a transfer, not for a datagram. Counting junk meant a handful of + // stray packets locked an address out — and in a lab everything arrives from one. + let s = Server::start(&[("ipxe.kpxe", loader(100))]); + { + let socket = UdpSocket::bind("127.0.0.1:0").expect("bind"); + for _ in 0..20 { + let _ = socket.send_to(&[0u8, 9, b'x', 0], &s.tftp_addr); + } + } + assert!( + s.client().fetch("ipxe.kpxe", &[]).is_ok(), + "twenty junk packets must not cost a transfer:\n{}", + s.log() + ); +} + +// ---- the allowlist -------------------------------------------------------- + +#[test] +fn an_allowlist_refuses_a_peer_outside_it() { + let s = Server::start_env( + &[("ipxe.kpxe", loader(100))], + &[("RESCRIPTUM_BOOT_ALLOW", "10.99.0.0/16")], + ); + // Refused with silence rather than an error: an unauthenticated UDP service that + // answers strangers at all is an amplifier. + assert!(s.client().fetch("ipxe.kpxe", &[]).is_err()); + + let s = Server::start_env( + &[("ipxe.kpxe", loader(100))], + &[("RESCRIPTUM_BOOT_ALLOW", "127.0.0.0/8")], + ); + assert!(s.client().fetch("ipxe.kpxe", &[]).is_ok()); +} + +// ---- configuration -------------------------------------------------------- + +#[test] +fn an_address_with_no_boot_directory_is_refused_at_startup() { + let out = Command::new(env!("CARGO_BIN_EXE_rescriptum")) + .arg("check") + .env("RESCRIPTUM_TFTP_ADDR", "127.0.0.1:6969") + .env_remove("RESCRIPTUM_BOOT_DIR") + .output() + .expect("run"); + assert!(!out.status.success()); + assert!( + String::from_utf8_lossy(&out.stderr).contains("RESCRIPTUM_BOOT_DIR"), + "{}", + String::from_utf8_lossy(&out.stderr) + ); +} + +/// **A TFTP port that cannot be bound must not take the answer endpoint down with it.** +/// +/// This is the one listener in the server whose failed bind is not fatal, and the reason +/// is a measurement rather than a preference: port 69 is privileged, so it is the only +/// bind that can fail for something nobody configured. On DSM the capability is granted +/// by a `setcap` outside the package and **an upgrade replaces the binary and drops it**; +/// when that was fatal the whole package went to `start_failed`, which failed every +/// install in flight to report that a second port could not be opened. +/// +/// The bind is made to fail deterministically by holding the address first — no +/// privileges involved, so this proves the same thing whether or not CI runs as root. +/// What is asserted is all three halves of the decision: the server lives, it says so, +/// and `boot check` still calls it a problem. +#[test] +fn a_tftp_port_that_cannot_be_bound_does_not_take_the_answers_down() { + let squatter = UdpSocket::bind("127.0.0.1:0").expect("hold the port first"); + let taken = squatter.local_addr().expect("addr").to_string(); + + let base = std::env::temp_dir().join(format!("rescriptum-tftp-busy-{}", std::process::id())); + let boot_dir = base.join("boot"); + let answers_dir = base.join("answers"); + fs::create_dir_all(&boot_dir).expect("boot dir"); + fs::create_dir_all(&answers_dir).expect("answers dir"); + // **Every loader the table names, not just one.** With any of them missing, + // `boot check` exits non-zero for that instead and the assertion below passes + // without proving anything — which is what happened the first time this was + // written, and is the exact shape CLAUDE.md warns about. + for name in rescriptum::boot::loaders::loaders() { + fs::write(boot_dir.join(name), loader(100)).expect("loader"); + } + common::seed(&answers_dir, "default.toml", "keyboard = \"fr\"\n"); + + let mut child = Command::new(env!("CARGO_BIN_EXE_rescriptum")) + .env("RESCRIPTUM_LISTEN_ADDR", "127.0.0.1:0") + .env("RESCRIPTUM_ANSWERS_DIR", &answers_dir) + .env("RESCRIPTUM_BOOT_DIR", &boot_dir) + .env("RESCRIPTUM_TFTP_ADDR", &taken) + .stderr(Stdio::piped()) + .stdout(Stdio::null()) + .spawn() + .expect("spawn server"); + + let stderr = child.stderr.take().expect("piped stderr"); + let mut lines = BufReader::new(stderr).lines(); + let mut log = Vec::new(); + let mut answer_addr = None; + for _ in 0..16 { + let Some(Ok(line)) = lines.next() else { break }; + let done = line.contains("rescriptum ") && line.contains("listening on"); + if done && let Some(rest) = line.split("listening on ").nth(1) { + answer_addr = Some( + rest.split_whitespace() + .next() + .unwrap_or_default() + .to_string(), + ); + } + log.push(line); + if done { + break; + } + } + let log = log.join("\n"); + + // 1. It said so, and said what it costs. A degraded server that says nothing is the + // failure mode this whole decision exists to refuse. + assert!( + log.contains("warning: cannot bind TFTP"), + "no warning about the failed bind; saw:\n{log}" + ); + assert!( + log.contains("Answers and media are unaffected"), + "the warning has to say what still works; saw:\n{log}" + ); + + // 2. It is still serving answers — the product. + let addr = answer_addr.unwrap_or_else(|| panic!("the server never came up; saw:\n{log}")); + let body = r#"{"dmi":{"system":{"serial":"unclaimed"}}}"#; + let mut sock = std::net::TcpStream::connect(&addr).expect("connect to answers"); + sock.set_read_timeout(Some(Duration::from_secs(10))) + .unwrap(); + std::io::Write::write_all( + &mut sock, + format!( + "POST /answer HTTP/1.1\r\nHost: nas\r\nContent-Length: {}\r\n\r\n{body}", + body.len() + ) + .as_bytes(), + ) + .expect("write"); + let mut response = String::new(); + let _ = std::io::Read::read_to_string(&mut sock, &mut response); + assert!(response.starts_with("HTTP/1.1 200"), "{response}"); + assert!(response.contains("keyboard"), "{response}"); + + // 3. And `boot check` still calls it a problem, because a startup warning scrolls + // past and a non-zero exit is what a deploy script and a monitor can see. + let out = Command::new(env!("CARGO_BIN_EXE_rescriptum")) + .arg("boot") + .arg("check") + .env("RESCRIPTUM_BOOT_DIR", &boot_dir) + .env("RESCRIPTUM_TFTP_ADDR", &taken) + .output() + .expect("run boot check"); + let said = String::from_utf8_lossy(&out.stdout).to_string(); + assert!(!out.status.success(), "boot check called it fine: {said}"); + assert!(said.contains("BROKEN nothing answers on"), "{said}"); + assert!( + said.contains("1 problem(s)"), + "the TFTP port is the only one: {said}" + ); + + // And the control: the same directory with TFTP turned off is clean. Without this + // the assertions above could be passing on some unrelated complaint. + let out = Command::new(env!("CARGO_BIN_EXE_rescriptum")) + .arg("boot") + .arg("check") + .env("RESCRIPTUM_BOOT_DIR", &boot_dir) + .env("RESCRIPTUM_TFTP_ADDR", "off") + .output() + .expect("run boot check"); + let said = String::from_utf8_lossy(&out.stdout).to_string(); + assert!(out.status.success(), "{said}"); + + let _ = child.kill(); + let _ = child.wait(); + let _ = fs::remove_dir_all(&base); + drop(squatter); +} + +/// The other half of the same decision: **when TFTP is healthy, `boot check` has to say +/// so.** Without this the command could report every port as a problem and still look +/// correct, because the failing case above is the only one anybody would notice. +/// +/// It also pins the probe's happy path, which nothing else reaches: `Served` means a real +/// read request came back with data. +#[test] +fn boot_check_says_so_when_a_loader_really_is_handed_over() { + let files: Vec<(&str, Vec)> = rescriptum::boot::loaders::loaders() + .iter() + .map(|name| (*name, loader(2048))) + .collect(); + let s = Server::start(&files); + + let out = Command::new(env!("CARGO_BIN_EXE_rescriptum")) + .arg("boot") + .arg("check") + .env("RESCRIPTUM_BOOT_DIR", &s.boot_dir) + .env("RESCRIPTUM_TFTP_ADDR", &s.tftp_addr) + .output() + .expect("run boot check"); + let said = String::from_utf8_lossy(&out.stdout).to_string(); + assert!(out.status.success(), "{said}"); + assert!(said.contains("handed over"), "{said}"); + + // And the third outcome, which `boot check` itself never produces because it only + // ever asks for a loader that is on disk: a server is there and the file is not. + // Worth telling apart from silence — one is a misconfigured root, the other is + // nothing running at all. + use rescriptum::boot::tftp::{ProbeResult, probe}; + assert_eq!( + probe(&s.tftp_addr, "nothing-of-the-sort", Duration::from_secs(2)), + ProbeResult::Refused + ); +} + +/// **A stalled transfer must not look like a completed one.** +/// +/// The line that reports a transfer used to be written *before the first byte went out*, +/// so `ipxe-x86_64.efi 1164800 bytes` meant "about to send". A machine on a real network +/// fetched a loader, did nothing, and the log said success — which is how an afternoon +/// goes. The size is now reported at the end, with what actually happened. +#[test] +fn a_transfer_that_dies_halfway_is_logged_as_a_failure() { + // Big enough to need many blocks, so abandoning it lands mid-transfer. + let s = Server::start(&[("ipxe-x86_64.efi", loader(64 * 1024))]); + + let mut client = s.client(); + client.read("ipxe-x86_64.efi", &[]); + // Take the first data packet and then stop answering, which is exactly what a ROM + // does when the block size is too large for the path: it never sees block 2. + let first = client.receive().expect("the first block"); + assert_eq!(first.0, OP_DATA); + drop(client); + + // The server retries, gives up, and says so. Six retries at 700ms is the bound. + let deadline = std::time::Instant::now() + Duration::from_secs(20); + let mut log = String::new(); + while std::time::Instant::now() < deadline { + log = s.log(); + if log.contains("FAILED") { + break; + } + std::thread::sleep(Duration::from_millis(200)); + } + assert!( + log.contains("FAILED after"), + "an abandoned transfer left no trace: {log}" + ); + assert!( + log.contains("RESCRIPTUM_TFTP_BLKSIZE"), + "the line has to name the knob that fixes the commonest cause: {log}" + ); + assert!( + !log.contains("tftp: sent ipxe-x86_64.efi"), + "it also claimed to have sent it: {log}" + ); +} + +/// The cap exists to be lowered when a path cannot carry a full-MTU block, so the value +/// a client asks for has to actually be bounded by it. +#[test] +fn the_block_size_can_be_capped_below_what_a_client_asks_for() { + let s = Server::start_env( + &[("ipxe.kpxe", loader(8000))], + &[("RESCRIPTUM_TFTP_BLKSIZE", "512")], + ); + let mut client = s.client(); + client.read("ipxe.kpxe", &[("blksize", "1468")]); + let (opcode, payload) = client.receive().expect("an answer"); + assert_eq!(opcode, OP_OACK, "the option must still be negotiated"); + let text = String::from_utf8_lossy(&payload); + assert!( + text.contains("512"), + "asked for 1468 with a cap of 512 and got: {text:?}" + ); + assert!(!text.contains("1468"), "the cap was ignored: {text:?}"); +} + +/// **`boot check` must not write alarms into the log it exists to help you read.** +/// +/// Its probe wants one block and no more, and a client that simply stops answering is +/// indistinguishable from one whose network broke — so the server retried for four +/// seconds and logged a failed transfer. Running the health check produced a scary +/// `FAILED` line about a transfer nobody wanted finished, in the one file an operator +/// reads to find a real one. The probe says goodbye with an ERROR packet now. +#[test] +fn the_health_probe_leaves_no_failure_in_the_log() { + let files: Vec<(&str, Vec)> = rescriptum::boot::loaders::loaders() + .iter() + .map(|name| (*name, loader(64 * 1024))) + .collect(); + let s = Server::start(&files); + + let out = Command::new(env!("CARGO_BIN_EXE_rescriptum")) + .arg("boot") + .arg("check") + .env("RESCRIPTUM_BOOT_DIR", &s.boot_dir) + .env("RESCRIPTUM_TFTP_ADDR", &s.tftp_addr) + .output() + .expect("run boot check"); + assert!( + String::from_utf8_lossy(&out.stdout).contains("handed over"), + "the probe has to still work: {}", + String::from_utf8_lossy(&out.stdout) + ); + + // Long enough that a transfer left hanging would have given up and logged by now. + std::thread::sleep(Duration::from_secs(6)); + let log = s.log(); + assert!( + !log.contains("FAILED"), + "the health check logged a failure of its own: {log}" + ); + // It is still recorded, because a transfer that happened and stopped is worth one + // line — just not an alarm. + assert!( + log.contains("cancelled by the client"), + "the cancel left no trace at all: {log}" + ); +} + +/// **A request that arrived must leave a trace, whatever happens next.** +/// +/// Reporting the transfer at its end — right on its own — meant a request that never got +/// going logged nothing at all, which is exactly the case somebody is trying to diagnose. +/// It cost an evening on a real NAS: the machine said it was downloading, the server said +/// nothing, and both were telling the truth. +#[test] +fn a_request_is_logged_when_it_arrives_not_only_when_it_finishes() { + let s = Server::start(&[("ipxe.kpxe", loader(32 * 1024))]); + let mut client = s.client(); + client.read("ipxe.kpxe", &[("blksize", "1468")]); + let _ = client.receive(); + // Deliberately never acknowledge, and look *before* the retries could have expired. + std::thread::sleep(Duration::from_millis(400)); + let log = s.log(); + assert!( + log.contains("ipxe.kpxe requested"), + "a request that has not finished yet is invisible: {log}" + ); +} + +/// **A client is allowed to hate the answer, and that must be visible.** +/// +/// RFC 2348 lets a server reply with a smaller block size than was asked for, and the +/// client is meant to accept it — but a PXE ROM that wanted 1468 and is offered 512 often +/// just stops. That path used to return without a word, so capping the block size to help +/// one network broke a machine that had been booting fine, invisibly. +#[test] +fn a_refused_option_handshake_says_so_and_names_the_cap() { + let s = Server::start_env( + &[("ipxe.kpxe", loader(32 * 1024))], + &[("RESCRIPTUM_TFTP_BLKSIZE", "512")], + ); + let mut client = s.client(); + client.read("ipxe.kpxe", &[("blksize", "1468")]); + let (opcode, _) = client.receive().expect("an option reply"); + assert_eq!(opcode, OP_OACK); + // A ROM that will not take the smaller size simply stops here. + drop(client); + + let deadline = std::time::Instant::now() + Duration::from_secs(20); + let mut log = String::new(); + while std::time::Instant::now() < deadline { + log = s.log(); + if log.contains("option handshake") { + break; + } + std::thread::sleep(Duration::from_millis(200)); + } + assert!( + log.contains("FAILED at the option handshake"), + "the handshake failed in silence: {log}" + ); + assert!( + log.contains("blksize=1468") && log.contains("512"), + "the line has to name both sides of the disagreement: {log}" + ); + assert!( + log.contains("RESCRIPTUM_TFTP_BLKSIZE"), + "and the setting that caused it: {log}" + ); +} + +/// **A message must not suggest a cause it has already ruled out.** +/// +/// When the server agrees to exactly what the client asked for and the client still walks +/// away, the block-size cap is provably not involved — it did nothing. Naming it anyway +/// printed "asked for 1468 and would not take 1468", which sent two people after the wrong +/// setting for an hour while the real cause (the reply comes from a fresh port, so a +/// firewall between the two eats the acknowledgement) went unexamined. +#[test] +fn a_handshake_failure_blames_the_cap_only_when_the_cap_did_something() { + // Nothing capped: the server grants what was asked, and the client still stops. + let s = Server::start(&[("ipxe.kpxe", loader(32 * 1024))]); + let mut client = s.client(); + client.read("ipxe.kpxe", &[("blksize", "1468")]); + let _ = client.receive(); + drop(client); + + let deadline = std::time::Instant::now() + Duration::from_secs(20); + let mut log = String::new(); + while std::time::Instant::now() < deadline { + log = s.log(); + if log.contains("option handshake") { + break; + } + std::thread::sleep(Duration::from_millis(200)); + } + assert!(log.contains("FAILED at the option handshake"), "{log}"); + assert!( + !log.contains("RESCRIPTUM_TFTP_BLKSIZE"), + "it blamed a cap that did nothing: {log}" + ); + assert!( + log.contains("fresh port"), + "and it has to point at what this actually looks like: {log}" + ); + // **Which options were offered is the whole diagnosis.** A ROM that refuses one + // refuses the whole reply and retries without it; without this line that is only + // visible in a packet capture, which is where it was actually found. + assert!( + log.contains("The reply offered: blksize=1468"), + "the line has to name what was offered: {log}" + ); +} + +/// **A client that answers from a different port must still be served.** +/// +/// The data socket used to be `connect`ed to the address the request came from, so the +/// kernel dropped anything from another port *before* this code could see it — and a +/// transfer with a client like that died looking exactly like a firewall eating the +/// acknowledgements. RFC 1350 says a client keeps its TID and most do; the ones that do +/// not are UEFI ROMs, which is the population being served here. +#[test] +fn a_client_that_acknowledges_from_another_port_is_still_served() { + let s = Server::start(&[("ipxe.kpxe", loader(3000))]); + + // The request comes from one socket… + let asker = UdpSocket::bind("127.0.0.1:0").expect("bind"); + asker + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let mut packet = vec![0, 1]; + packet.extend_from_slice(b"ipxe.kpxe\0octet\0"); + asker.send_to(&packet, &s.tftp_addr).expect("send"); + + let mut buffer = vec![0u8; 2048]; + let (n, server) = asker.recv_from(&mut buffer).expect("first block"); + assert_eq!(u16::from_be_bytes([buffer[0], buffer[1]]), OP_DATA); + let mut got = buffer[4..n].to_vec(); + + // …and every acknowledgement from a *different* one, which is what the fix is for. + let other = UdpSocket::bind("127.0.0.1:0").expect("bind"); + other + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let mut block = 1u16; + loop { + let mut ack = vec![0, 4]; + ack.extend_from_slice(&block.to_be_bytes()); + other.send_to(&ack, server).expect("ack"); + if got.len() % 512 != 0 || got.is_empty() { + break; + } + let (n, _) = other.recv_from(&mut buffer).expect("next block"); + block = u16::from_be_bytes([buffer[2], buffer[3]]); + got.extend_from_slice(&buffer[4..n]); + if n - 4 < 512 { + let mut ack = vec![0, 4]; + ack.extend_from_slice(&block.to_be_bytes()); + other.send_to(&ack, server).expect("final ack"); + break; + } + } + + assert_eq!(got.len(), 3000, "the whole file has to arrive"); + assert_eq!(got, loader(3000)); + + let deadline = std::time::Instant::now() + Duration::from_secs(3); + let mut log = String::new(); + while std::time::Instant::now() < deadline { + log = s.log(); + if log.contains("sent ipxe.kpxe") { + break; + } + std::thread::sleep(Duration::from_millis(100)); + } + assert!(log.contains("sent ipxe.kpxe 3000 bytes"), "{log}"); + // And it says the client moved, because that is worth knowing about a ROM. + assert!(log.contains("the client moved to port"), "{log}"); +} + +/// **Never agree to an option that is not implemented.** +/// +/// `windowsize` used to be echoed back — the server saying "yes, four blocks per +/// acknowledgement" — while the transfer loop sent one block and waited. A client told +/// four waits for four, so both sides waited, and only the 700 ms retransmit broke the +/// deadlock. Every block then cost a resend and 700 ms: 1.1 MB takes nine minutes that +/// way, and firmware gives up long before. Found on the wire, from a capture on the NAS, +/// after several wrong guesses about firewalls and block sizes. +/// +/// 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 and nothing else. +#[test] +fn windowsize_is_declined_rather_than_agreed_to_and_ignored() { + let s = Server::start(&[("ipxe.kpxe", loader(8000))]); + let mut client = s.client(); + // Exactly what a UEFI ROM sends: tsize, blksize and windowsize together. + client.read( + "ipxe.kpxe", + &[("tsize", "0"), ("blksize", "1468"), ("windowsize", "4")], + ); + let (opcode, payload) = client.receive().expect("an option reply"); + assert_eq!(opcode, OP_OACK); + let text = String::from_utf8_lossy(&payload); + assert!( + !text.contains("windowsize"), + "agreed to a window it does not implement: {text:?}" + ); + // The options that *are* implemented still come back, so declining one is not + // declining all of them. + assert!( + text.contains("blksize") && text.contains("1468"), + "{text:?}" + ); + assert!(text.contains("tsize"), "{text:?}"); +} + +/// And the whole file still arrives, one acknowledgement per block, without a single +/// retransmission — which is what nine minutes versus a second comes down to. +#[test] +fn a_rom_that_asks_for_a_window_still_gets_its_file_promptly() { + let s = Server::start(&[("ipxe.kpxe", loader(64 * 1024))]); + + // **The client has to behave like the ROM it is standing in for**, or this proves + // nothing: the ordinary test client acknowledges every block whatever was negotiated, + // so the deadlock cannot happen and the test passes with the bug reintroduced. Which + // it did, first time round. This one honours the window it was granted — waiting for + // that many blocks before acknowledging, exactly as RFC 7440 says a client should. + let sock = UdpSocket::bind("127.0.0.1:0").expect("bind"); + sock.set_read_timeout(Some(Duration::from_secs(3))).unwrap(); + let mut request = vec![0, 1]; + // Built field by field rather than as one byte string: `\\00` reads as an + // octal escape, which in a protocol test is exactly the ambiguity to keep out. + for field in [ + "ipxe.kpxe", + "octet", + "tsize", + "0", + "blksize", + "1468", + "windowsize", + "4", + ] { + request.extend_from_slice(field.as_bytes()); + request.push(0); + } + let started = std::time::Instant::now(); + sock.send_to(&request, &s.tftp_addr).expect("send"); + + let mut buffer = vec![0u8; 4096]; + let (n, server) = sock.recv_from(&mut buffer).expect("a reply"); + let mut window = 1usize; + if u16::from_be_bytes([buffer[0], buffer[1]]) == OP_OACK { + let text = String::from_utf8_lossy(&buffer[2..n]).to_string(); + let parts: Vec<&str> = text.split('\0').collect(); + for pair in parts.windows(2) { + if pair[0].eq_ignore_ascii_case("windowsize") { + window = pair[1].parse().unwrap_or(1); + } + } + sock.send_to(&[0, 4, 0, 0], server) + .expect("ack the options"); + } + + let mut got = Vec::new(); + let mut since_ack = 0usize; + while let Ok((n, _)) = sock.recv_from(&mut buffer) { + if u16::from_be_bytes([buffer[0], buffer[1]]) != OP_DATA { + break; + } + let last = u16::from_be_bytes([buffer[2], buffer[3]]); + got.extend_from_slice(&buffer[4..n]); + since_ack += 1; + let short = n - 4 < 1468; + if since_ack >= window || short { + let mut ack = vec![0, 4]; + ack.extend_from_slice(&last.to_be_bytes()); + sock.send_to(&ack, server).expect("ack"); + since_ack = 0; + } + if short { + break; + } + } + assert_eq!(got.len(), 64 * 1024, "the whole file has to arrive"); + assert_eq!(got, loader(64 * 1024)); + // 45 blocks at one retransmit each would be 31 seconds. Anything near that is the + // deadlock back. + assert!( + started.elapsed() < Duration::from_secs(5), + "took {:?} — that is the retransmit deadlock, not a transfer", + started.elapsed() + ); +}