← Back to site Loading…

rescriptum

Running it

Guide

Generated on August 30, 2026

Running it

Running it

rescriptum is one process, configured entirely through the environment, that writes nothing outside the store you point it at. Running it well is mostly about deciding what it is allowed to serve and to whom.

  • Deployment — a systemd unit, a container, or nothing at all.
  • Synology DSM 7 — the original target: a Package Center install that creates the share, registers the port and starts at boot.
  • Security — the two tokens, why they behave differently, and what neither of them protects.
  • Capturing requests — record what machines actually send, and replay it offline.
  • The SQLite store — for a fleet administered by tooling rather than by hand.
  • The admin API — manage answers over HTTP, on its own listener, with a write that cannot break the fleet.
  • Troubleshooting — the log line is the whole diagnostic
  • Serving boot media — the installer’s own kernel, initrd and image, from the same server.
  • Netbooting a machine — TFTP, the loader, the menu, and their DHCP server’s two lines. story.

The shape of a deployment

One processno supervisor tree, no workers to size, no sidecar
One port by defaultplus a second, only if you enable the admin API
No writesoutside the answers directory or database, and none at all unless you enable the admin API or request capture
No statebetween requests. Restarting loses nothing
Graceful shutdownon SIGTERM (what DSM’s scheduler sends) and Ctrl-C

Configuration is environment variables only. A zero or unparseable numeric value falls back to its default rather than starting a server that accepts connections and never answers.

What it needs from the network

The installer has to reach it, and that is all. It makes no outbound connections, needs no DNS, and does not care whether it is behind NAT.

Plain HTTP is the normal choice on a provisioning network. If you need TLS — some installer versions ask for a certificate fingerprint — terminate it in front with nginx or Caddy and point the ISO at that. See Security.

Deployment

Deployment

The binary is self-contained: copy it somewhere, give it an answers directory, and start it. Everything below is about doing that repeatably.

For Synology DSM 7 — which has no systemd — see its own page.

An environment file

Keep configuration in one root-readable file rather than in a unit or a command line. Anything on a command line is visible to every user on the machine through ps, which matters as soon as a token is involved:

# /etc/rescriptum.env   (chmod 600, owned by root)
RESCRIPTUM_ANSWERS_DIR=/srv/answers
RESCRIPTUM_LISTEN_ADDR=0.0.0.0:8000
RESCRIPTUM_TIMEOUT_SECS=10
# RESCRIPTUM_ANSWER_TOKEN=…

Under systemd, EnvironmentFile= below reads it and you need nothing else. Elsewhere — and on DSM 7, which has no systemd — point RESCRIPTUM_ENV_FILE at the same file and the binary reads it itself, refusing to start if it cannot.

A systemd unit

# /etc/systemd/system/rescriptum.service
[Unit]
Description=rescriptum — per-machine answer files for unattended installs
After=network-online.target
Wants=network-online.target

[Service]
ExecStart=/usr/local/bin/rescriptum
EnvironmentFile=/etc/rescriptum.env
Restart=on-failure
RestartSec=2

# It needs to read one directory and bind one port. Nothing else.
DynamicUser=yes
ReadOnlyPaths=/srv/answers
NoNewPrivileges=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectSystem=strict
ProtectHome=yes
ProtectKernelTunables=yes
ProtectControlGroups=yes
RestrictAddressFamilies=AF_INET AF_INET6
SystemCallFilter=@system-service

[Install]
WantedBy=multi-user.target
$ sudo systemctl enable --now rescriptum
$ curl -s http://localhost:8000/health
OK

Adjust for what you actually enable:

  • SQLite store — the database needs to be writable, so ReadWritePaths=/srv and drop ReadOnlyPaths.
  • Request captureReadWritePaths= the capture directory.
  • A port below 1024 — add AmbientCapabilities=CAP_NET_BIND_SERVICE.

Logs go to stderr, so journalctl -u rescriptum -f is the live view.

In a container

There is nothing to install, so the image is the binary:

FROM scratch
COPY rescriptum /rescriptum
ENV RESCRIPTUM_ANSWERS_DIR=/answers RESCRIPTUM_LISTEN_ADDR=0.0.0.0:8000
EXPOSE 8000
ENTRYPOINT ["/rescriptum"]

Use the build for the right architecture — the musl ones are statically linked, which is what makes FROM scratch work. Mount the answers directory read-only.

Sizing it

The defaults are already right for both ends of the range this was built for.

SettingDefaultChange it when
RESCRIPTUM_WORKERSCPU countyou are sharing a small box and want to cap runtime threads
RESCRIPTUM_MAX_CONNECTIONS2048you are seeing 503s during a burst — or want to shed earlier
RESCRIPTUM_TIMEOUT_SECS10clients are on a slow link, or you want to cut off slowloris sooner

MAX_CONNECTIONS is not a throughput limit. Over the cap the server writes a prompt 503 and closes rather than queueing — a client that is told to retry is better off than one parked in a queue that turns a burst into an out-of-memory.

A 2,000-machine rollout completes in under two seconds at the measured throughput, so sizing is rarely the interesting problem. Troubleshooting usually is.

Replacing a running instance

$ ./deploy.sh admin@nas
$ ./deploy.sh admin@nas /volume1/netboot        # a different remote directory

What it does, in order:

  1. Builds for the target (TARGET, default armv7-unknown-linux-gnueabihf).
  2. Checks the local answers with rescriptum check and refuses to continue if anything fails — shipping a broken answer set is worse than not deploying.
  3. Copies the binary under a temporary name, then renames it into place. Replacing a running binary in place is how a half-copied file gets executed.
  4. Stops the running instance, starts the new one detached, and confirms it stayed up.
  5. Confirms /health answers over the network, so a firewall problem is reported as one rather than as a mysterious silence.
EnvironmentDefault
TARGETarmv7-unknown-linux-gnueabihf
ANSWERS<remote-dir>/answers
PORT8000

It replaces what is running; it does not install autostart. On DSM that is a Task Scheduler entry; with systemd it is systemctl enable.

Upgrading

Answers are data, not state: nothing is migrated, and a new binary reads the same directory. Replace it and restart.

The exception is the SQLite store, which carries a schema version. There is one so far, so there is nothing to migrate; what the version buys is the other direction, an older binary refusing to open a database written by a newer one rather than guessing at it. See the SQLite store.

Synology DSM 7

Synology DSM 7

A Synology DS416j is why this project exists: ARMv7, 512 MB of RAM, DSM 7, no Docker. A static binary with no runtime is not an aesthetic preference there — it is the only thing that fits.

DSM 7 does run systemd, but it offers no supported place for a unit of your own: files in /usr/lib/systemd/system are Synology’s, and a DSM update is free to replace them. The supported route to a service is a package — install one and DSM generates pkgctl-rescriptum.service from it. That is what this page leads with; the older Task Scheduler route still works and is kept at the bottom.

Install the package

Download the .spk for your model from the releases page:

FileFor
rescriptum-<version>-armv7.spkDS416j and other Marvell armada38x models
rescriptum-<version>-x86_64.spkevery Intel model

Not sure which? Ask the machine:

$ ssh admin@nas synogetkeyvalue /etc.defaults/synoinfo.conf unique
synology_armada38x_ds416j

Then Package Center → Manual Install, pick the file, and click through the warning that the package is not verified by Synology. That warning is not about this package in particular: DSM 7 removed third-party signing altogether and no longer offers a trust-level setting, so every non-Synology package shows it. Our verification is the SHA-256 sum published beside the .spk:

$ shasum -a 256 -c rescriptum-0.2.0-1-armv7.spk.sha256

The wizard asks two things — where the answers live and which port to listen on — and then the package:

  • creates a rescriptum shared folder and grants itself read/write access to it (if you already have one by that name, it is kept and simply gains the grant);
  • creates the answers directory inside it at every start;
  • registers the port with the DSM firewall, so the service is selectable by name;
  • links rescriptum-cli into /usr/local/bin;
  • starts at boot, and stops and starts from Package Center like anything else.

What the package does not do

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. 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.
  • It does not tell you about updates. There is no package source to poll — the distribution model is: download the new .spk from the releases page and install it by hand, for an upgrade as much as for a first install. Watch the releases.
  • A custom answers path is yours to permission. The package runs unprivileged and cannot grant itself access to a folder you name; if you point it outside the rescriptum share, give the rescriptum user read access yourself.
  • The share’s permission is reapplied at every start. If you deliberately narrow it, you will find it restored the next time the package starts.

Where everything lives

WhatWhereSurvives an upgradeSurvives uninstall
binary, rescriptum-cli, the example env file/var/packages/rescriptum/target/no — replacedno
the env file/var/packages/rescriptum/etc/rescriptum.envyesyes — see below
log, pidfile, captures/var/packages/rescriptum/var/yesyes
answers/var/packages/rescriptum/shares/rescriptum/answers/yesyes — always
the SQLite database, if you use onebeside the answers, in the same shareyesyes — always

Use the shares/ path rather than /volume1/…: it is a symlink DSM maintains, so it keeps working on a NAS whose data is not on volume 1.

Uninstalling leaves the shared folder and everything in it alone. That is both DSM’s own behaviour and ours: when the store is SQLite, the database is your answers.

Uninstalling also leaves your configuration behind, and that is worth knowing. etc/ and var/ are symlinks into /volume1/@appconf/rescriptum and /volume1/@appdata/rescriptum, which DSM keeps — so the env file stays on the volume after the package is gone, with whatever tokens are in it. Reinstalling picks it back up, which is usually what you want. If you are removing rescriptum for good and it held a token, delete /volume1/@appconf/rescriptum yourself.

The desktop application

The package installs an application on the DSM desktop — the icon is in the main menu, and Package Center’s Open button leads to it. It is a real DSM application, built on the desktop’s own UI framework, so it is in the DSM theme and in the DSM language; the French of a French DSM is the application’s French too.

It has three tabs:

  • Settings — every configuration variable, as a form. Each field says where its value comes from, and a value the environment sets is shown but locked, because editing the file would not change it. Saving writes the file and offers to restart the package, since the server reads its configuration once, at startup.
  • Status — the version, whether the package is running, the answers folder and whether it is really readable by the service’s own user, and the output of check.
  • Log — the last lines of the request log and of startup.log.

Three properties are worth knowing rather than discovering:

  • It edits the file, not the running server. So it still works when the server will not start, which is exactly when a settings panel earns its place. A change that would leave the server unable to start is refused before anything is written, with the reason shown.
  • It never shows you a token. RESCRIPTUM_ANSWER_TOKEN and RESCRIPTUM_ADMIN_TOKEN appear as set or not set, and an empty box means “leave it alone” rather than “clear it”. Typing a new one replaces it.
  • It requires a DSM administrator. Being signed in to DSM is not enough. See security for why that check is the whole door.

Restart now stops and starts the package through DSM itself, so DSM closes the window while it does — open it again to see the new state. The application says so next to the button rather than letting it surprise you.

It needs DSM 7.1 or newer (os_min_ver="7.1-42661"). It is built on DSM’s ExtJS framework, which is present on 7.1.1 and on 7.2.2 — both measured. DSM 7.2 ships a newer Vue framework and Synology’s current guide documents only that one; the DS416j this project exists for is capped at 7.1.1, where Vue is undefined, so ExtJS is what covers every DSM this package supports rather than only the recent ones. 7.0 is not claimed because nothing has been run there.

Configuring it

The application above is the comfortable way. Everything it does can also be done from a shell, and on a machine where the desktop is not to hand that is the faster route:

$ sudo rescriptum-cli config
env file: /var/packages/rescriptum/etc/rescriptum.env

  RESCRIPTUM_STORE            files                             default
  RESCRIPTUM_ANSWERS_DIR      /var/packages/rescriptum/shares/rescriptum/answers   file
  RESCRIPTUM_LISTEN_ADDR      0.0.0.0:8000                      file


$ sudo rescriptum-cli config set RESCRIPTUM_LOG=problems
wrote /var/packages/rescriptum/etc/rescriptum.env

config set keeps the file’s comments, uncomments a setting rather than duplicating it, and refuses a change that would stop the server starting. Its exit code says whether the configuration is one the server would start on, which makes it usable from a script.

Underneath both is the same file, and editing it by hand is still perfectly reasonable:

$ sudo vi /var/packages/rescriptum/etc/rescriptum.env

postinst writes it complete on a fresh install, with the variables in use uncommented and the rest commented with a line saying what they do. Stop and start the package from Package Center to apply a change — the server reads the file at every start.

An upgrade never touches it. The complete example for the version you have is at /var/packages/rescriptum/target/etc/rescriptum.env.example, rewritten on every install and upgrade, which is how a new variable becomes visible without disturbing your live file. Every variable is in the configuration reference.

The file is chmod 600 and owned by the package user. It is where RESCRIPTUM_ANSWER_TOKEN and RESCRIPTUM_ADMIN_TOKEN live, and — being under etc/ — it is a plausible passenger in a DSM configuration backup. Worth knowing rather than discovering.

The admin API is off by default and, when you enable it, should stay on loopback and be reached through an SSH tunnel; it is deliberately not registered with the firewall. It also requires RESCRIPTUM_STORE=sqlite and a token of at least 16 characters, both of which are startup errors — so getting them wrong shows up as a package that will not start, with the reason in /var/log/packages/rescriptum.log.

Putting answers in place

Drop files into the rescriptum shared folder’s answers directory, over File Station or over SSH, exactly as you would anywhere else — see writing answers. Then validate them as the package user:

$ sudo -u rescriptum rescriptum-cli check

The sudo -u matters. Run as root it succeeds whatever the shared folder’s permissions say, which makes a successful run meaningless. rescriptum-cli is the packaged wrapper: it names the env file, so check and render look at this machine’s answers rather than at /srv/answers.

The firewall

Control Panel → Security → Firewall — create a rule allowing rescriptum from your provisioning network. The service appears by name because the package registered its port.

DSM’s firewall is the single most common reason a machine “never contacts the server”.

If you change the port later, edit RESCRIPTUM_LISTEN_ADDR in the env file and then move 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:
$ 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.

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:

$ 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:

$ 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.

$ 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:

$ 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, and the package installs a logrotate stanza for it — weekly, eight kept, copytruncate (the server opens its log once and never reopens it, so anything else would silently end logging). Beside it, var/startup.log holds what the server says before it knows where its log lives: a configuration error, a malformed env file.

Once a rollout is routine, RESCRIPTUM_LOG=problems keeps the failures and drops the successful answers, which are the only high-volume thing in there.

When it will not start

Three places say why, in this order:

$ cat /var/log/packages/rescriptum.log        # the package scripts' own output
$ cat /var/packages/rescriptum/var/startup.log  # what the server said before it had a log
$ cat /var/packages/rescriptum/var/rescriptum.log
$ systemctl status pkgctl-rescriptum          # what DSM's service manager saw

A refused configuration — an admin token under 16 characters, a store that cannot be opened — is reported after the server knows where its log lives, so it lands in rescriptum.log; a malformed env file is reported before, and lands in startup.log. The package’s start prints the tail of both when the server exits immediately, so Package Center shows you the reason rather than only the failure.

DSM does not restart the process if it dies. The unit it generates is Type=oneshot with RemainAfterExit=yes and no Restart=, so a server that exits stays stopped until you start it from Package Center. That is not a regression — the Task Scheduler route did not restart it either — but it is worth knowing before you rely on it.

A package that installs, starts, and then answers 404 to everything is almost always the answers directory: check sudo -u rescriptum rescriptum-cli check. On a NAS with an encrypted shared folder, that is also what a boot before the volume is unlocked looks like — unlock it and restart the package.

Verify

$ curl http://NAS_IP:8000/health
OK

Without the package

The manual route still works, and is the honest choice if you would rather not install a package at all.

Use the armv7-unknown-linux-gnueabihf build (or x86_64-unknown-linux-musl, or aarch64-unknown-linux-musl for a newer ARM model) from the releases page, or cross-compile one yourself (see building).

$ scp rescriptum admin@nas:/volume1/netboot/rescriptum
$ ssh admin@nas chmod +x /volume1/netboot/rescriptum
$ ssh admin@nas mkdir -p /volume1/netboot/answers

If ARMv7 misbehaves, confirm the real architecture before assuming:

$ ssh admin@nas uname -m
armv7l

Take the ARMv7 build, not a musl one you built yourself. The published armv7 binary is linked against glibc 2.17, which DSM has; a musl build of the same code installs, answers --version, and then dies the moment it wants the time. Synology’s 3.10 kernels answer the time64 syscalls with EINVAL rather than ENOSYS, and musl 1.2 only falls back on ENOSYS — the build page has the measurement. The x86_64 and aarch64 builds are static musl and unaffected.

$ file rescriptum
ELF 32-bit LSB pie executable, ARM, EABI5 version 1 (SYSV), dynamically linked, ...

RESCRIPTUM_ANSWERS_DIR defaults to /srv/answers, which does not exist on DSM, so set it explicitly. The env file below is the tidiest place to do that.

Control Panel → Task Scheduler → Create → Triggered Task → User-defined script

FieldValue
EventBoot-up
Userroot
Commandsee below

If you use a token, do not put it in that box. Anything in a process’s arguments — and in DSM’s case, in the task definition — is readable by every user on the machine through ps. Put the configuration in a root-only file and name it instead:

# /volume1/netboot/rescriptum.env   (chmod 600, owned by root)
RESCRIPTUM_ANSWERS_DIR=/volume1/netboot/answers
RESCRIPTUM_LOG_FILE=/volume1/netboot/rescriptum.log
RESCRIPTUM_STORE=sqlite
RESCRIPTUM_DB_PATH=/volume1/netboot/answers.db
RESCRIPTUM_ADMIN_ADDR=127.0.0.1:8001
RESCRIPTUM_ADMIN_TOKEN=
RESCRIPTUM_ANSWER_TOKEN=
# the Task Scheduler entry runs this
RESCRIPTUM_ENV_FILE=/volume1/netboot/rescriptum.env exec /volume1/netboot/rescriptum

Prefer this to sourcing it. The older form — . /volume1/netboot/rescriptum.env && exec … — works, and still does, but it fails silently: drop the leading ., mistype a line, or get the permissions wrong, and the shell sources nothing while the server comes up on its defaults — the default answers directory, no admin token, and not a word about it in the log. With RESCRIPTUM_ENV_FILE the binary reads the file itself and refuses to start if it cannot. It also warns if the file is readable by anyone but root, and names any key it does not recognise, so a RESCRIPTUM_ADMIN_TOKENN is caught rather than quietly ignored.

Details of the format are in the configuration reference.

Run the task once by hand from the Task Scheduler rather than waiting for a reboot to find out it does not work. Then open the port in the firewall by number, and rotate the log yourself — the server does not, and nothing else will either.

Replacing a running instance

$ ./deploy.sh admin@nas

It builds for ARMv7, checks the answers first, copies the binary under a temporary name so a half-copied file is never executed, restarts it, and confirms /health responds. Details in deployment.

The Task Scheduler entry is still what starts it after a reboot — deploy.sh only replaces what is running now. On a packaged install, use Package Center instead.

Shutdown

Both routes send SIGTERM, which the server handles: it stops accepting and exits. There is no state to lose either way.

What to expect from a DS416j

512 MB and an ARMv7 core is not much, and it does not need to be. Measured on a DS416j running the package, over the LAN: 3–4 ms to compose and serve an answer, network round trip included, for a machine claimed by a group and merged with its own file. A connection costs kilobytes rather than a thread, the directory listing is cached and invalidated by mtime rather than walked per request, and a group with no per-machine overrides is rendered once at load and served afterwards as a prepared string.

The one thing worth knowing: filesystem work happens on a blocking thread pool, because read_dir on a NAS with a sleeping disk is not a fast call, and blocking an async worker would stall every other connection it was driving.

Security

Security

Answer documents carry root-password-hashed and root-ssh-keys. Whoever can read them can log into every machine you install; whoever can write them decides those credentials. That is the whole of the threat model, and it is worth being plain about.

The answer endpoint is open by default

By default, anyone who can reach the port can fetch an answer. That is not an oversight: most installers have no credential to offer. A kickstart client fetching inst.ks=http://… has nothing to present, and refusing it would refuse the install.

The right primary control is the network. A provisioning VLAN that only PXE-booting machines sit on is worth more than any token.

RESCRIPTUM_ANSWER_TOKEN

Proxmox can present a credential when its ISO was prepared for it:

$ proxmox-auto-install-assistant prepare-iso … --answer-auth-token 'a-long-random-string'
$ export RESCRIPTUM_ANSWER_TOKEN='a-long-random-string'

The installer then sends Authorization: Bearer …, and the server refuses anything without it, comparing in constant time.

Failures here are logged but never rate-limited. A whole rack can sit behind one address, and shutting that address out would turn one bad token into a failed rollout. The admin API, which no installer talks to, does lock out.

A token shorter than 16 characters is a startup warning, not an error — refusing to start would leave a fleet unable to install.

GET /health stays open either way, so monitoring does not go dark.

The admin API token

RESCRIPTUM_ADMIN_TOKEN is a different thing protecting a different surface, and it is treated accordingly. The admin API sets the root password and SSH keys of every machine installed afterwards, so:

  • it never shares the answer endpoint’s listener — its own RESCRIPTUM_ADMIN_ADDR;
  • the server refuses to start without a token, with a token under 16 characters, or over the file store — errors, not warnings;
  • an address that keeps guessing is shut out: five failures within a minute earn a block, doubling on repeats up to fifteen minutes, and the block applies to a correct token from that address too — otherwise guessing until you got it right would cost nothing.

Generate a real one. Not a word you thought of:

$ openssl rand -hex 24        # or: head -c 24 /dev/urandom | base64

Full details on the admin API page.

Why constant-time comparison

An ordinary == returns as soon as two bytes differ, so a wrong token sharing a longer prefix takes measurably longer to reject. That difference is enough to recover a token one byte at a time — a few thousand requests rather than an impossible number. Comparing every byte regardless removes the signal.

Over a network the timing is usually lost in jitter, so this is precautionary. It costs five lines.

Do not put a token on a command line

Anything in a process’s arguments is visible to every other user on the machine through ps. That includes putting it directly in a DSM scheduled task. Keep it in a file only root can read:

# /etc/rescriptum.env   (chmod 600, owned by root)
RESCRIPTUM_STORE=sqlite
RESCRIPTUM_DB_PATH=/srv/answers.db
RESCRIPTUM_ADMIN_ADDR=127.0.0.1:8001
RESCRIPTUM_ADMIN_TOKEN=

Then hand it to the server — EnvironmentFile=/etc/rescriptum.env under systemd, or RESCRIPTUM_ENV_FILE=/etc/rescriptum.env anywhere else. The second form makes the binary read it, so a file it cannot read is a startup error rather than a server quietly running without the token you thought you had set. The file is never discovered on its own — there is no ./.env, deliberately: this process runs as root, and a file picked up from the working directory would be a way to hand someone the admin token.

What the server refuses on its own

Path traversala filesystem path is never built from request data. Only direct entries of the answers directory are read. Identifiers reaching the admin API accept letters, digits and - _ . : only, because export turns them back into filenames
Oversized bodiesan implausible Content-Length is refused from the header, before anything is read; the body is capped at 1 MB regardless
Slow clientsa header-read timeout and a whole-connection deadline, so a client that promises a body and sends nothing cannot park a connection
Burstsover RESCRIPTUM_MAX_CONNECTIONS in flight, a prompt 503 and close, rather than queueing into an out-of-memory
Malformed inputa parse failure is an error response and a log line, never a panic that takes a connection — or a server — down mid-install

TLS

The server speaks plain HTTP. On a trusted provisioning network that is normally fine, and it is what keeps the binary small and dependency-free.

If you need TLS — some installer versions want a certificate fingerprint when fetching over HTTPS — terminate it in front with nginx or Caddy and point the ISO at that. The answer endpoint does not care what is upstream of it.

The admin API is the one place where this matters by default: it speaks plain HTTP too, so the token crosses the network in the clear. On loopback that is moot. Anywhere else, put a TLS-terminating proxy in front.

The desktop application

Only on Synology, and only there — the DSM application is part of the package, not of the server. Its backend is a CGI that DSM serves from /webman/3rdparty/rescriptum/, and two things about that path decide its whole security model. Both were measured on a DSM 7.2.2 machine rather than read in a guide, which does not mention either:

  1. A CGI there runs as the owner of the script. DSM chowns a package’s files to the package user, so the backend runs as rescriptum — the same identity that owns the 0600 env file and the log. That is what lets the application edit the configuration and read the log while the server itself is stopped, which is exactly when a settings panel earns its place. It is not root, and it cannot become anybody: it has no privilege to start or stop the package, which is why restarting goes through DSM’s own API with the administrator’s session instead. (A script left owned by root does run as root there. Worth knowing, and worth never doing.)
  2. DSM does not authenticate that path. An unauthenticated request reaches the script and is answered. DSM protects its own pages; a package’s are the package’s problem.

Put together: the checks inside the script are the only thing in front of it, so it makes three, in this order, before it touches anything.

  • A DSM session. It runs DSM’s own authenticate.cgi, which prints the signed-in user’s name and prints nothing at all when there is no session.
  • An administrator. Being signed in is not enough; the user must be in administrators. Anything less would let any account on the NAS set the root password of every machine it installs.
  • Intent, for a write. A write must carry a header the application sends and a form on another site cannot: a browser will not send an invented header cross-origin without a preflight first, and this script answers no preflight. DSM’s own SynoToken is sent along too, which is what keeps the application working with DSM’s cross-site request forgery protection switched on.

check-spk.sh asserts that the first two are still in the script, and lifecycle-test.sh drives the script with a stubbed authenticator to prove all three actually refuse. They were watched failing: removing the session check turns four green into four red.

The application never receives a token. RESCRIPTUM_ANSWER_TOKEN and RESCRIPTUM_ADMIN_TOKEN reach it as set or not set and nothing more — the command it calls will not print a credential, whatever it is asked.

Known and accepted

  • Per-address rate limiting does not stop an attacker with many addresses. The admin token’s length is what makes guessing hopeless — hence the 16-character floor.
  • The answer endpoint is not rate-limited at all, deliberately, for the reason above.
  • Binding the admin API beyond loopback is your call, and the server says so in the log when you do. 127.0.0.1 plus an SSH tunnel is the safe default.

Capturing requests

Capturing requests

Most of what rescriptum knows about installers comes from their documentation. Until a real installer has talked to it, that is a claim rather than a fact — and when a rollout misbehaves, “what did node07 actually send?” is usually the only question worth answering.

$ export RESCRIPTUM_CAPTURE_DIR=/var/log/rescriptum-captures

Off unless set.

What it writes

Two files per request:

20260824T084337Z-10.0.0.42-0000.body     the body, verbatim
20260824T084337Z-10.0.0.42-0000.meta     who asked, and what they got
time: 2026-08-24T08:43:37Z
peer: 10.0.0.42:51234
request: POST /proxmox/answer
body-bytes: 1876
outcome: 200 format=toml machine=98fa9b50d810 group=rack-a

The .body is byte-for-byte what arrived, so it replays unchanged. The filename carries the timestamp, the peer address (sanitised — an IPv6 peer’s colons do not belong in a filename) and a sequence number, so two requests in the same second do not collide.

Replaying one

$ rescriptum render --body /var/log/rescriptum-captures/20260824T084337Z-10.0.0.42-0000.body

That resolves exactly as the server did, offline, with no machine involved — which is what makes a bad answer debuggable at your desk instead of in front of a rack.

It is also the best way to build selectors against a body format you have not seen: capture one real request, then iterate with render --body until it resolves the way you meant.

The limits, and why

  • Capped at 1000 captures. A provisioning server that fills its own disk is worse than one that captures nothing. On reaching the cap it logs once and stops writing. The count is of captures, not files, and it survives a restart: the server counts what is already in the directory before it writes anything.
  • Nothing is ever deleted. Rotating or clearing the directory is yours to do; the server counts what is already there at startup so a restart does not blow past the cap.
  • A capture failure never fails a request. It is logged, and the install carries on. Losing a diagnostic is not worth losing an install.

Before you attach one to a bug report

A captured body is a hardware inventory: MAC addresses, disk serials, DMI. The .meta says which answer it received. Neither contains your password hashes — but the answer does, so scrub anything you paste alongside it.

The SQLite store

The SQLite store

A directory of files is the default, and it is the right answer for a handful of machines: greppable, diffable, in git if you like, with no database to run, back up or migrate.

For a fleet administered by tooling rather than by hand, the same answers can live in a SQLite database. It is compiled into the binary, so there is still nothing to install.

$ export RESCRIPTUM_STORE=sqlite RESCRIPTUM_DB_PATH=/srv/answers.db
$ rescriptum import /srv/answers      # bring the files across
$ rescriptum check                    # the same checks, now against the database
$ rescriptum                          # serve from it

Why you would

  • The admin API needs it. Managing answers over HTTP requires the database; over files there would be two ways to change the same configuration — by hand and over the wire — racing each other.
  • Concurrent writes are safe. WAL mode, so an administrative write never stalls an install in progress.
  • One file to back up, and it moves atomically.

Why you might not

  • A directory is legible. git log answers/ answers “who changed this rack and why”; a database does not, unless your tooling records it.
  • A file is editable with anything. vi, scp, a Makefile.
  • It is 1.2 MB of binary — 2.4 MB with SQLite versus 1.3 MB without, on ARMv7. Build with cargo build --no-default-features if that matters and you do not need it.

The behaviour is identical

Matching, groups, extends, merging, templating, render, check — all of it lives above the store, which is deliberately thin: it hands back raw document text and a cheap version token, and decides nothing.

That is enforced rather than asserted. tests/stores.rs runs every behavioural case twice, once per store, and requires the identical outcome. A new behaviour belongs in that suite, not in a store-specific test.

Moving between them

$ rescriptum import /srv/answers      # directory → the configured store
$ rescriptum export /tmp/backup       # the configured store → a directory
$ RESCRIPTUM_STORE=sqlite RESCRIPTUM_DB_PATH=/srv/answers.db rescriptum import examples
copying files:examples -> sqlite:/srv/answers.db
  10 group(s), 8 machine(s)
  ok — now run `check` against the target

The round trip is byte-identical. Import a directory, export it again, and diff -r reports nothing — comments, formatting and all. That is what makes the database safe to adopt and safe to leave, and it is worth keeping true.

Both directions run check afterwards on your say-so rather than automatically; the output above tells you to.

Schema versions

The database carries a schema version (user_version). There is one so far, and nothing has been released under an older one, so there is nothing to migrate from.

What the version is for is the other direction: an older binary refuses to open a database written by a newer one rather than guessing at what changed.

database schema is version 2, this binary understands 1

So a rollback across a future schema change needs the export from before the upgrade, or a binary new enough to read the database. Keep an export around when you upgrade across one.

Operational notes

  • version() is an in-process atomic, not a query, because it is called per request. A change made by a different process is picked up by the 1-second reload backstop instead.
  • The database file and its -wal/-shm siblings all need to be writable, and they all belong to the same backup.
  • RESCRIPTUM_DB_PATH defaults to /srv/answers.db, a sibling of the default answers directory. The database holds the same curated content, not runtime state, so it belongs in the same tree.
  • The parent directory is created if it does not exist.

The admin API

The admin API

With RESCRIPTUM_STORE=sqlite, answers can be managed over HTTP instead of by editing files. It is off unless you configure it, and it runs on its own listener.

$ export RESCRIPTUM_STORE=sqlite RESCRIPTUM_DB_PATH=/srv/answers.db
$ export RESCRIPTUM_ADMIN_ADDR=127.0.0.1:8001
$ export RESCRIPTUM_ADMIN_TOKEN=$(openssl rand -hex 24)
$ rescriptum
2026-08-24T08:52:30Z - admin API listening on 127.0.0.1:8001
2026-08-24T08:52:30Z - rescriptum 0.1.0 listening on 0.0.0.0:8000 — store=sqlite:/srv/answers.db …

Three properties that are load-bearing

1. Its own listener. The answer endpoint is unauthenticated by necessity — the installer has no credentials to offer. This API sets the root password and SSH keys of every machine installed afterwards. It never shares that port.

2. SQLite only. Over a directory of files there would be two ways to change the same configuration, by hand and over the wire, racing each other.

3. A write can never leave the answer set broken. Every write snapshots the current problems, applies itself, and compares. Anything newly broken is rolled back and answered 409.

The server refuses to start — as an error, not a warning — if you point the admin API at the file store, leave the token out, or set a token shorter than 16 characters.

Endpoints

RequestDoes
GET /machines, GET /groupslist identifiers
GET /machines/{id}, GET /groups/{name}, GET /defaultthe stored document, as written — comments and formatting intact
PUT /machines/{id}, PUT /groups/{name}, PUT /defaultstore a document (the body is the document)
DELETE /machines/{id}, DELETE /groups/{name}, DELETE /defaultremove one
GET /resolve/{id}the merged answer that machine would receive
GET /checkcurrent problems, the same set as the check subcommand
GET /healthliveness — the only endpoint needing no token, and never blocked

Every endpoint that names a document takes ?format= — the extension the document is in. It defaults to toml, which is what this server started life serving:

$ curl -H "$AUTH" -X PUT --data-binary @base.preseed \
    'http://127.0.0.1:8001/groups/base?format=preseed'

Because a document’s key is (identifier, format), an identifier appears in GET /machines once per format it exists in — a machine that is both a Proxmox node and a Debian node is listed twice.

Examples

$ AUTH="Authorization: Bearer $RESCRIPTUM_ADMIN_TOKEN"

$ curl -s -H "$AUTH" http://127.0.0.1:8001/groups
{"group":["base","example-rack","rhel-compute","ubuntu-web"]}

$ curl -s -H "$AUTH" -X PUT --data-binary @rack-a.toml \
    http://127.0.0.1:8001/groups/rack-a
{"status":"stored","problems":[]}

$ curl -s -H "$AUTH" http://127.0.0.1:8001/resolve/98:fa:9b:50:d8:10
[global]
country = "fr"
keyboard = "fr"

GET /resolve also answers the response header X-Answer-Source, carrying the same description the log line uses:

x-answer-source: format=toml machine=98fa9b50d810 group=example-rack

Rehearsing a real request

GET /resolve accepts the same labels a real request would carry, so you can rehearse a particular URL — the difference between /user-data and /meta-data, for instance:

$ curl -s -H "$AUTH" 'http://127.0.0.1:8001/resolve?path=/rhel/ks&serial=7ABC123'

When a query string is present, the identifier in the path is ignored — the facts come from the query alone. So GET /resolve/98:fa:9b:50:d8:10?format=toml resolves nothing, because format=toml is not an identity. Use the bare path form, or put the identity in the query: ?mac=98:fa:9b:50:d8:10.

It will not let you break the fleet

Every write is checked after it is applied. If it introduced a problem — a cycle between groups, a document referring to a group that no longer exists — the write is rolled back and you get a 409 saying what you broke:

$ curl -s -H "$AUTH" -X DELETE 'http://127.0.0.1:8001/groups/base?format=preseed'
{"error":"refused: this would break the answer set (rolled back)",
 "problems":["machine \"98fa9b50d810\": extends unknown group \"base\""]}

Two things follow from how this works:

  • A successful write still reports any pre-existing problems, in the problems array. A clean response never implies the whole set is healthy — only that you did not make it worse.
  • It is why a machine’s extends pointing at a missing group is detected at load time rather than only when that machine asks. The guard can only catch what the problem report knows about.

Malformed documents are refused at write time too, rather than becoming a 500 the next time a machine asks:

$ curl -s -H "$AUTH" -X PUT --data-binary 'x = = 1' http://127.0.0.1:8001/machines/aa-bb-cc-dd-ee-01
{"error":"document: invalid TOML: TOML parse error at line 1, column 5 …"}

Identifiers

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

CodeMeans
200done
400a malformed document, or an invalid identifier
401missing or wrong token
404no such document, or nothing resolves for that identifier
409a write that would have broken the answer set (rolled back), or a resolve that could not render
413document over 256 KB
429this address is blocked after repeated authentication failures
500the store could not be read or written

Looking after the token

The token is the whole of the authentication, and what it protects is worth saying plainly: answer documents carry root-password-hashed and root-ssh-keys, so whoever can write to this API decides the root credentials of every machine you install afterwards.

Generate a real one — not a word you thought of:

$ openssl rand -hex 24        # or: head -c 24 /dev/urandom | base64

Do not put it on a command line. Anything in a process’s arguments is visible to every other user through ps, which includes putting it directly in a DSM scheduled task. Keep it in a root-only file and source it — see Security.

What the server does on its side:

  • Compares the token in constant time, so it cannot be recovered a byte at a time by whoever is timing the responses.
  • Shuts out an address that keeps guessing. Five failures within a minute earn a block, doubling on repeats to a maximum of fifteen minutes, and every attempt is logged. The block applies to a correct token from that address too — otherwise guessing until you got it right would cost nothing.
  • Bounds its own bookkeeping to 4096 tracked addresses, so the guard cannot itself be turned into a memory leak.
  • Leaves GET /health unauthenticated and unblocked, so monitoring does not go dark during an attack.
2026-08-24T08:52:32Z - admin: 10.0.0.9 failed authentication 5 times — blocked for 60s

Two limits to plan around

  • It speaks plain HTTP, so the token crosses the network in the clear. On loopback that is moot. Anywhere else, put a TLS-terminating reverse proxy in front.
  • Per-address blocking does not stop an attacker with many addresses. The token’s length is what makes guessing hopeless — hence the 16-character floor at startup.

Binding it beyond loopback is your call, and the server says so in the log when you do:

2026-08-24T08:52:30Z - warning: the admin API is not bound to loopback — it rewrites what gets installed on every machine, so restrict it to a management network

127.0.0.1 plus an SSH tunnel is the safe default.

Troubleshooting

Troubleshooting

When a PXE install will not start, the log is the only diagnostic anyone has — so it is deliberately boring and greppable: one line per request, on stderr. Both halves are configurable: RESCRIPTUM_LOG drops the requests that worked, and RESCRIPTUM_LOG_FILE sends the lines to a file instead.

2026-08-24T08:43:36Z - rescriptum 0.1.0 listening on 127.0.0.1:8999 — store=files:answers workers=10 max_conn=2048 timeout=10s
2026-08-24T08:43:37Z 127.0.0.1:61720 GET /health 200
2026-08-24T08:43:37Z 127.0.0.1:61721 POST /answer body=102 200 format=toml machine=98fa9b50d810 group=example-rack bytes=431
2026-08-24T08:43:37Z 127.0.0.1:61722 GET /rhel/ks?serial=7ABC123 body=0 200 format=text group=rhel-compute bytes=747
2026-08-24T08:43:37Z 127.0.0.1:61723 POST /answer body=27 404 no answer file applies

Reading a line

2026-08-24T08:43:37Z 127.0.0.1:61721 POST /answer body=102 200 format=toml machine=98fa9b50d810 group=example-rack bytes=431
└─ UTC timestamp     └─ peer         └─ request      └─ body   └─ status
                                                                  └─ how the answer was composed  └─ bytes sent

Lines with - instead of a peer address are server-level: startup, accept failures, shedding, load-time problems with the answer set.

format=… names the family (toml, yaml, json, xml, text) rather than the extension — ks and preseed both report as text.

Common failures

SymptomLikely cause
404 no answer file appliesNothing claimed the request and there is no default for the format asked for. Capture the body and check the MAC really is in it
404 on a URL that used to workThe URL now names a format alias that excludes your document — /ubuntu/answer will not serve a .toml
500 … extends unknown groupA document references a group that does not exist. Deliberate: serving a configuration whose base is missing would install the machine half-configured
500 on one machine onlyThat machine’s document, or its group, will not parse. The reason is on the same log line. rescriptum check finds it without waiting for the machine to ask
500 template needs {{ … }}A placeholder the request could not fill. Never served as an empty string, on purpose
401 bad or missing tokenRESCRIPTUM_ANSWER_TOKEN is set but the ISO was not prepared with the same --answer-auth-token
413A body over 1 MB, or a Content-Length claiming one. Refused from the header, before anything is read
503More concurrent connections than RESCRIPTUM_MAX_CONNECTIONS. Raise it, or find out who is connecting
Answer served, install still failsThe document is valid TOML but not valid Proxmox. Pipe render into validate-answer
Installer never contacts the server at allThe ISO’s URL or a firewall, not this server. curl http://SERVER:8000/health from the same network

The server starts but everything 404s

Check the startup line. The two usual causes announce themselves:

warning: /srv/answers does not exist yet — every request will 404 until it does
warning: /srv/answers cannot be read: Permission denied (os error 13) — every request
will 404 until that is fixed; check the directory's owner against the user this server
runs as

That second one is what you get when the directory exists but the process cannot list it — the usual cause is a directory created as root and a server running as somebody else. It is asked of the filesystem rather than read off the permission bits, so it accounts for the owner, the group, ACLs and the mount.

… store=files:/srv/answers …

— that second one is the default answers directory. If you meant a different one, RESCRIPTUM_ANSWERS_DIR did not reach the process. An empty or whitespace-only value is treated as unset, and a zero or unparseable number falls back to its default.

Reproducing it offline

This is the fastest route from “a machine got the wrong thing” to a fix:

$ export RESCRIPTUM_CAPTURE_DIR=/var/log/rescriptum-captures   # then let it fail once more
$ rescriptum render --body /var/log/rescriptum-captures/2026…-0000.body

render resolves exactly as the server does, so whatever it prints is what that machine would have received. No rack required. See capturing requests.

If you have no capture, rehearse from the identity and the URL:

$ rescriptum render --query "path=/rhel/ks&mac=98:fa:9b:50:d8:10&serial=7ABC123"

Add path= — without it, resolution is unconstrained by format and may pick a document the real URL would have excluded, which is exactly the bug you might be chasing.

Checking the whole set

$ rescriptum check

Load-time problems, every machine and group member rendered, and the installer’s own validator run where it is on PATH. Details in validating.

Reporting something

For a wrong answer, the useful report is what the machine sent and what it got back — the .body and .meta from a capture, plus the log line.

Scrub the password hashes and SSH keys before attaching anything: the answer is in the capture’s outcome only by name, but if you paste the rendered document too, it carries real credentials.

Open an issue with those and the version from the startup line.

Serving boot media

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.

$ 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), 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

$ 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

$ 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:

$ 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

$ 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 sanbooted, 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.

RouteWhat comes back
GET /the catalogue as text, or JSON with Accept: application/json
GET /<id>/isothe image
GET /<id>/kernelthe kernel, streamed from inside the image
GET /<id>/initrdthe initrd, likewise
GET /<id>/initrd+isothe initrd with the image appended, for old loaders
GET /<id>/file/<path>any file inside the image
GET /health200 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:

$ 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:

$ 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:

FamilyHow the answer reaches it
Proxmox VEinside the image, via auto-installer-mode.toml — and proxmox-start-auto-installer on the command line to select the automated path
Debianpreseed/url=…
Ubuntuds=nocloud-net;s=…/, from which cloud-init fetches user-data and meta-data
RHEL familyinst.ks=…
SUSEautoyast=…
Fedora CoreOSignition.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.

::: 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.

$ 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

$ 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:

$ 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.

$ 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

$ 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:

$ 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.

Tuning

VariableDefaultWhat it is for
RESCRIPTUM_MEDIA_ADDR0.0.0.0:8001The listener
RESCRIPTUM_MEDIA_TIMEOUT_SECS600Whole-transfer deadline
RESCRIPTUM_MEDIA_MAX_CONNECTIONS16Concurrent 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.

Netbooting a machine

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

$ 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:

$ 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 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:

$ 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

$ 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:

$ 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 93ClientServed
0x0000BIOS PXEipxe-undionly.kpxe
0x0007, 0x0009UEFI x86-64ipxe-x86_64.efi, plus -snp / -snponly
0x000bUEFI ARM64ipxe-arm64.efi
0x0010, 0x0013UEFI HTTP Bootthe same files, over HTTP, no TFTP at all
everything else32-bit UEFI, EBC, U-Bootrefused, 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-<version>.tar.gz. Unpack it where the server can read it, name the directory, and check it:

$ 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:

$ 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_UNCLAIMEDA 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
localis 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.

$ 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:

[post-installation-webhook]
url = "http://192.0.2.10:8000/installed"
auth-token = "nas:s3cr3t"
$ 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:

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:

FamilyWhere
Proxmox[post-installation-webhook] — native, nothing to write
Debiand-i preseed/late_command string in-target sh -c '…'
Ubuntulate-commands: in the autoinstall document
RHEL, AlmaLinux, Rockythe %post section of the kickstart
SUSE<scripts><chroot-scripts> 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:

# 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}:

$ 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 net0net0 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

$ 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, routingunaffected — it speaks none of those protocols
Machines already installed and runningunaffected
Machines rebootingunaffected — they boot from disk
A machine that PXE-boots by accidentfalls to its next boot device, as it would anyway
Starting a new installationstops

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:

$ 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.

::: 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.