an answer written for this machine
rescriptum
Complete documentation
Guide
What rescriptum is
What rescriptum is
rescriptum — in Roman law, an authority’s written answer to a question raised by a particular case. You set out your situation; you received a document drafted for it.
rescriptum serves the install config a machine asks for while installing itself, and composes it per machine out of the layers you share across a rack. One server answers every installer you run. Serving a file is easy — deciding which file is the whole job.
The problem
Every unattended installer fetches its configuration over HTTP, and every machine needs a different one. The URL is baked into the media, so it is identical on every machine — which means a static file server cannot serve them. Installers ask in one of two shapes.
They POST what they found
Since Proxmox VE 8.2, an installer prepared with --fetch-from http POSTs a JSON
description of the hardware it found — NICs and their MAC addresses, disks, DMI — and
expects the answer file back in the response body:
{
"network_interfaces": [{ "mac": "98:fa:9b:50:d8:10", "link": "up" }],
"dmi": { "system": { "serial": "7ABC123", "product": "PowerEdge R620" } }
}
The reply depends on the request. A static file server cannot do that: it has one answer for one URL, and the URL is baked into the ISO — identical on every machine.
They GET with their identity in the query string
Everything else has the opposite shape. A kickstart, a preseed, an Ubuntu autoinstall, an Ignition config, an AutoYaST profile are fetched, with the machine’s identity in the query string, because iPXE substitutes it into the URL it was told to fetch:
GET /rhel/ks?serial=7ABC123&mac=98:fa:9b:50:d8:10
Either way, the answer has to be chosen — and usually assembled — per machine.
The four ideas
1. The endpoint declares the format. A kickstart client wants kickstart and would
choke on TOML. So /rhel/ks serves .ks documents and nothing else, /proxmox/answer
serves .toml, /ubuntu/ serves YAML. The consequence that makes the model click:
a machine’s answer is specific to the OS it is for, so 98fa9b50d810/proxmox.toml is not
“that machine” but “that machine as Proxmox” — and 98fa9b50d810/debian.preseed, in the
same directory, is the same hardware as Debian. Both exist at once.
→ One document per operating system
2. A machine is claimed, not looked up. Name a directory after the MAC and it wins.
Or list the machine in a group’s members. Or write a [match] block and let the
machine be claimed by what it is — a Dell R620 with a serial starting 7ABC. The
resolution is deterministic: naming beats matching, more criteria beats fewer, ties break
on sorted name.
→ How an answer is picked
3. Answers compose. A rack of machines shares everything except its MAC addresses.
Put the shared part in a group; a machine that differs gets a document containing only the
difference. Structured formats really merge — maps key by key, arrays replaced so a
list can still be shortened. Add {{ serial }} placeholders and one group document covers
five hundred machines.
→ Groups and merging · Templating
4. What gets served is reviewable before it is served. Merging creates a document
nobody ever wrote, and a bad merge surfaces as a failed unattended install at 3am.
rescriptum render prints exactly what a given machine would receive; rescriptum check
renders everything and reports what breaks, calling the installer’s own validator where
one is on PATH.
→ Validating what will be served
What it is not
- Not a DHCP server, in any form. Not a responder, not a proxy, not behind a flag. Sites that deploy this already run one, and pointing it at a boot server is a solved problem with thirty years of tooling.
- Not a config management system. It hands over a document at install time and then has nothing more to do with the machine — nothing it installs depends on it afterwards.
- Not a schema validator. It proves your documents are well-formed and merge cleanly.
Whether the result is valid Proxmox is
proxmox-auto-install-assistant’s job, andcheckwill call it when it is installed.
Two deployment realities
Both are real, and the design has to satisfy both:
- A Synology DS416j — ARMv7, 512 MB, DSM 7, no Docker. The original motivation, and the reason this is a single static binary with no runtime and no interpreter.
- A datacenter host fielding a provisioning burst, with one answer directory per machine. The reason it is async, bounds its own concurrency, and caches the directory listing instead of walking it per request.
At 2,000 machines a rack served from one group renders 13,000 requests/second with nothing parsed per request — grouping is the fast path, not just the tidy one.
Where to go next
- Install — get the binary running.
- Serve your first answer — end to end in five minutes.
- Preparing installer media — the URL to bake into each ISO.
- Writing answers — selection, formats, groups, templating.
- Running it — deployment, security, storage, troubleshooting.
- Boot media and netbooting — serve the installer itself, not only its answer.
Working on rescriptum rather than with it? The Development space is the other half of this site.
Install
Install
rescriptum is one self-contained binary. There is no runtime to install, no interpreter, no container image, and nothing written outside the directory you point it at. Copy it somewhere and run it.
Download a release
Binaries for every published target are attached to each release, with a SHA-256 sum beside them.
| Target | For |
|---|---|
armv7-unknown-linux-gnueabihf | Synology DS416j and other ARMv7 NAS boxes (glibc ≥ 2.17) |
aarch64-unknown-linux-musl | modern ARM NAS, Raspberry Pi |
x86_64-unknown-linux-musl | most other Linux hosts |
aarch64-apple-darwin | local development, Apple silicon |
x86_64-apple-darwin | local development, Intel Macs |
$ VERSION=0.2.0 TARGET=x86_64-unknown-linux-musl
$ curl -fsSLO https://github.com/z29k/rescriptum/releases/download/v$VERSION/rescriptum-$VERSION-$TARGET.tar.gz
$ curl -fsSLO https://github.com/z29k/rescriptum/releases/download/v$VERSION/rescriptum-$VERSION-$TARGET.tar.gz.sha256
$ shasum -a 256 -c rescriptum-$VERSION-$TARGET.tar.gz.sha256
$ tar xzf rescriptum-$VERSION-$TARGET.tar.gz
$ sudo install -m755 rescriptum-$VERSION-$TARGET/rescriptum /usr/local/bin/
Check the sum. This binary runs as root on hardware you are about to install, which is about as much trust as a program gets.
On a Synology
Take the .spk for your model instead — rescriptum-<version>-armv7.spk for the DS416j
and other armada38x machines, -x86_64.spk for every Intel model — and install it with
Package Center → Manual Install. It creates the shared folder, registers the port with
the firewall, links the CLI onto PATH and starts at boot. Details, and what it
deliberately does not do for you, are on the
Synology page.
The Linux builds are linked against musl, statically, so they do not care how old the host’s glibc is:
$ file /usr/local/bin/rescriptum
ELF 64-bit LSB executable, x86-64, ... statically linked, stripped
Or build it
You need a Rust toolchain and nothing else for a native build:
$ git clone https://github.com/z29k/rescriptum && cd rescriptum
$ ./build.sh
Cross-compiling for the NAS needs cargo-zigbuild
and Zig, which stand in for a full cross toolchain. The
build page has the details, including how to confirm the
result really is static — a dynamically linked musl binary fails at exec time, on the
NAS, rather than at build time on your laptop.
Run it
$ mkdir -p /srv/answers
$ RESCRIPTUM_ANSWERS_DIR=/srv/answers rescriptum
2026-08-22T18:00:00Z - rescriptum 0.1.0 listening on 0.0.0.0:8000 — store=files:/srv/answers workers=8 max_conn=2048 timeout=10s
2026-08-22T18:00:00Z - warning: /srv/answers does not exist yet — every request will 404 until it does
The startup line is worth reading rather than scrolling past:
| Field | Meaning |
|---|---|
listening on | the address actually bound, not the one requested — with :0 they differ |
store= | files:<dir> or sqlite:<path>, so a misconfigured store is visible immediately |
workers= | runtime threads, CPU count by default. Not a concurrency limit |
max_conn= | in-flight connections before the server sheds with 503 |
timeout= | header-read timeout and whole-connection deadline |
Anything wrong with the answer set — a group extending a group that does not exist, a
document that will not parse — is reported here too, once, at startup. It is also
reported by rescriptum check, which is the better place to
find out.
Confirm it is alive:
$ curl http://localhost:8000/health
OK
GET /health is the one endpoint that never needs a token and is never rate-limited, so
a monitor keeps working even while the server is refusing everything else.
Where it looks by default
RESCRIPTUM_ANSWERS_DIR defaults to /srv/answers, and RESCRIPTUM_DB_PATH to
/srv/answers.db. /srv is where the filesystem hierarchy standard puts data served by
the system, which is what these are. Nothing creates the directory for you; the startup
line says so if it is missing.
Everything is configured through the environment; there is no configuration format to
learn and no command line to get wrong. If you have nowhere good to put a token — DSM 7,
say — RESCRIPTUM_ENV_FILE names a file of the same variables. The full list is in the
configuration reference.
Next
- Serve your first answer — a real machine getting a real document.
- Deployment — systemd, or the DSM task scheduler.
Serve your first answer
Serve your first answer
Five minutes, one terminal, no installer needed. Everything here is testable offline:
rescriptum render resolves an answer exactly as the server would, so you can get the
answer right before any machine boots.
1. A directory and one document
$ mkdir -p answers/groups/rack-a
One directory per identity. A directory at the top level is one machine, named after
it; groups/ holds the shared ones. Inside either, the extension names the format and the
rest of the filename is just a label. Start with a group, since that is the shape almost
every real deployment ends up with — a rack of machines that agree about everything except
which disks they have:
# answers/groups/rack-a/proxmox.toml
members = ["98:fa:9b:50:d8:10", "98:fa:9b:50:d8:11"]
[global]
keyboard = "fr"
country = "fr"
timezone = "Europe/Paris"
root-password-hashed = "$6$rounds=656000$REPLACE$ME"
[network]
source = "from-dhcp"
[disk-setup]
filesystem = "zfs"
zfs.raid = "raid1"
disk-list = ["sda", "sdb"]
members lists the machines this group answers for. Separator style does not matter —
98:fa:9b:50:d8:10, 98-FA-9B-50-D8-10 and 98fa9b50d810 are one MAC, on both sides of
the comparison. members is rescriptum’s key, not Proxmox’s, and is stripped from what
the installer receives.
2. See what a machine would get
$ RESCRIPTUM_ANSWERS_DIR=answers rescriptum render 98:fa:9b:50:d8:11
# format=toml group=rack-a
[global]
keyboard = "fr"
country = "fr"
timezone = "Europe/Paris"
root-password-hashed = "$6$rounds=656000$REPLACE$ME"
[network]
source = "from-dhcp"
[disk-setup]
filesystem = "zfs"
zfs.raid = "raid1"
disk-list = ["sda", "sdb"]
The first line goes to stderr and says how the answer was reached — the format family,
which machine document matched, which group applied. The document itself goes to stdout,
so render … > answer.toml gives you just the document.
3. One machine that differs
The second node in the rack has four disks. It gets a directory named after its MAC, holding a document with only the difference in it:
# answers/98-fa-9b-50-d8-10/proxmox.toml
[global]
fqdn = "node01.example.com"
[disk-setup]
zfs.raid = "raid10"
disk-list = ["sda", "sdb", "sdc", "sdd"]
$ RESCRIPTUM_ANSWERS_DIR=answers rescriptum render 98:fa:9b:50:d8:10
# format=toml machine=98-fa-9b-50-d8-10 group=rack-a
[global]
keyboard = "fr"
country = "fr"
timezone = "Europe/Paris"
root-password-hashed = "$6$rounds=656000$REPLACE$ME"
fqdn = "node01.example.com"
[network]
source = "from-dhcp"
[disk-setup]
filesystem = "zfs"
zfs.raid = "raid10"
disk-list = ["sda", "sdb", "sdc", "sdd"]
The group came first, the machine’s own document on top, and the machine won wherever the two
disagreed. Tables merged key by key; disk-list was replaced, not appended — a list
that could only grow could never be shortened from a higher layer.
4. Check the whole set
$ RESCRIPTUM_ANSWERS_DIR=answers rescriptum check
checking files:answers
1 group(s), 1 machine document(s)
note: toml answers not schema-checked — proxmox-auto-install-assistant is not on PATH
ok — everything renders
check renders every machine and every group member and reports whatever breaks: a
document that will not parse, a group extending one that does not exist, a placeholder
nothing can fill. Where the installer’s own validator is on PATH it runs that too, and
says which formats it could not check.
This is the command to put in CI if your answers live in git.
5. Actually serve them
$ RESCRIPTUM_ANSWERS_DIR=answers rescriptum
2026-08-24T08:43:36Z - rescriptum 0.1.0 listening on 0.0.0.0:8000 — store=files:answers workers=10 max_conn=2048 timeout=10s
In another terminal, imitate what the Proxmox installer sends:
$ curl -s -X POST http://localhost:8000/answer \
-d '{"network_interfaces":[{"mac":"98:fa:9b:50:d8:10","link":"up"}]}'
and watch the server say what it did:
2026-08-24T08:43:37Z 127.0.0.1:61721 POST /answer body=102 200 format=toml machine=98fa9b50d810 group=rack-a bytes=431
That line is the whole diagnostic story when a rollout misbehaves: who asked, how big their body was, what they got, and what it was built from.
New documents are picked up as you add them — no restart, no reload signal. A machine’s whole directory appearing or leaving is noticed at once; a document added or edited inside one is picked up within a second.
What to read next
- How an answer is picked — you have seen naming and membership; selectors claim a machine by what it is.
- One document per operating system — the same machine as Proxmox, as Debian, as Ubuntu, side by side.
- Templating —
fqdn = "node-{{ serial }}.example.com", so one group covers a rack without a directory per machine. - Preparing installer media — the URL to bake into the ISO, per OS.
Preparing installer media
Preparing installer media
Every installer is told, at build time, where to fetch its configuration. That URL does two jobs here:
- It reaches the server. Any path works —
POSTandGETare answered on all of them, precisely so the URL baked into an ISO is never wrong. - Its path declares the format. A segment naming a known alias restricts the answer to documents of that format, so a kickstart client cannot be handed TOML.
Give each installer its own URL, and one server can answer for all of them.
Proxmox VE
$ proxmox-auto-install-assistant prepare-iso proxmox-ve.iso \
--fetch-from http \
--url http://SERVER:8000/proxmox/answer \
--output proxmox-auto.iso
The installer POSTs a JSON inventory of the hardware it found and expects the answer in
the response body. /proxmox/ restricts the reply to .toml documents; /answer on its
own names no alias and constrains nothing, which is why an existing deployment keeps
working unchanged.
To require a credential, prepare the ISO with a token and give the server the same one:
$ proxmox-auto-install-assistant prepare-iso proxmox-ve.iso \
--fetch-from http --url http://SERVER:8000/proxmox/answer \
--answer-auth-token 'a-long-random-string' --output proxmox-auto.iso
$ export RESCRIPTUM_ANSWER_TOKEN='a-long-random-string'
See Security for what that protects and what it does not.
Without rebuilding the ISO
Proxmox can also discover the URL at boot, which saves rebuilding media when the address changes:
- a DNS TXT record on
proxmox-auto-installer.<your-domain>, or - DHCP option 250.
Both are outside this server’s scope — it only has to be at the address they name.
Everything else, over iPXE
Other installers fetch their configuration, and identify themselves in the query string because iPXE substitutes its own variables into the URL before fetching:
| Variable | Is |
|---|---|
${net0/mac} | the first NIC’s MAC address |
${uuid} | the SMBIOS system UUID |
${serial} | the system serial number |
${manufacturer}, ${product} | DMI vendor and model |
Those become facts a document can be selected on, and their values also feed the substring haystack — so a document named after a MAC resolves whether the MAC arrived in a POST body or a query string.
| Installer | Boot parameter |
|---|---|
| RHEL / CentOS / Fedora / Alma / Rocky | inst.ks=http://SERVER:8000/rhel/ks?mac=${net0/mac} |
| Debian preseed | url=http://SERVER:8000/debian/preseed?mac=${net0/mac} |
| Ubuntu autoinstall | autoinstall ds=nocloud-net;s=http://SERVER:8000/ubuntu/?mac=${net0/mac} |
| Flatcar / Fedora CoreOS | ignition.config.url=http://SERVER:8000/flatcar/config?mac=${net0/mac} |
| openSUSE / SLES | autoyast=http://SERVER:8000/suse/profile?mac=${net0/mac} |
| Windows | fetched by your own tooling from http://SERVER:8000/windows/unattend |
A full iPXE script fragment:
#!ipxe
set base http://SERVER:8000
kernel ${base}/images/rhel9/vmlinuz inst.ks=${base}/rhel/ks?mac=${net0/mac}&serial=${serial}
initrd ${base}/images/rhel9/initrd.img
boot
rescriptum serves the answer, not the kernel — netbooting stays with whatever TFTP/HTTP server you already run.
Ubuntu and cloud-init NoCloud
cloud-init’s NoCloud datasource fetches two named files from the seed URL —
user-data and meta-data — and skips the datasource entirely if either is missing.
Since this server answers on any path, both requests would otherwise receive the same
document and the install would never start.
The path’s last segment is available as the file fact, so the two are told apart with a
selector:
# answers/groups/ubuntu-web/ubuntu.yaml
match:
file: "user-data"
product: "PowerEdge R6*"
# answers/groups/ubuntu-meta/ubuntu.yaml
match:
file: "meta-data"
instance-id: iid-local01
Note the trailing slash in s=http://SERVER:8000/ubuntu/ — cloud-init appends the file
name to it.
NoCloud can also expand __dmi.chassis-serial-number__ into the seed URL, which puts the
machine’s identity in the path rather than the query. Path segments feed the haystack
too, so a document named after that serial still resolves.
Choosing the alias
| URL segment | Serves documents with extension |
|---|---|
proxmox, pve, toml | .toml |
debian, preseed | .preseed, .seed |
rhel, centos, fedora, alma, rocky, kickstart, ks | .ks |
ubuntu, autoinstall, cloudinit, nocloud, yaml, yml | .yaml, .yml |
flatcar, coreos, ignition, ign | .ign, .json |
suse, opensuse, autoyast | .autoyast, .xml |
windows, unattend | .unattend, .xml |
json | .json, .ign |
xml, cfg, ipxe | the matching extension |
Any segment of the path may name the alias, so /rhel/ks, /ks, and
/provision/rhel/node.cfg all restrict to kickstart. A URL naming none of them —
/answer — constrains nothing.
The full table, and why seed is deliberately not an alias, is in the
format reference.
Next
- How an answer is picked — what the server does with what the URL just told it.
- One document per operating system — the same machine, as Proxmox and as Debian, at the same time.
Writing answers
Writing answers
An answer is the document an installer receives: a Proxmox answer.toml, an Ubuntu
autoinstall user-data, a kickstart, a preseed, an Ignition config, an AutoYaST profile,
a Windows unattend.xml. rescriptum’s whole job is to pick the right one for the machine
asking and hand it back, assembled from however many layers you wrote.
The layout
One directory per identity. A machine is a directory named after it, holding one document per operating system:
answers/
├── 98fa9b50d810/ one machine
│ ├── proxmox.toml as Proxmox
│ └── debian.preseed …and the same hardware as Debian
├── aabbccddeeff/ another machine
│ └── ubuntu.yaml as Ubuntu
├── default/ when nothing else matches
│ └── proxmox.toml
└── groups/
├── rack-a/ shared by a rack, claims its members
│ ├── proxmox.toml
│ └── debian.preseed
└── rhel-compute/ claims machines by what they are
└── rhel.ks
- A machine is a directory named after it — a MAC address, in any separator style — holding that machine’s own configuration, or only the part of it that differs from its group.
- A group is a directory under
groups/and is shared. It claims machines by listing them inmembers, or by amatchblock tested against the request. default/answers when nothing else does. One document per format: a TOML default must not answer a client that asked for kickstart.
The extension decides; the name does not
Inside a directory, the extension is the format and the part before it means nothing.
proxmox.toml and answer.toml are the same document to the server; the name is there
for whoever opens the folder. rescriptum writes readable ones — proxmox.toml,
ubuntu.yaml, debian.preseed, boot.ipxe — and never renames yours.
The one rule that follows: a directory holds at most one document per format. Two
.toml in one directory is reported as a problem rather than resolved, because nothing
could pick between them that you would have predicted. Two different formats are not a
duplicate at all — that is the whole point of the directory.
Storage layout and URL are still deliberately kept apart: a folder can be reorganised, a URL baked into an ISO cannot. See formats.
:::note[Upgrading from a flat directory]
Answers used to be files at the top of the directory: 98fa9b50d810.toml beside
98fa9b50d810.preseed. Those are no longer served, and each one is reported by name
with its new path. rescriptum migrate shows what it would move; rescriptum migrate --apply moves them.
:::
The five things to know
| How an answer is picked | By name, by member list, or by what the machine is. Naming always wins; among selectors, more criteria wins; ties break on sorted name |
| One document per operating system | The extension is the format, the endpoint chooses between them, and a machine can exist as several operating systems at once |
| Groups and merging | Layers apply lowest to highest and the machine always wins. Maps merge; arrays replace |
| Templating | {{ serial }} filled from the request, so one group file covers a rack |
| Validating | render shows what a machine would get; check renders everything and reports what breaks |
Control keys
Four keys steer resolution and are stripped before the answer is sent, so the installer never sees them:
| Key | Does |
|---|---|
members | the machines this group answers for |
match | criteria tested against the request’s facts |
extends | the group this document layers on top of |
They travel in whatever the format allows — top-level keys in TOML, YAML and JSON, an
<answer-meta> element in XML, # answer: directives in kickstart and preseed. The
per-format spelling is in formats.
Worked examples
The repository’s examples/
directory carries a commented example of every supported format, all selected
differently — by hardware, by member list, by directory name — and they are exercised by
the test suite:
$ RESCRIPTUM_ANSWERS_DIR=examples rescriptum check
$ RESCRIPTUM_ANSWERS_DIR=examples rescriptum render --query "path=/rhel/ks&serial=7ABC123"
It is the only place the formats are shown composing together; start there when you are unsure what a real file looks like.
How an answer is picked
How an answer is picked
A document does not get looked up; it claims the request. Three ways to do that, ordered by how narrowly they target one machine.
1. By name
Name a directory after the machine’s MAC address, and put its documents in it:
answers/
├── 98-fa-9b-50-d8-10/
│ └── proxmox.toml
├── aabbccddeeff/
│ └── proxmox.toml
└── default/
└── proxmox.toml
When a request arrives, the server lowercases everything it carries and drops every
non-alphanumeric character, does the same to each directory’s name, and serves the first
whose name appears inside the request. So 98-fa-9b-50-d8-10, 98:fa:9b:50:d8:10 and
98fa9b50d810 all name the same machine — you never have to care which separator style
Proxmox happens to use this version, or how it structures its JSON.
The identity is the directory name; the filenames inside it choose nothing, they only carry the format in their extension.
That normalization is the whole trick, and it is why this survives Proxmox changing its body format between releases: it is a substring test over the bytes, not a schema.
Nothing prevents naming a directory after a serial number, an asset tag or a hostname instead. Any string that appears in what the machine sends will do.
2. By member list
A group claims a set of machines by listing them:
# answers/groups/rack-a/proxmox.toml
members = ["98:fa:9b:50:d8:10", "98:fa:9b:50:d8:11", "98:fa:9b:50:d8:12"]
Member strings are normalized exactly like directory names, so separator style does not matter here either. A listed machine needs no directory of its own unless it has something to override — see grouping.
3. By what the machine is
A match block claims a machine by its properties rather than its identity:
# answers/groups/dell-r620/proxmox.toml
[match]
manufacturer = "Dell Inc."
product = "PowerEdge R620"
serial = "7ABC*" # * and ? work
Every criterion must hold for the group to claim the request. * matches any run of
characters and ? exactly one; both sides are normalized before comparing, so case and
separator style never matter.
A machine document may carry a match block too — useful for “whatever machine is
currently in this chassis slot”.
The facts a selector can test
Facts come from three places, deliberately layered from most to least structured.
Query parameters
?mac=…&uuid=…&serial=… — how every installer other than Proxmox identifies itself,
because iPXE substitutes the values into the URL before fetching. Reliable, arbitrary
keys, no guessing.
Three more are synthesized from the URL itself:
| Fact | Is |
|---|---|
path | the whole path, trimmed of slashes — rhel/ks |
file | its last segment — ks. This is what tells cloud-init’s user-data from its meta-data |
segment | every segment, as separate values — rhel and ks |
A POSTed JSON body
When the body really is JSON, it is flattened to both its full dotted paths and its bare leaf names:
{ "dmi": { "system": { "serial": "7ABC123" } } }
gives both dmi.system.serial and plain serial. The leaf form is the point.
Proxmox’s own documentation warns that the contents of dmi “might vary wildly,
depending on the system”, so a selector saying “a field called serial, wherever it
lives” survives a reorganisation that a fixed path would not.
Array indices become part of the path but not of the leaf name, so
network_interfaces.0.mac is also reachable as plain mac.
A body that is not JSON is not an error — it simply contributes nothing but the haystack.
The raw body
Normalized to lowercase alphanumerics: the substring haystack that makes matching by name
work. Query values and path segments are appended to it too, so a directory named after a
MAC resolves whether that MAC arrived in a POST body or a query string. Without that, a
GET — which has no body at all — could never match by name.
When several documents claim the same request
The rule is fixed, and a test pins it:
- Naming a machine always wins. However many criteria a selector carries, an identity match beats it — naming a machine is as specific as anyone can be.
- Among selectors, more criteria wins. Three matching criteria beat two; a more deliberate rule is a more specific one.
- Ties break on sorted name. Alphabetically first.
The answer never depends on filesystem order or on the order rows came out of a database. matchbox, the closest prior art, documents that its own resolution between competing groups “will not be deterministic”. This one is.
Only the first matching group applies. Composition is expressed with
extends, not by merging every group that happens to match —
the order between several matching groups would be arbitrary, and an arbitrary order is
how a machine quietly gets the wrong disk layout.
And if nothing matches
default.<ext> is served, if there is one for the format the endpoint asked for.
Otherwise the answer is 404 — logged as no answer file applies.
Try it before booting anything
render resolves exactly as the server would, from facts you supply:
$ rescriptum render 98:fa:9b:50:d8:10 # by identity
$ rescriptum render --query "serial=7ABC123&mac=98:fa:9b:50:d8:10" # by label
$ rescriptum render --query "path=/rhel/ks&serial=7ABC123" # including the endpoint
$ rescriptum render --body captured-request.json # a real captured body
A bare identifier claims nothing about what kind of identifier it is — it fills the
haystack and nothing else. That is enough for name matching, but a selector on serial
needs --query "serial=…" to have anything to test. check works the same way, which is
why a template needing a request-only fact is reported as a
problem.
To capture what your machines really send, see Capturing requests.
One document per operating system
One document per operating system
An installer fetching a URL expects one particular thing back. A kickstart client wants kickstart and would choke on TOML. That is the protocol, not a convention anyone chose. So:
- the endpoint declares the format —
/rhel/ksasks for kickstart; - the document carries it as its extension —
groups/rhel-compute/rhel.ks; - only documents of that format may answer.
The consequence that makes it click
A machine’s answer is specific to the operating system it is for. So this is not one machine and two files:
answers/
└── 98fa9b50d810/
├── proxmox.toml "that machine, as Proxmox"
└── debian.preseed "that machine, as Debian"
It is one piece of hardware with two answers, and both can exist at once. Which one a
request receives depends on the URL it arrived on — /proxmox/answer gets the TOML,
/debian/preseed gets the preseed. Neither is more “the” answer than the other.
Internally this is why a document is keyed by (identifier, format) rather than by identifier alone — and why one directory holds one document per format and no more.
Storage is not the URL
Documents are grouped by identity, never by format, and that is deliberate. Directories and database rows are a lookup space — they must stay free to be reorganised. A URL is a public contract baked into an ISO — it must not move because someone renamed a folder. An earlier design made the directory name be the URL segment and was discarded for exactly that reason.
The same rule is why the filename inside a machine’s directory carries no meaning:
proxmox.toml reads well and matches the /proxmox/ endpoint, but only the .toml is
load-bearing. Rename it answer.toml and nothing changes.
Which alias serves which extension is in the format reference; how to pick one for your media is in preparing installer media.
The formats
| Extension | For | Layering |
|---|---|---|
toml | Proxmox VE | structural merge |
yaml, yml | Ubuntu autoinstall, cloud-init | structural merge |
json, ign | Ignition, Flatcar, Fedora CoreOS | structural merge |
xml, autoyast, unattend | AutoYaST, Windows unattend.xml | structural merge, by element |
ks | kickstart — RHEL, CentOS, Fedora, Alma, Rocky | concatenation |
preseed, seed | Debian preseed | concatenation |
cfg, ipxe | boot scripts and other line-oriented config | concatenation |
The allowlist is deliberate: txt is not on it, so a stray notes file next to your
answers never becomes a candidate.
.autoyast and .unattend are XML under a name that says which one it is, so a store
holding both a SUSE profile and a Windows unattend can keep them apart. Plain .xml
still answers either, which is fine right up until you have both.
Structural merge
For toml, yaml, json and xml, layering is a real merge:
- Maps merge key by key, recursively — including TOML’s inline and dotted tables.
- Any other value is replaced outright by the higher layer.
- Arrays replace, they do not append. Appending would make a list impossible to shorten from a higher layer, and “this node has two disks, not four” has to be expressible.
The details, with examples, are in grouping.
Concatenation
For ks, preseed, cfg, seed and ipxe, layering is concatenation in layer
order, and the module says so rather than pretending otherwise. A directive in a later
layer follows an earlier one rather than removing it.
Whether that amounts to an override is the target format’s business: preseed’s last answer wins, kickstart’s does not always. Render the result and read it before trusting a rack to it.
One thing worth knowing before you write an essay at the top of a kickstart: ordinary
comments are served. Only # answer: directive lines are stripped. That is fine —
kickstart and preseed both allow comments — but the installer will see everything else.
XML
XML pairs siblings by element name plus a discriminating attribute — name, id,
key, alias or pass. That is what makes
<settings pass="specialize">
<component name="Microsoft-Windows-Shell-Setup" …>
mergeable: overriding one pass leaves the others alone, and overriding one component
does not replace every other component in the file. Repeated siblings without a
discriminating attribute are treated as a list, and AutoYaST’s config:type="list" is
honoured.
What survives a merge: the <?xml?> declaration, the <!DOCTYPE>, namespaces, and
attributes. What does not: the original indentation and comment placement — the
output is re-rendered, not patched.
It understands no schema. Render and check before trusting a rack to it.
Where the control keys live
The control keys travel in whatever each format allows, and are stripped before the answer is sent.
TOML
extends = "base"
members = ["98:fa:9b:50:d8:10"]
[match]
product = "PowerEdge R6*"
YAML / JSON — the same three as top-level keys:
extends: base
members: ["98:fa:9b:50:d8:10"]
match:
file: "user-data"
product: "PowerEdge R6*"
XML — an <answer-meta> element, with extends as an attribute on it:
<answer-meta extends="base">
<member>52:54:00:11:22:33</member>
<match manufacturer="Dell Inc." product="PowerEdge R6*" />
</answer-meta>
Kickstart, preseed, and anything line-oriented — # answer: directives (// works
too, for formats that comment that way):
# answer: extends base
# answer: member 00:11:22:33:44:55, 00:11:22:33:44:56
# answer: match serial=7ABC* product=PowerEdge*
match takes space-separated key=pattern pairs, member a comma-separated list.
One answer, one format
Every layer of one answer must be the same format. A YAML machine document over a
TOML group is refused, not half-served, and extends resolves within one format for the
same reason — layering a preseed onto a TOML base is meaningless.
Grouping is otherwise untouched by any of this: a rack shares one group per format, and a machine that exists as two operating systems joins two of them.
default follows the same rule — a .toml in default/ answers a request that asked for
TOML, and never one that asked for kickstart.
Groups and merging
Groups and merging
A rack of machines usually shares everything except its MAC addresses. Writing that out once per machine is how a fleet’s configuration drifts. So answers compose.
answers/
├── groups/
│ ├── base/
│ │ └── proxmox.toml shared by everything
│ └── rack-a/
│ └── proxmox.toml extends = "base"; members = [ … ]
├── 98-fa-9b-50-d8-10/
│ └── proxmox.toml one machine's overrides (optional)
└── default/
└── proxmox.toml only when nothing else matches
The shared part
# answers/groups/rack-a/proxmox.toml
members = [
"98:fa:9b:50:d8:10",
"98:fa:9b:50:d8:11",
"98:fa:9b:50:d8:12",
]
[global]
keyboard = "fr"
country = "fr"
timezone = "Europe/Paris"
[disk-setup]
filesystem = "zfs"
zfs.raid = "raid1"
disk-list = ["sda", "sdb"]
The difference
A machine that differs gets a document with only the difference in it:
# answers/98-fa-9b-50-d8-10/proxmox.toml
[global]
fqdn = "node01.example.com"
[disk-setup]
zfs.raid = "raid10" # this one has four disks
disk-list = ["sda", "sdb", "sdc", "sdd"]
…and receives the two merged, with its own values winning:
$ rescriptum render 98:fa:9b:50:d8:10
# format=toml machine=98-fa-9b-50-d8-10 group=rack-a
[global]
keyboard = "fr"
country = "fr"
timezone = "Europe/Paris"
fqdn = "node01.example.com"
[disk-setup]
filesystem = "zfs"
zfs.raid = "raid10"
disk-list = ["sda", "sdb", "sdc", "sdd"]
Merge rules
| Layers | group chain first, machine document last — the machine always wins |
| Maps | merge recursively, including TOML’s inline and dotted tables |
| Other values | replaced outright by the higher layer |
| Arrays | replace, they do not append |
| Text formats | concatenated in layer order — see formats |
Why arrays replace. Appending is the intuitive choice right until you need to shorten
a list. disk-list = ["sda", "sdb"] in a group and ["sda"] in a machine document has
exactly one sensible meaning — this one has a single disk — and appending cannot express
it. The same rule holds in every format, so you never have to remember which one you are
in.
extends
A group may extend another group, giving a chain — what every rack shares in one file, per-rack differences in another:
# answers/groups/base/proxmox.toml
[global]
mailto = "ops@example.com"
timezone = "Europe/Paris"
root-ssh-keys = ["ssh-ed25519 AAAA…REPLACE ops@example.com"]
# answers/groups/rack-a/proxmox.toml
extends = "base"
members = ["98:fa:9b:50:d8:10", "98:fa:9b:50:d8:11"]
[disk-setup]
filesystem = "zfs"
Layers then apply base → rack-a → machine document.
extends in a machine document overrides membership. It is the escape hatch for a
machine that needs a group it is not listed in:
# answers/98-fa-9b-50-d8-99/proxmox.toml
extends = "rack-a" # even though rack-a does not list this MAC
[global]
fqdn = "spare01.example.com"
extends resolves within one format — layering a preseed onto a TOML base is
meaningless, and the merge would refuse it anyway.
Only the first matching group applies
If two groups both claim a machine, one of them applies —
the most specific, ties
broken on sorted name. Compose with extends rather than relying on several groups
matching at once: the order between them would be arbitrary, and an arbitrary order is
how a machine quietly gets the wrong disk layout.
When a group is broken
Cycles and missing parents are detected when the store is read, reported in the log once, and the broken group is dropped rather than half-applied:
2026-08-24T08:43:36Z - warning: group "rack-a": extends unknown group "base"
One bad group does not stop the other racks from installing. A machine that
needed that group gets a loud 500 rather than a half-built answer — serving a
configuration whose base is missing would install the machine half-configured, and nobody
would find out until it was running.
rescriptum check reports the same problems, which is a better place to find out than
the log at 3am.
Grouping is the fast path
Measured at 2,000 machines, 3,000 requests at 100 concurrent:
| Layout | Throughput |
|---|---|
| 2,000 machine documents, no group | 12,132 req/s |
| one group of 2,000 members, no machine documents | 13,036 req/s |
| 2,000 machine documents plus a group (a merge per request) | 8,816 req/s |
A group with no machine overrides and no placeholders is rendered once, when the store is read, and served afterwards as a prepared string. The common datacenter case parses nothing per request. Adding a per-machine override buys a merge per request — worth it where it is needed, and worth avoiding where it is not.
The other half of the same argument is what a read costs. The whole store is re-read at
most once a second, and with a directory per identity that read is a readdir per machine
on top of the file it already opened — measured at 2,000 machines on an M1 Pro, 28 ms
before the layout changed and 63 ms after. It is amortised over a second’s worth of
requests either way, and the throughput figures above did not move measurably; but a group
that needs no per-machine directory avoids that cost too.
Next
- Templating —
{{ serial }}removes the remaining reason for a directory per machine. - Validating — a merged answer is a document nobody wrote; look at it before a rack does.
Templating
Templating
Grouping removes the duplication between machines that agree. Templating removes the last reason to write a document per machine at all: the values that must differ.
# answers/groups/rack-a/proxmox.toml
members = ["98:fa:9b:50:d8:10", "98:fa:9b:50:d8:11", "…"]
[global]
fqdn = "node-{{ serial }}.example.com"
[network]
filter.ID_NET_NAME_MAC = "*{{ mac }}"
Five hundred machines, one document. Without this, a per-machine hostname means a document per machine — and five hundred documents that differ in one line each.
Placeholders work in every format: TOML, YAML, JSON, XML, kickstart, preseed.
In the structured formats, substitution happens on parsed string values — so a comment is just a comment. In the line-oriented ones (
ks,preseed,cfg,ipxe,seed) the document is an opaque string, so a placeholder written inside a comment is still a placeholder and still has to resolve. Mentioning{{ serial }}in a#line to explain it to the next reader will fail the render just as a real one would.
What you can put in one
| Placeholder | Filled from |
|---|---|
{{ mac }}, {{ serial }}, {{ uuid }}, … | any fact the request carries — query parameters, and the fields of a POSTed JSON body by leaf name or full path |
{{ dmi.system.serial }} | the same body, by its exact path |
{{ path }}, {{ file }}, {{ segment }} | the URL the request arrived on |
{{ group }} | the name of the group that applied |
{{ machine }} | the identifier of the machine document that matched |
Whitespace inside the braces is optional: {{serial}} and {{ serial }} are the same.
machine needs a machine document
{{ machine }} is the identifier of the machine document that matched — so it is only
available when the machine has a document of its own. A machine claimed by a group’s
members list, with no directory of its own, has no machine value and rendering fails with
template needs {{ machine }}, but this request carries no "machine".
In a group, use a request fact instead:
[global]
fqdn = "node-{{ mac }}.example.com" # works for every member
{{ machine }} is for a machine document that wants to name itself without repeating its
own MAC.
A missing value is an error
A placeholder the request cannot fill is a 500 with the reason, never an empty string:
$ rescriptum render 98:fa:9b:50:d8:10
error: template needs {{ serial }}, but this request carries no "serial"
This is deliberate. Serving node-.example.com installs a machine with a broken hostname
and nobody notices until later — possibly much later, on a machine that is already in
production. Failing the install is the cheaper outcome.
Control characters are refused outright for the same class of reason: a newline in a kickstart value would inject a directive into the file the installer executes.
$ rescriptum render --query "mac=aa:bb&serial=$(printf 'a\nb')"
error: value for "serial" contains a control character and will not be substituted
Substitution is escape-safe
Substitution happens on parsed values, never on raw document text. The value is put into the document’s own data model and the format’s serializer writes it out — so the serializer does the escaping.
A serial containing a quote cannot break the TOML it lands in:
$ rescriptum render --query 'mac=aa:bb&serial=a"b'"'"'c<d>e'
[global]
fqdn = """node-a"b'c<d>e.example.com"""
The TOML writer reached for a multi-line string on its own. The same value in an XML
document comes back entity-escaped, and in JSON, JSON-escaped. A test feeds
a"b'c<d>e&f through all four structured formats and reparses the output.
This is why templating is safe to feed from a request that a machine you have never met controls.
check and request-only facts
rescriptum check renders every machine from its identity alone — it has no request
to draw on, because there is no request. A template that needs serial, which only ever
arrives in a body or a query string, is therefore reported as a problem:
$ rescriptum check
FAIL group "rack-a" member "98fa9b50d811": template needs {{ serial }}, but this request carries no "serial"
That is honest — check genuinely cannot prove that answer renders — but it is noisy for
a set that deliberately templates on request facts. Verify those with render and
representative facts instead:
$ rescriptum render --query "mac=98:fa:9b:50:d8:11&serial=7ABC123"
The cost
None, when you are not using it. A group whose prepared string carries no {{ is served
as-is, without being parsed per request — the check for placeholders happens once, when
the store is read. Templating moves a group onto the merge-per-request path only for the
documents that actually contain one.
Next
- Validating — render with real facts, and check the whole set.
- Capturing requests — get a real body to render against.
Validating what will be served
Validating what will be served
Before answers composed, an admin wrote a complete document and validated it:
$ proxmox-auto-install-assistant validate-answer answer.toml
Once an answer is assembled from a group chain plus a machine document plus a template fill, the document the installer receives is one nobody has ever seen — and a bad merge surfaces as a failed unattended install at 3am. Two subcommands exist to close that gap, and any change to merging has to keep them working.
render — what this machine would get
$ rescriptum render 98:fa:9b:50:d8:10 # by identity
$ rescriptum render --query "serial=7ABC123&mac=98:fa:9b:50:d8:10" # by label
$ rescriptum render --query "path=/rhel/ks&serial=7ABC123" # including the endpoint
$ rescriptum render --body captured-request.json # a real captured body
It resolves exactly as the server does — same matching, same layering, same template fill — and prints the result. The document goes to stdout; the line explaining how it was reached goes to stderr:
$ rescriptum render 98:fa:9b:50:d8:10
# format=toml machine=98-fa-9b-50-d8-10 group=rack-a
[global]
…
so redirecting gives you just the document:
$ rescriptum render 98:fa:9b:50:d8:10 > /tmp/answer.toml
Add path=… to --query when you want to check what a particular endpoint would
answer — without it, resolution is unconstrained by format and may pick a document the
real URL would have excluded.
Exit status is 0 when something resolved, non-zero when nothing applied (the server would have returned 404) or when rendering failed.
check — render everything, report what breaks
$ rescriptum check
checking files:examples
10 group(s), 8 machine document(s)
group "rhel-compute" selects on serial=7ABC*
(verify with: rescriptum render --query "...")
group "ubuntu-web" selects on file=user-data product=PowerEdge R6*
(verify with: rescriptum render --query "...")
1 answer(s) validated by their installer's own tool
note: no schema validator exists for preseed answers
note: toml answers not schema-checked — proxmox-auto-install-assistant is not on PATH
ok — everything renders
Well-formed and merging cleanly is not the same as valid for an
installer. Where a validator exists and is installed it was used above;
install proxmox-auto-install-assistant, xmllint or ksvalidator for the rest.
What it does:
- Reports load-time problems — a group extending one that does not exist, a cycle between groups, a document that will not parse.
- Renders every machine document, and every member of every group. That is what actually exercises the merge.
- Names the groups that select on a
matchblock and says it could not try them, rather than implying they were verified — a selector needs a real request. - Flags a group with neither
membersnormatchas reachable only viaextends, in case that was not the intention. - Calls the installer’s own validator where one exists and is on PATH, and says which formats it could not check.
Exit status is 0 when everything renders, 1 when anything failed — so it drops straight into CI.
The validators it knows
| Format | Tool |
|---|---|
toml | proxmox-auto-install-assistant validate-answer |
xml, autoyast, unattend | xmllint --noout |
ks | ksvalidator |
yaml, json, ign, preseed, cfg, ipxe | none exists — render and read it |
A missing tool is reported once as a note, never treated as a failure. A checker that refuses to run without optional tooling is a checker nobody runs.
check is not a schema checker itself: it proves your documents are well-formed and
merge cleanly. For anything it cannot call a validator for, pipe a rendered answer in
yourself:
$ rescriptum render 98:fa:9b:50:d8:10 > /tmp/answer.toml
$ proxmox-auto-install-assistant validate-answer /tmp/answer.toml
What check cannot prove
check renders each machine from its identity alone. It has no request, so it cannot
supply facts that only arrive with one — a serial from a POSTed body, a mac from a
query string. A template needing those is reported as a problem:
FAIL group "rack-a" member "98fa9b50d811": template needs {{ serial }}, but this request carries no "serial"
That is accurate — check genuinely cannot prove that answer renders — but it means a
set that deliberately templates on request facts will not come back clean. Verify those
with render --query and representative facts. See
templating.
In CI
If your answers live in git, this is worth a job of its own:
# .github/workflows/answers.yml
name: answers
on: [push, pull_request]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Get rescriptum
run: |
curl -fsSL https://github.com/z29k/rescriptum/releases/latest/download/rescriptum-x86_64-unknown-linux-musl.tar.gz \
| tar xz --strip-components=1
- run: RESCRIPTUM_ANSWERS_DIR=answers ./rescriptum check
Add proxmox-auto-install-assistant to the runner and the same job schema-checks the
TOML too.
Before deploying
deploy.sh runs check
before it ships anything, and refuses to deploy if the answers do not come back clean.
Serving a broken answer set is worse than not deploying.
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 process | no supervisor tree, no workers to size, no sidecar |
| One port by default | plus a second, only if you enable the admin API |
| No writes | outside the answers directory or database, and none at all unless you enable the admin API or request capture |
| No state | between requests. Restarting loses nothing |
| Graceful shutdown | on 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=/srvand dropReadOnlyPaths. - Request capture —
ReadWritePaths=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.
| Setting | Default | Change it when |
|---|---|---|
RESCRIPTUM_WORKERS | CPU count | you are sharing a small box and want to cap runtime threads |
RESCRIPTUM_MAX_CONNECTIONS | 2048 | you are seeing 503s during a burst — or want to shed earlier |
RESCRIPTUM_TIMEOUT_SECS | 10 | clients 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:
- Builds for the target (
TARGET, defaultarmv7-unknown-linux-gnueabihf). - Checks the local answers with
rescriptum checkand refuses to continue if anything fails — shipping a broken answer set is worse than not deploying. - 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.
- Stops the running instance, starts the new one detached, and confirms it stayed up.
- Confirms
/healthanswers over the network, so a firewall problem is reported as one rather than as a mysterious silence.
| Environment | Default |
|---|---|
TARGET | armv7-unknown-linux-gnueabihf |
ANSWERS | <remote-dir>/answers |
PORT | 8000 |
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:
| File | For |
|---|---|
rescriptum-<version>-armv7.spk | DS416j and other Marvell armada38x models |
rescriptum-<version>-x86_64.spk | every 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
rescriptumshared 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
answersdirectory inside it at every start; - registers the port with the DSM firewall, so the service is selectable by name;
- links
rescriptum-cliinto/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
.spkfrom 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
rescriptumshare, give therescriptumuser 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
| What | Where | Survives an upgrade | Survives uninstall |
|---|---|---|---|
binary, rescriptum-cli, the example env file | /var/packages/rescriptum/target/ | no — replaced | no |
| the env file | /var/packages/rescriptum/etc/rescriptum.env | yes | yes — see below |
| log, pidfile, captures | /var/packages/rescriptum/var/ | yes | yes |
| answers | /var/packages/rescriptum/shares/rescriptum/answers/ | yes | yes — always |
| the SQLite database, if you use one | beside the answers, in the same share | yes | yes — 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_TOKENandRESCRIPTUM_ADMIN_TOKENappear 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:
- Uncomment
RESCRIPTUM_MEDIA_DIRin the env file and restart the package. - Drop an ISO into the
rescriptumshare’smediafolder, over File Station or SMB. - 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
| Field | Value |
|---|---|
| Event | Boot-up |
| User | root |
| Command | see 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 traversal | a 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 bodies | an implausible Content-Length is refused from the header, before anything is read; the body is capped at 1 MB regardless |
| Slow clients | a header-read timeout and a whole-connection deadline, so a client that promises a body and sends nothing cannot park a connection |
| Bursts | over RESCRIPTUM_MAX_CONNECTIONS in flight, a prompt 503 and close, rather than queueing into an out-of-memory |
| Malformed input | a 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:
- 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 the0600env 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.) - 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
SynoTokenis 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.1plus 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.
Related
- Troubleshooting — reading the log, and the usual causes.
- Validating —
renderin its other forms.
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-featuresif 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/-shmsiblings all need to be writable, and they all belong to the same backup. RESCRIPTUM_DB_PATHdefaults 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.
Related
- The admin API — the reason most people turn this on.
- How the stores are built — the trait, and why it is thin.
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
| Request | Does |
|---|---|
GET /machines, GET /groups | list identifiers |
GET /machines/{id}, GET /groups/{name}, GET /default | the stored document, as written — comments and formatting intact |
PUT /machines/{id}, PUT /groups/{name}, PUT /default | store a document (the body is the document) |
DELETE /machines/{id}, DELETE /groups/{name}, DELETE /default | remove one |
GET /resolve/{id} | the merged answer that machine would receive |
GET /check | current problems, the same set as the check subcommand |
GET /health | liveness — 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
problemsarray. A clean response never implies the whole set is healthy — only that you did not make it worse. - It is why a machine’s
extendspointing 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
| Code | Means |
|---|---|
200 | done |
400 | a malformed document, or an invalid identifier |
401 | missing or wrong token |
404 | no such document, or nothing resolves for that identifier |
409 | a write that would have broken the answer set (rolled back), or a resolve that could not render |
413 | document over 256 KB |
429 | this address is blocked after repeated authentication failures |
500 | the 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 /healthunauthenticated 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.
Related
- The SQLite store — the prerequisite.
- How the guard is built — the internals.
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
| Symptom | Likely cause |
|---|---|
404 no answer file applies | Nothing 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 work | The URL now names a format alias that excludes your document — /ubuntu/answer will not serve a .toml |
500 … extends unknown group | A 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 only | That 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 token | RESCRIPTUM_ANSWER_TOKEN is set but the ISO was not prepared with the same --answer-auth-token |
413 | A body over 1 MB, or a Content-Length claiming one. Refused from the header, before anything is read |
503 | More concurrent connections than RESCRIPTUM_MAX_CONNECTIONS. Raise it, or find out who is connecting |
| Answer served, install still fails | The document is valid TOML but not valid Proxmox. Pipe render into validate-answer |
| Installer never contacts the server at all | The 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.
| Route | What comes back |
|---|---|
GET / | the catalogue as text, or JSON with Accept: application/json |
GET /<id>/iso | the image |
GET /<id>/kernel | the kernel, streamed from inside the image |
GET /<id>/initrd | the initrd, likewise |
GET /<id>/initrd+iso | the initrd with the image appended, for old loaders |
GET /<id>/file/<path> | any file inside the image |
GET /health | 200 OK |
Nothing is extracted and nothing is unpacked. A file in an ISO9660 image is one
contiguous run of bytes, so serving /pve-8.4/kernel is a seek and a length — the same
few kilobytes of work whether the image is 400 MB or 4 GB.
Ranges, ETag, If-Range and HEAD are all answered, because real clients need them:
Ubuntu’s casper and Red Hat’s anaconda both range-fetch, and UEFI HTTP Boot sends HEAD
before it fetches.
Why it is a second listener
Not preference — three separate reasons, any one of which would be enough:
- The answer endpoint answers on any path, because the URL is baked into an ISO. A
/media/…prefix would carve a reserved space out of one that is deliberately open. RESCRIPTUM_TIMEOUT_SECSis 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:
| Family | How the answer reaches it |
|---|---|
| Proxmox VE | inside the image, via auto-installer-mode.toml — and proxmox-start-auto-installer on the command line to select the automated path |
| Debian | preseed/url=… |
| Ubuntu | ds=nocloud-net;s=…/, from which cloud-init fetches user-data and meta-data |
| RHEL family | inst.ks=… |
| SUSE | autoyast=… |
| Fedora CoreOS | ignition.config.url=… |
Proxmox is the odd one out, and it is worth knowing why: it is the only one that carries
the answer’s location inside the image rather than on the kernel command line. That is
also why it is the only one that needs prepare-iso run over it once — see
Preparing installer media.
::: 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
| Variable | Default | What it is for |
|---|---|---|
RESCRIPTUM_MEDIA_ADDR | 0.0.0.0:8001 | The listener |
RESCRIPTUM_MEDIA_TIMEOUT_SECS | 600 | Whole-transfer deadline |
RESCRIPTUM_MEDIA_MAX_CONNECTIONS | 16 | Concurrent transfers |
Sixteen is low on purpose. Each transfer holds its permit for minutes, and the small end of what this has to run on is a NAS with one spinning disk: sixteen transfers at 64 KiB a chunk is about two megabytes of buffers, which is arithmetic that has to hold in 512 MB of RAM.
On a datacenter host, raise it. The answer endpoint has its own budget and is untouched either way.
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
filefield 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.
HTTPClientechoed back in option 60 for UEFI HTTP Boot clients. The firmware filters offers on it: a reply carrying only the URL is discarded, silently, which is indistinguishable from having no DHCP server.- A next-server for those clients too, even though they fetch over HTTP. Without one
the loader’s embedded script reads an empty
${next-server}and chains into nowhere.
::: tip Windows Server
A DHCP policy cannot condition on option 93 — the condition types are vendor class,
user class, MAC, client id, FQDN and relay information. The architecture reaches a policy
only inside the option 60 string, so the generated PowerShell defines vendor classes on
PXEClient:Arch:00007* and hangs the policies off those. Same outcome, different
mechanism, and it is exactly the sort of thing that gets half-remembered.
:::
The loader
TFTP hands over one file, and the rule is written into the code:
TFTP hands over the loader. Everything after that is HTTP.
At 1468 bytes a round-trip, TFTP moves about 1.4 MB/s on a millisecond of latency. The loader is a megabyte — two seconds. A 1.5 GB image would be the better part of twenty minutes, against fifteen seconds over HTTP on the same wire.
Which loader depends on what the firmware announced:
| Option 93 | Client | Served |
|---|---|---|
0x0000 | BIOS PXE | ipxe-undionly.kpxe |
0x0007, 0x0009 | UEFI x86-64 | ipxe-x86_64.efi, plus -snp / -snponly |
0x000b | UEFI ARM64 | ipxe-arm64.efi |
0x0010, 0x0013 | UEFI HTTP Boot | the same files, over HTTP, no TFTP at all |
| everything else | 32-bit UEFI, EBC, U-Boot | refused, with the reason |
0x0009 needs a word. RFC 4578 defined it as “EFI x86-64”; IANA’s registry, rewritten by
RFC 5970, lists it as “EBC”. Real x64 firmware sends either, so both map to x64 — a table
generated from the registry alone would hand half a fleet nothing.
snponly exists because the plain UEFI build cannot always see the NIC. All the variants
are served and the table picks; this is precisely the knowledge an operator should not
have to acquire.
Getting them
Every release attaches rescriptum-boot-assets-<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_UNCLAIMED | A machine no answer claims |
|---|---|
menu (default) | gets the menu, whose first entry is the local disk and whose timeout falls through to it — fifteen seconds, then the disk |
local | is handed straight back to its firmware, which moves to the next boot device |
These are opposite readings of what an answer file means, and the choice belongs to the deployment.
With the menu, a file claiming a machine is how you say leave this one alone — because without one it lands in a menu somebody could click. That is right while machines are being provisioned, and it is the project’s thesis: a machine nobody has decided anything about should end up where a human can decide.
With local, an answer file means install this one, and its absence is the safe state.
Nothing happens to a machine you have not written a file for — it boots its own disk, every
time, with no menu to click by accident. That is the reading a fleet in production needs,
and it is the one that scales: the number of machines you want to reinstall is always
smaller than the number you do not.
The payoff is that netboot can stay first in the BIOS order forever. Reinstalling a machine becomes add a file, reboot — no console, no boot menu, no hands on the hardware. Removing the file is what stops it happening twice.
$ 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:
| Family | Where |
|---|---|
| Proxmox | [post-installation-webhook] — native, nothing to write |
| Debian | d-i preseed/late_command string in-target sh -c '…' |
| Ubuntu | late-commands: in the autoinstall document |
| RHEL, AlmaLinux, Rocky | the %post section of the kickstart |
| SUSE | <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.kpxefrom 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 net0 — net0 is merely the
first interface, so a server booting from its second port would identify as its unused
first. And :uristring on every SMBIOS string, because ${manufacturer} expands to
Dell Inc. with the space and iPXE percent-encodes nothing on its own.
That final || is the whole of “a menu is the default answer”: a machine something
claims gets its own unattended answer, and a machine nothing claims falls through to the
menu. It is default/’s job description word for word, applied to a different
format.
The menu
$ 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 diskis first, and the timeout falls through to it. A machine that PXE-boots by accident, and that nothing claims, ends up on its own disk after fifteen seconds. It never sits waiting for a human who is not coming, and it never installs anything. Combined with the rule that an unclaimed machine gets a menu rather than an install, the worst case of being wrong about which machines reach this server is a few seconds added to a boot.- Entries are gated on the client’s architecture, so an ARM64 image is not offered to an x86 machine — that is an entry that boots the wrong kernel.
- An image no probe could place is still offered, as a CD.
- The diagnostics entries — a shell,
netinfo, and one that boots a different rescriptum — are what every boot server ends up needing. The last is how you test a candidate server on site, from the running one, without touching DHCP or the loaders.
RESCRIPTUM_BOOT_TIMEOUT_SECS (default 15) sets the wait, and RESCRIPTUM_BOOT_TITLE
the title bar. The logo is fetched with console --picture … ||, which tolerates its
own failure: a serial console over IPMI has no framebuffer, and that is how half of all
datacenter installs are watched.
What breaks when this server is down
Worth stating plainly, because “a boot server” sounds load-bearing and is not:
| rescriptum down | |
|---|---|
| DHCP addressing, DNS, routing | unaffected — it speaks none of those protocols |
| Machines already installed and running | unaffected |
| Machines rebooting | unaffected — they boot from disk |
| A machine that PXE-boots by accident | falls to its next boot device, as it would anyway |
| Starting a new installation | stops |
Nothing rescriptum installs depends on rescriptum afterwards. The answer endpoint is consulted during an install and never again.
Security
Boot traffic is unauthenticated, and necessarily — a PXE ROM has no credentials, the same necessity that already governs the answer endpoint. So the controls are structural, and one of them can say not you:
$ 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.
Reference
Reference
The exhaustive counterpart to the rest of the guide. Tables and contracts, one page per surface:
- Configuration — every environment variable, its default, and what an invalid value does.
- HTTP surface — methods, status codes, headers, limits.
- Formats and endpoint aliases — which extension is which format, and which URL segment asks for it.
- Command line — every subcommand and flag.
Looking for the narrative instead? Start at what rescriptum is.
Configuration
Configuration
Environment variables — and, optionally, a file to read them from, in either of two shapes. There is no command line to get wrong, and the variables are the whole configuration: both file formats set exactly the same things under exactly the same rules, so nothing you can write in a file means anything the environment could not.
The variables
| Variable | Default | Meaning |
|---|---|---|
RESCRIPTUM_CONFIG | unset | Read defaults from this TOML file — see below |
RESCRIPTUM_ENV_FILE | unset | Read defaults from this KEY=value file — see below |
RESCRIPTUM_STORE | files | files (a directory) or sqlite (a database) |
RESCRIPTUM_ANSWERS_DIR | /srv/answers | Directory of answer documents |
RESCRIPTUM_DB_PATH | /srv/answers.db | Database path, when RESCRIPTUM_STORE=sqlite |
RESCRIPTUM_LISTEN_ADDR | 0.0.0.0:8000 | Listen address. :0 picks a free port, and the bound one is printed |
RESCRIPTUM_WORKERS | CPU count | Async runtime threads. Not a concurrency limit |
RESCRIPTUM_MAX_CONNECTIONS | 2048 | In-flight connections before shedding with 503 |
RESCRIPTUM_TIMEOUT_SECS | 10 | Header-read timeout and whole-connection deadline |
RESCRIPTUM_ANSWER_TOKEN | unset | Bearer token the answer endpoint requires. Unset means open |
RESCRIPTUM_ADMIN_ADDR | unset | Admin API listener. Unset means the admin API is off |
RESCRIPTUM_ADMIN_TOKEN | unset | Admin bearer token, 16+ characters. Required with RESCRIPTUM_ADMIN_ADDR |
RESCRIPTUM_CAPTURE_DIR | unset | Record request bodies here. Unset means no capture |
RESCRIPTUM_LOG | all | all, problems or off — see below |
RESCRIPTUM_LOG_FILE | unset | A file to append to, or stdout / stderr. Unset means stderr |
RESCRIPTUM_MEDIA_DIR | unset | Installer images. Unset means no media and no media listener |
RESCRIPTUM_MEDIA_ADDR | 0.0.0.0:8001 | The media listener, when there is a media directory |
RESCRIPTUM_MEDIA_TIMEOUT_SECS | 600 | Whole-transfer deadline. Deliberately not the answer listener’s 10 |
RESCRIPTUM_MEDIA_MAX_CONNECTIONS | 16 | Concurrent transfers. Low on purpose: each holds its permit for minutes |
RESCRIPTUM_PUBLIC_HOST | derived | The host generated URLs name. A host, never a URL |
RESCRIPTUM_BOOT_ALLOW | unset | Client CIDRs allowed to fetch boot media. Unset means anyone who can reach the port |
RESCRIPTUM_BOOT_DIR | unset | Loaders and menus, handed out over TFTP. Unset means no TFTP at all |
RESCRIPTUM_TFTP_ADDR | 0.0.0.0:69 | The TFTP listener, or off for none. Port 69 is privileged; see RESCRIPTUM_USER |
RESCRIPTUM_TFTP_PORT_RANGE | unset | The ports transfers answer from, as first-last. A TFTP transfer leaves port 69 immediately — the server replies from a fresh port and the client acknowledges to that — so a firewall allowing only 69 drops the acknowledgement and the machine looks like it lost interest. Pin the range so it can be opened. Unset, the kernel picks |
RESCRIPTUM_TFTP_BLKSIZE | 1468 | The largest TFTP block to agree to. 1468 fills a 1500-byte path exactly — 1468 payload, 4 TFTP, 8 UDP, 20 IP — so a VLAN tag or a tunnel makes the frame too big and a PXE ROM usually just stops. Lower it (1400, or 512) when a boot stalls at the first block |
RESCRIPTUM_BOOT_TIMEOUT_SECS | 15 | Seconds before the menu falls through to local boot |
RESCRIPTUM_BOOT_UNCLAIMED | menu | What a machine no answer claims gets. local hands it back to its firmware instead, which inverts what an answer file means: present is install this one rather than leave this one alone |
RESCRIPTUM_INSTALLED_TOKEN | unset | Proxmox’s [post-installation-webhook] token. Set it and POST /installed exists, dropping a machine’s install claim when it reports success. Unset, there is no endpoint |
RESCRIPTUM_BOOT_LOGO | built-in | A PNG to show behind the menu |
RESCRIPTUM_BOOT_TITLE | built-in | The menu’s title bar |
RESCRIPTUM_USER / _GROUP | unset | Drop to these after binding. The other order fails on deployment |
/srv is where the filesystem hierarchy standard puts data served by the system, which is
what an answers directory is. Both defaults live there so that a bare rescriptum does
something plausible on any Linux host. Nothing creates the directory for you, and the server
says so at startup if it is missing.
Logging
One line per event, on stderr by default. Two knobs, because the two questions are different.
What — RESCRIPTUM_LOG:
| Value | Keeps |
|---|---|
all (default) | every request, plus startup, warnings and errors |
problems | startup, warnings, errors, and only the requests that did not succeed |
off / none | nothing at all |
A successful answer is one line, and at thirteen thousand requests a second that is the
only thing here with any volume. problems is what you want once a rollout is routine and
the disk is not. Everything else is low-volume and diagnostic, so it survives both.
A request that never reached a status at all — a connection that timed out mid-body —
counts as a problem. An unrecognised value falls back to all with a warning: a typo must
not be the reason nobody can see why a rollout failed. The level is named in the startup
line (log=problems), so an empty log explains itself.
Where — RESCRIPTUM_LOG_FILE:
| Value | Goes to |
|---|---|
unset, or stderr | stderr, which is what a supervisor reads |
stdout | stdout |
| any other value | that file, appended to; parent directories are created |
A file that cannot be opened is a startup error, not a fall back to stderr — that would be a silent surprise discovered much later. A write that fails once the server is running is dropped instead: a provisioning server that died because its log disk filled up would fail every install in flight in order to report that it could not report something.
Rotation is yours. Under systemd there is nothing to do, since the log goes to the journal;
with a file, point logrotate at it with copytruncate.
The TOML file
RESCRIPTUM_CONFIG names a file in TOML that sets the same variables in a shape meant to
be read. Reach for it when a person edits the file by hand — on a NAS, in File Station
or over SMB — which is exactly where RESCRIPTUM_ANSWERS_DIR=… on every line reads
poorly and where the word “environment” sends people looking for a shell that is not
there.
# /etc/rescriptum.toml (chmod 600, owned by root)
answers_dir = "/srv/answers"
listen_addr = "0.0.0.0:8000"
log = "problems" # all | problems | off
[store]
kind = "sqlite"
db_path = "/srv/answers.db"
[server]
workers = 2
max_connections = 2048
timeout_secs = 10
[admin]
addr = "127.0.0.1:8001"
token = "…"
[answer]
token = "…"
capture_dir = "/var/lib/rescriptum/captures"
$ RESCRIPTUM_CONFIG=/etc/rescriptum.toml rescriptum
2026-08-29T12:42:02Z - reading configuration defaults from /etc/rescriptum.toml (8 set)
Every rule the env file has, this one has too: never discovered, only named (there is
no ./rescriptum.toml); the real environment wins; and a file that was asked for and
cannot be read is a startup error, never a warning.
Put it outside the answers directory. Every servable .toml at the top of that
directory is an answer document, and this format shares the extension — a configuration
file dropped in there is reported by check as a misplaced answer, and migrate offers
to move it. /etc is the obvious home; on a packaged install the package chooses one.
The names
The prefix goes away and tables do the grouping. Nothing else changes: each line below is
the variable of the same name, and rescriptum config prints both spellings.
| In the file | Variable |
|---|---|
answers_dir | RESCRIPTUM_ANSWERS_DIR |
listen_addr | RESCRIPTUM_LISTEN_ADDR |
log, log_file | RESCRIPTUM_LOG, RESCRIPTUM_LOG_FILE |
public_host | RESCRIPTUM_PUBLIC_HOST |
user, group | RESCRIPTUM_USER, RESCRIPTUM_GROUP |
store.kind, store.db_path | RESCRIPTUM_STORE, RESCRIPTUM_DB_PATH |
server.workers, server.max_connections, server.timeout_secs | RESCRIPTUM_WORKERS, RESCRIPTUM_MAX_CONNECTIONS, RESCRIPTUM_TIMEOUT_SECS |
admin.addr, admin.token | RESCRIPTUM_ADMIN_ADDR, RESCRIPTUM_ADMIN_TOKEN |
answer.token, answer.capture_dir | RESCRIPTUM_ANSWER_TOKEN, RESCRIPTUM_CAPTURE_DIR |
media.dir, media.addr, media.timeout_secs, media.max_connections | the RESCRIPTUM_MEDIA_* four |
boot.dir, boot.allow, boot.unclaimed, boot.timeout_secs, boot.logo, boot.title | the RESCRIPTUM_BOOT_* six |
tftp.addr, tftp.port_range, tftp.blksize | the RESCRIPTUM_TFTP_* three |
installed.token | RESCRIPTUM_INSTALLED_TOKEN |
The format
| Any TOML scalar | a number may be written as a number (workers = 2) or as a string; both reach the server as the same setting |
A # comment | anywhere, including at the end of a line — unlike the env file, which has no escapes and so cannot have inline comments |
A value with a #, a quote or a space | fine, quoted the way TOML quotes things, and read back unchanged |
"" | unset — the same rule as an exported-but-empty variable, which is what lets config unset empty a line instead of deleting the paragraph that documents it |
| The same key twice | refused by TOML itself, so the file does not load |
| A key this program does not read | a warning naming it, so admin.tokenn is caught rather than ignored |
| A list or a table where a value belongs | a startup error: unlike a misspelling it was aimed at a real setting, and serving the default while the file says otherwise would be silent |
| A file others can read | a warning with its mode, because it may hold admin.token |
Warnings name keys and paths, never values.
Both files at once
Naming both is a transition rather than a steady state, so nothing is refused and the
order is stated at startup: the environment beats the TOML file, which beats the env
file. rescriptum config shows which of the three put every value in force, and
config set writes to the TOML file — the one the server reads first, so that a write
cannot be a change that silently does nothing.
The env file
RESCRIPTUM_ENV_FILE names a file of the same variables. It exists for deployments with
nowhere good to put a token — chiefly Synology DSM 7, which has no systemd. Under
systemd, EnvironmentFile= already does this and you do not need it.
# /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=…
$ RESCRIPTUM_ENV_FILE=/etc/rescriptum.env rescriptum
2026-08-24T12:42:02Z - reading configuration defaults from /etc/rescriptum.env (4 set)
It is never discovered, only named. There is no ./.env. This binary runs as root: if
it picked a file up from whatever directory it happened to be launched in, anyone who could
write there would own RESCRIPTUM_ADMIN_TOKEN — and with it the root password of every
machine installed afterwards.
The real environment wins. The file supplies defaults, so something exported deliberately at launch is never silently overridden. An exported-but-empty variable counts as unset, so the file still applies.
A file that was asked for and cannot be read is a startup error, not a warning. That is the whole point: the failure it replaces is a server coming up on its defaults — wrong answers directory, no admin token — without a word in the log.
The format
KEY=value, one per line | leading export is accepted, so the same file can also be sourced |
# at the start of a line | a comment |
# anywhere else | part of the value. There are no inline comments: truncating a token at a # it legitimately contains would be silent, and a comment landing in a value is loud |
"quoted" or 'quoted' | the quotes are stripped and inner whitespace is kept; unquoted values are trimmed |
$HOME, ${x} | not expanded. This is not a shell — no substitution, no continuation lines |
| the same key twice | a startup error, rather than a guess about which was meant |
| a key this program does not read | a warning naming the key — so RESCRIPTUM_ADMIN_TOKENN is caught rather than ignored |
| a file others can read | a warning with its mode, because it may hold the admin token |
Warnings name keys and paths, never values.
Reading and editing it
rescriptum config prints every variable, its value, and which of the files and the
environment put it there — the distinction that matters, because the files supply
defaults and the real environment wins. config set edits the file the way you would want
it edited, in either format: comments kept, a commented-out setting uncommented in place
rather than duplicated (in the env file) or the value replaced where it stands (in TOML),
and a change that would leave a server unable to start refused before anything is
written. It is documented in the command line reference, and it is
what the DSM application drives
underneath.
Invalid values
| Case | What happens |
|---|---|
Exported but empty (RESCRIPTUM_LISTEN_ADDR=) | treated as unset — an empty value is a mistake, not an instruction |
| Whitespace-only | same, and values are trimmed |
| A zero or unparseable number | falls back to the default, rather than starting a server that accepts connections and never answers |
RESCRIPTUM_STORE set to anything else | a warning, and files is used |
RESCRIPTUM_ENV_FILE or RESCRIPTUM_CONFIG naming a missing, unreadable or malformed file | a startup error |
| A TOML setting given a list or a table | a startup error, unlike a misspelled key, which warns |
RESCRIPTUM_STORE=sqlite on a binary built without the feature | a startup error |
Startup errors
These stop the server rather than warning, because starting anyway would be worse:
| Condition | Why it is fatal |
|---|---|
RESCRIPTUM_ADMIN_ADDR set with RESCRIPTUM_STORE not sqlite | two ways to change the same configuration, racing |
RESCRIPTUM_ADMIN_ADDR set with no RESCRIPTUM_ADMIN_TOKEN | an open API that rewrites root credentials |
RESCRIPTUM_ADMIN_TOKEN under 16 characters | short enough to guess |
| The listen address cannot be bound | nothing to do |
| The store cannot be opened | nothing to serve |
RESCRIPTUM_MEDIA_ADDR set with no RESCRIPTUM_MEDIA_DIR | a listener with nothing to serve |
RESCRIPTUM_MEDIA_ADDR equal to the answer or admin address | the second bind loses, and which one depends on start order |
RESCRIPTUM_PUBLIC_HOST carrying a scheme, a port or a path | it is written into URLs for two listeners; one port in the value pins every generated script to one of them |
RESCRIPTUM_TFTP_ADDR set with no RESCRIPTUM_BOOT_DIR | a listener with no loaders to hand out |
| The boot directory cannot be resolved | every path check compares against it |
RESCRIPTUM_USER names an account that does not exist | nothing to become |
Startup warnings
These are printed and the server carries on:
| Condition | Line |
|---|---|
| Answers directory missing | warning: … does not exist yet — every request will 404 until it does |
| Answers path exists but is not a directory | warning: … is not a directory — every request will 404 until it is |
| Answers directory present but unreadable | warning: … cannot be read: … — every request will 404 until that is fixed. The likeliest cause is the server running as a user that is not the directory’s owner |
| Admin API not on loopback | warning: the admin API is not bound to loopback — … |
RESCRIPTUM_ANSWER_TOKEN under 16 characters | a warning, not an error — refusing to start would leave a fleet unable to install |
| Any problem in the answer set | one warning: line each, the same set check reports |
RESCRIPTUM_PUBLIC_HOST unset | The routing table’s answer, or the sole interface address when there is no default route. Logged either way, as a warning naming the other addresses when there are any. A NAT host still gets it wrong silently |
| TFTP cannot bind | warning: cannot bind TFTP on … — the one listener whose failed bind is not fatal. Port 69 is the only privileged port in the design, so it is the only bind that can fail for something nobody configured; answers are the product, and dying would fail every install in flight to report that a second port could not be opened. boot check exits non-zero and the message names the ways to have the port |
| Media directory missing or unlistable | one warning: media: … line — a fleet must never be unable to install because one image is odd |
Compile-time options
| Feature | Default | Effect |
|---|---|---|
sqlite | on | The SQLite store and the admin API |
boot | on | The media catalogue, the ISO reader and the media listener |
Measured on ARMv7 (gnueabihf, glibc floor 2.17), all four in one sitting on 2026-08-29. Re-measure rather than quoting these: they moved by about 375 KB when that target changed from musl, and the set they replace here had drifted about 200 KB out of date.
| Build | Bytes |
|---|---|
| both (default) | 2,813,712 |
sqlite only | 2,557,592 |
boot only | 1,649,048 |
| neither | 1,392,544 |
Fixed limits
Not configurable, and deliberately so:
| Limit | Value | Where |
|---|---|---|
| Request body | 1 MB | answer endpoint — an aberrant Content-Length is refused from the header |
| Document size | 256 KB | admin API PUT |
| Captured requests | 1000 captures | counted from the directory at startup, so a restart does not start again |
| Admin failures before a block | 5 within 60 s | block doubles to a maximum of 900 s |
| Addresses tracked by the guard | 4096 | so the guard cannot be turned into a memory leak |
| Listing reload backstop | 1 s | forces a re-read even when the directory mtime looks unchanged |
HTTP surface
HTTP surface
Two listeners, and they never share a port. The answer endpoint is what installers talk to; the admin API is off unless configured.
The answer endpoint
| Request | Response |
|---|---|
POST any path | the answer, content-typed by its format |
GET any path | the same |
GET /health | 200 OK, body OK\n — no token needed, never rate-limited |
| any other method | 405 |
Any path, because the URL is baked into an ISO and this server does not get to choose
it. The path is not ignored, though: segments naming a
format alias restrict which documents may answer, and the path also
contributes the path, file and segment
facts.
Status codes
| Code | When |
|---|---|
200 | an answer applied |
400 | the body could not be read |
401 | RESCRIPTUM_ANSWER_TOKEN is set and the request did not present it |
404 | nothing claimed the request and there is no default for the format asked for |
405 | a method other than GET or POST |
413 | body over 1 MB, or a Content-Length claiming one |
500 | a document would not parse, a group is missing, a template could not be filled, or the lookup panicked |
503 | at RESCRIPTUM_MAX_CONNECTIONS — written promptly, then the connection closes |
Response headers
| Header | Value |
|---|---|
Content-Type | from the answer’s format — see the table below |
Content-Length | always set |
Connection | close |
WWW-Authenticate | Bearer, on 401 |
| Format | Content-Type |
|---|---|
toml, and every text format (ks, preseed, cfg, seed, ipxe) | text/plain; charset=utf-8 |
yaml, yml | text/yaml; charset=utf-8 |
json, ign | application/json |
xml, autoyast, unattend | application/xml; charset=utf-8 |
TOML is served as text/plain rather than application/toml because that is what the
Proxmox installer expects.
Request handling limits
| Body cap | 1 MB. An implausible Content-Length is refused from the header, before the body is read at all — rather than allocating for it and tripping a limit later |
| Header-read timeout | RESCRIPTUM_TIMEOUT_SECS, default 10 s |
| Whole-connection deadline | the same value. Both are needed: the header timeout stops at the end of the headers, so a client that promises a body and sends nothing would otherwise park a connection indefinitely |
| Concurrency | RESCRIPTUM_MAX_CONNECTIONS in flight; over that, a 503 and close rather than queueing |
| Authentication | only when RESCRIPTUM_ANSWER_TOKEN is set. Compared in constant time. Failures are logged and never rate-limited |
Authentication
Authorization: Bearer <RESCRIPTUM_ANSWER_TOKEN>
Proxmox sends this when its ISO was prepared with --answer-auth-token. Nothing else
can, which is why it is off by default. See Security.
The admin API
A separate listener, RESCRIPTUM_ADMIN_ADDR, over SQLite only. Full details on
its own page.
| Request | Does |
|---|---|
GET /machines, GET /groups | list identifiers |
GET /machines/{id}, GET /groups/{name}, GET /default | the stored document, as written |
PUT /machines/{id}, PUT /groups/{name}, PUT /default | store a document |
DELETE /machines/{id}, DELETE /groups/{name}, DELETE /default | remove one |
GET /resolve/{id} | the merged answer that machine would receive |
GET /check | current problems |
GET /health | liveness — no token, never blocked |
All document endpoints take ?format=<ext>, defaulting to toml.
| Code | When |
|---|---|
200 | done |
400 | malformed document, invalid identifier, or a non-UTF-8 body |
401 | missing or wrong token |
404 | no such document or endpoint; nothing resolves for that identifier |
409 | the write would have broken the answer set (rolled back), or a resolve that could not render |
413 | document over 256 KB |
429 | this address is blocked; Retry-After says for how long |
500 | the store could not be read or written |
Every admin response sets Connection: close. Successful GET /resolve also sets
X-Answer-Source, carrying the same description the log line uses.
Logging
One line per request, on stderr by default. RESCRIPTUM_LOG chooses what is kept and
RESCRIPTUM_LOG_FILE chooses where it goes — see logging.
2026-08-24T08:43:37Z 127.0.0.1:61721 POST /answer body=102 200 format=toml machine=98fa9b50d810 group=example-rack bytes=431
Server-level lines carry - where the peer address would be. See
troubleshooting.
Formats and endpoint aliases
Formats and endpoint aliases
Three tables. The narrative version is in one document per operating system.
Document extensions
The allowlist of extensions rescriptum will pick up from a store. Anything else is
ignored — txt is deliberately not on the list, so a stray notes file next to your
answers never becomes a candidate.
| Extension | Family | Layering | Content-Type |
|---|---|---|---|
toml | TOML | structural merge | text/plain; charset=utf-8 |
yaml, yml | YAML | structural merge | text/yaml; charset=utf-8 |
json, ign | JSON | structural merge | application/json |
xml, autoyast, unattend | XML | structural merge, by element | application/xml; charset=utf-8 |
ks, cfg, preseed, seed, ipxe | text | concatenation in layer order | text/plain; charset=utf-8 |
The family is what the log line’s format= field reports, so ks and preseed both
appear as format=text. The extension is what an endpoint filters on, and what
check needs in order to pick the right validator.
Endpoint aliases
A path segment naming one of these restricts the answer to documents with the listed
extensions. Any segment of the path may name it, so /rhel/ks, /ks and
/provision/rhel/node.cfg all restrict to kickstart.
| Segment | Serves | Typical use |
|---|---|---|
proxmox, pve, toml | .toml | Proxmox VE |
debian, preseed | .preseed, .seed | Debian preseed |
rhel, centos, fedora, alma, rocky, kickstart, ks | .ks | kickstart |
ubuntu, autoinstall, cloudinit, nocloud, yaml, yml | .yaml, .yml | Ubuntu autoinstall, cloud-init |
flatcar, coreos, ignition, ign | .ign, .json | Ignition |
suse, opensuse, autoyast | .autoyast, .xml | AutoYaST |
windows, unattend | .unattend, .xml | Windows unattend.xml |
json | .json, .ign | |
xml | .xml | |
cfg | .cfg | |
ipxe | .ipxe |
A segment naming none of these constrains nothing, which is why /answer keeps
working exactly as it always has.
Two traps in this table
- Filtering is on the extension, not the family.
.ksand.preseedare both text documents; filtering by family would let a preseed answer/rhel/ks. seedis deliberately not an alias.s=http://server/seed/is an ordinary NoCloud seed URL, and it serves YAML. An alias has to be specific enough that nobody reaches it by accident. (The.seedextension still exists, and/debian/serves it.)
Control keys, per format
Stripped before the answer is sent.
| Format | Spelling |
|---|---|
| TOML | top-level extends = "base", members = […], [match] table |
| YAML | top-level extends:, members:, match: |
| JSON | top-level "extends", "members", "match" |
| XML | <answer-meta extends="base"><member>…</member><match k="v" /></answer-meta> |
| Text | # answer: extends <name> · # answer: member a, b · # answer: match k=v k2=v2 |
Text directives also accept // as the comment marker. match takes space-separated
key=pattern pairs; member a comma-separated list. Ordinary comments in a text document
are served — only # answer: lines are removed.
Merge semantics
| Structural formats | Text formats | |
|---|---|---|
| Maps / objects / elements | merge recursively | — |
| Scalars | higher layer replaces | — |
| Arrays / lists | replace, never append | — |
| Whole document | — | concatenated in layer order |
XML pairs siblings by element name plus a discriminating attribute — name, id, key,
alias, pass — and honours config:type="list". Declarations, doctypes, namespaces and
attributes survive a merge; original indentation and comment placement do not.
Validators check can call
| Format | Tool | Invoked as |
|---|---|---|
toml | proxmox-auto-install-assistant | validate-answer <file> |
xml, autoyast, unattend | xmllint | --noout <file> |
ks | ksvalidator | <file> |
| everything else | — | none exists |
A tool that is not on PATH is reported once as a note, never as a failure.
Command line
Command line
With no arguments, rescriptum runs the server. Everything else is a subcommand.
| Command | Does |
|---|---|
rescriptum | run the server |
rescriptum render <id> | print the answer that identifier would receive |
rescriptum render --body FILE | …for a captured request body |
rescriptum render --query Q | …for labels, e.g. "mac=aa:bb&serial=7ABC1" |
rescriptum check | render everything in the configured store and report what breaks |
rescriptum import <dir> | copy a directory of documents into the configured store |
rescriptum export <dir> | write the configured store out as a directory of documents |
rescriptum migrate [<dir>] | show what a flat answers directory would become |
rescriptum migrate --apply | move those documents into a directory each |
rescriptum config | show the configuration, and where each value comes from |
rescriptum config --json | the same, for a settings panel |
rescriptum config --value KEY | one value, for a script — never a credential |
rescriptum config set K=V … | edit the file RESCRIPTUM_CONFIG or RESCRIPTUM_ENV_FILE names |
rescriptum config unset KEY … | take a setting back out of it |
rescriptum --help | usage and the environment variables |
All of them read the same environment variables, including
RESCRIPTUM_CONFIG and
RESCRIPTUM_ENV_FILE — which are resolved first, so a
file that cannot be read stops any command that needs configuration. --help and
--version are answered before it is read, because they are what you reach for when
something is wrong. There are no global flags.
render
$ rescriptum render 98:fa:9b:50:d8:10
$ rescriptum render --query "serial=7ABC123&mac=98:fa:9b:50:d8:10"
$ rescriptum render --query "path=/rhel/ks&serial=7ABC123"
$ rescriptum render --body /var/log/rescriptum-captures/2026…-0000.body
| Form | Facts it supplies |
|---|---|
<id> | the identifier as a haystack, and nothing else — enough to match by name, not enough for a selector on serial |
--query "k=v&k2=v2" | those labels, percent-decoded. path= also yields file and segment, and constrains the format the way a real URL would |
--body FILE | the file verbatim: haystack, plus flattened JSON if it parses as JSON |
- The document goes to stdout; the
# format=… machine=… group=…line explaining how it was reached goes to stderr. Sorender … > answer.tomlgives you just the document. - Load-time problems are printed as
warning:lines first. - Exit 0 when something resolved, 1 when nothing applied (the server would have
returned
404) or rendering failed.
check
$ rescriptum check
Reports load-time problems, renders every machine document and every group member, names
the groups that select on a match block (which it cannot try without a real request),
and calls the installer’s own validator where one is on PATH.
Exit 0 when everything renders, 1 when anything failed — so it drops into CI as is. See validating.
import / export
$ RESCRIPTUM_STORE=sqlite RESCRIPTUM_DB_PATH=/srv/answers.db rescriptum import /srv/answers
$ RESCRIPTUM_STORE=sqlite RESCRIPTUM_DB_PATH=/srv/answers.db rescriptum export /tmp/backup
import reads a directory and writes into the configured store; export does the
reverse. The round trip is byte-identical, paths included. Neither runs check for you —
the output says to.
migrate
Answers used to be files at the top of the answers directory — 98fa9b50d810.toml beside
98fa9b50d810.preseed. They are now a directory each, and a document left flat is
reported and not served. This moves them:
$ rescriptum migrate
migrating /srv/answers
98fa9b50d810.toml -> 98fa9b50d810/proxmox.toml
98fa9b50d810.ipxe -> 98fa9b50d810/boot.ipxe
groups/rack-a.toml -> groups/rack-a/proxmox.toml
default.toml -> default/proxmox.toml
4 document(s) to move — nothing has been changed. Re-run with --apply.
It shows by default and moves only when told to. The answers directory is what a rack installs from; typing the command to find out what it would do must not rearrange it.
--apply performs the moves, each a rename within the same directory, so no document is
ever rewritten. If any destination is already taken, nothing moves at all — including
the documents that could have — and the collisions are named: a half-migrated directory is
the state nobody can reason about. It takes a directory as an argument, defaulting to
RESCRIPTUM_ANSWERS_DIR, and running it on an already-migrated directory says there is
nothing to move.
config
The configuration is environment variables, and on a deployment that reads them from a file — a packaged install, mainly — this is how to see and change them without opening an editor. It is also what the DSM application runs underneath.
$ rescriptum config
env file: /var/packages/rescriptum/etc/rescriptum.env
RESCRIPTUM_STORE files default
RESCRIPTUM_ANSWERS_DIR /volume1/netboot/answers file
RESCRIPTUM_LISTEN_ADDR 0.0.0.0:9000 environment
RESCRIPTUM_ADMIN_TOKEN (set) file
The third column is the point. The files supply defaults and the real environment
wins, so a value marked environment cannot be changed by editing a file — and config set says so rather than letting you write something the running server will ignore. With
a TOML file the column reads toml file, and naming both files prints both paths plus the
order they win in.
config set writes the TOML file when both are named, because it is the one the server
reads first: writing the other would be a change that silently does nothing.
A credential is never printed, by any form of this command. A token shows as (set) or
(not set); --value refuses outright.
$ rescriptum config set RESCRIPTUM_LOG=problems RESCRIPTUM_CAPTURE_DIR=/srv/captures
wrote /var/packages/rescriptum/etc/rescriptum.env
Writing keeps the file as it is otherwise: comments stay, a setting is replaced where it
stands, and one that is commented out is uncommented in place rather than appended
below — which matters when the comment above it is the only documentation the file has. In
a TOML file the same care applies to the document: the value is replaced where it stands,
its trailing comment survives, and config unset empties the value rather than deleting
the line, so the paragraph explaining the setting stays where it was.
Two refusals are deliberate:
- A change that would leave a server unable to start is refused, whole, before anything is written. Turning on the admin API without a token, or with a token shorter than 16 characters, gets the reason instead of a broken next boot.
- A misspelled variable is refused. Written, it would be read back as a stranger and warned about at the next start, by which time nobody connects the two.
Unlike every other subcommand, this one works when the configuration is too broken to start a server — a file that will not parse, a token one character short. That is the state people run it to get out of.
media
Boot media: the installer images this server holds. Every one of these needs
RESCRIPTUM_MEDIA_DIR; without it they say so and exit 1. See
Serving boot media.
$ rescriptum media list # what is held: family, architecture, version, digest
$ rescriptum media add FILE [--sha256 D] # register one already in the directory
$ rescriptum media add URL --sha256 D # fetch one into it, then register it
$ rescriptum media check # re-verify every recorded digest
$ rescriptum media ipxe ID # print the .ipxe answer that boots one image
$ rescriptum media prepare ID [--url URL] # a Proxmox image with its answer URL inside
$ rescriptum media export ID FILE # materialise a prepared entry, for a stick
media add takes a file already inside the media directory — nothing is downloaded
and nothing is copied. It hashes it with progress, probes it, and writes a .media
sidecar beside it. --sha256 is checked before anything is recorded: a mismatch writes
nothing and exits 1.
media check’s exit status is a contract, like check’s. deploy.sh keys on it.
media ipxe prints to stdout and puts everything else on stderr, so
rescriptum media ipxe pve-8.4 > groups/rack-a/boot.ipxe produces a usable answer document —
which is all it is. It prints a script; it does not install one.
boot
The netboot half: TFTP’s loaders, the generated DHCP configuration, and the two scripts a machine executes. See Netbooting a machine.
$ rescriptum boot dhcp-snippet [--format F] [--one-loader]
$ rescriptum boot check # are the loaders a snippet names actually on disk?
$ rescriptum boot bootstrap # print the stage-two script
$ rescriptum boot menu # print the built-in menu
--format is dnsmasq (the default), isc, kea, powershell, pfsense or
mikrotik. The snippet goes to stdout and warnings to stderr, so
boot dhcp-snippet > dhcpd.conf produces a file that can be included as-is.
boot check’s exit status is a contract, like check’s. What it catches is the least
diagnosable failure in the chain: a snippet naming a loader that is not on disk fails
silently at the ROM, with nothing on any console. It also warns when the media listener
has moved off the port shipped loaders embed.
boot bootstrap and boot menu print what a machine will execute, for the same reason
render prints an answer: everything a rack runs should be readable by a human first.
Exit statuses
| Status | Means |
|---|---|
0 | success |
1 | the command failed — nothing resolved, a document would not parse, the store could not be opened |
config is the one with a second meaning: 0 says the configuration is one the server
would start on, 1 that it is not — or that a write was refused. That makes it usable
from a script the way check is.
The server itself exits 0 on SIGTERM or Ctrl-C, and 1 if it cannot bind or cannot
open the store.
Development
Working on rescriptum
Working on rescriptum
rescriptum is a small, focused thing: it works out which install config each machine should get, composes it from layers, and serves it. Around 4,000 lines of Rust, 308 tests, and a short list of constraints that are not up for casual revision.
This space is the why. The Guide is the what.
Get it running
git clone https://github.com/z29k/rescriptum && cd rescriptum
cargo test # 308 tests
cargo run -- --help
Try a change against the worked examples rather than only against tests — they are the only place all the formats are shown composing together:
RESCRIPTUM_ANSWERS_DIR=examples cargo run -- check
RESCRIPTUM_ANSWERS_DIR=examples cargo run -- render --query "path=/rhel/ks&serial=7ABC123"
Before opening a PR:
cargo fmt --all
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features
cargo build --release --no-default-features # the smallest build must keep working
Those four are exactly what CI runs.
The repository
| Path | Holds |
|---|---|
src/main.rs | runtime setup, accept loop, connection serving, routing, and the blocking half of a request |
src/lib.rs | the crate. main.rs is a thin binary over it, so behaviour is testable directly |
src/select.rs | normalization, matching, layering — the behaviour that matters |
src/facts.rs | what a request says about the machine |
src/format/ | one interface per document format; xml.rs holds the XML tree |
src/merge.rs | the TOML merge, used by format |
src/store/ | where documents come from: file.rs, sqlite.rs, behind a thin trait |
src/admin.rs | the write API, and the guarantee that a write cannot break the fleet |
src/config.rs | environment configuration |
src/envfile.rs | the optional file of defaults RESCRIPTUM_ENV_FILE names — never discovered, only named |
src/capture.rs | recording what machines actually send |
src/cli.rs | the render, check, import and export subcommands |
src/log.rs | one line per event, UTC timestamps without a date crate, and the two knobs over both |
tests/ | the real binary over a socket (integration, admin, guards), its command line (cli), and the two-store conformance suite (stores) |
examples/ | a worked example of every supported format |
docs/ | this site |
Never re-declare a module in main.rs. It compiles a second copy, runs every unit
test twice, and lets the two copies drift.
Where to start reading
- The constraints — first. They explain most of the code’s shape, and several of them look like things worth “improving” until you know why they are there.
- Architecture — the module map and what flows between them.
- The request lifecycle — a request from accept to response.
- Selection — the part with the most behaviour per line.
- Traps already hit — a list of things that cost time once. Reading it is cheaper than rediscovering them.
Conventions
- English for code, comments, commit messages, and the source of the documentation.
The docs are additionally published in French (
*.fr.mdsiblings) — see the documentation site. - Behaviour belongs in
tests/stores.rs, which runs every case against both stores and requires the identical outcome. A test covering one store proves half of what it claims. See testing. - Arrays replace, they do not append, in every format.
- Fail loudly. A missing group, an unfillable template, a document that will not parse — all are errors with a reason. Serving a half-built answer installs a machine wrongly, and nobody finds out until it is running.
- Adding a dependency needs a reason in the commit message. This binary runs as root
on other people’s hardware, and CI’s
auditjob is the other half of that rule: a reason to add one is not a reason to keep it. - Conventional commits with a scope —
feat(http): …,fix(select): ….
Also worth reading
CLAUDE.md at the repository
root is the architecture document written for coding agents. It overlaps this space
heavily and is the file to update when a constraint changes.
Architecture
Architecture
One process, one crate, no framework. main.rs is a thin binary over lib.rs, so every
behaviour can be tested directly rather than only through a socket.
The shape of it
flowchart TB
subgraph net["Network"]
I["Installer<br/>POST /answer · GET /rhel/ks"]
A["Admin client"]
end
I --> M["main.rs<br/>accept · timeouts · routing"]
A --> AD["admin.rs<br/>own listener · auth · guarded writes"]
M --> F["facts.rs<br/>query · JSON leaves · haystack"]
F --> S["select.rs<br/>match · layer · fill"]
AD --> S
S --> FM["format/<br/>parse · merge · render"]
FM --> MG["merge.rs<br/>TOML deep merge"]
FM --> X["format/xml.rs<br/>XML tree"]
S --> ST["store/ (trait)"]
AD --> ST
ST --> FS["file.rs<br/>a directory"]
ST --> SQ["sqlite.rs<br/>a database"]
CLI["cli.rs<br/>render · check · import · export"] --> S
What each piece owns
| Module | Owns |
|---|---|
main.rs | the tokio runtime, the accept loop, the connection semaphore, both timeouts, routing, the answer-token check, and the spawn_blocking call that does the lookup |
facts.rs | turning a request into labelled values — query parameters, a flattened JSON body, path segments, and the normalized haystack |
select.rs | the behaviour that matters: normalization, scoring, the group chain, the merge order, template filling, and the cached listing |
format/ | one interface per document format. Doc parses, merges, renders, and reports its control keys |
merge.rs | the TOML deep merge, used by format |
store/ | where documents come from, behind a two-method read trait |
admin.rs | its own listener, bearer auth, the failure guard, and the rollback that keeps a write from breaking the answer set |
config.rs | the environment, and the validation that turns a dangerous configuration into a startup error |
envfile.rs | the file RESCRIPTUM_ENV_FILE names: parsed, never discovered, and fatal when it cannot be read |
cli.rs | render, check, import, export |
capture.rs | recording request bodies |
log.rs | one line per event, UTC timestamps computed without a date crate, and the two knobs over both: what is kept, and where it goes |
The one boundary worth defending
The store is deliberately thin. It hands back raw document text and a cheap version token, and decides nothing:
pub trait Store: Send + Sync {
fn version(&self) -> Version; // cheap enough to call per request
fn snapshot(&self) -> io::Result<Snapshot>; // only when version moved
fn describe(&self) -> String;
}
Every decision — matching, extends chains, merging, rendering, check — lives above
it, in select.rs and merge.rs, and is shared by both backends. The moment a backend
starts deciding behaviour, the two drift.
tests/stores.rs is what makes that a guarantee rather than an intention: every
behavioural case runs twice, once per store, and asserts the identical outcome.
The write half is a separate trait, because serving answers never needs it:
pub trait StoreWrite: Store {
fn put_machine(&self, id: &str, format: &str, body: &str) -> io::Result<()>;
fn delete_machine(&self, id: &str, format: &str) -> io::Result<bool>;
// …groups, default
}
Note that every operation names a format. A document is keyed by what it is for — a machine and an operating system — not by identifier alone.
The caching layer
Answers wraps a store and holds a parsed, merged Listing behind a mutex:
struct Cached { version: Version, loaded_at: Instant, listing: Arc<Listing> }
A request reuses the cache only when all three hold:
store.version()is unchanged — for files, the directory’s mtime; for SQLite, an in-process atomic;- that version is
Some— an unreadable version is never treated as “unchanged”; - less than
RELOAD_BACKSTOP(1 s) has passed.
The backstop is not redundant with the version check. Editing a group file’s contents moves no directory mtime, and a change made by another process moves no in-process atomic. Without the backstop, either edit would be invisible until something else happened to the directory.
A poisoned mutex — some other request panicked mid-refresh — is recovered into rather than propagated. The cached data is still structurally fine, and failing an install over another request’s panic would be the wrong trade.
Why there is no framework
Routing here is one if on method and path. A framework buys nothing for that, and
axum specifically gives no way to set a header-read timeout — which is precisely the
slowloris guard that motivated going async in the first place. So: hyper directly.
Dependencies
64 crates, 2.4 MB static on ARMv7 (1.3 MB without SQLite). Direct:
| Crate | For |
|---|---|
tokio | the runtime, timers, signals |
hyper + hyper-util + http-body-util | HTTP/1, with a header-read timeout |
toml_edit | TOML, preserving formatting |
serde_json | JSON documents, and flattening a request body |
serde_yaml_ng | YAML documents |
quick-xml | XML documents |
rusqlite (optional, bundled) | the SQLite store |
No serde derive anywhere. The original rule was “never parse the request body as
JSON”; it has since been relaxed deliberately, and the honest statement of where it stands
is: the body is parsed into an untyped serde_json::Value when it happens to be
JSON, purely to harvest facts. Nothing is deserialized into a struct, so no assumption
about Proxmox’s schema is baked into a type. A body that is not JSON is not an error — it
contributes the haystack and nothing more. See selection.
Adding a dependency needs a reason in the commit message. This binary runs as root on other people’s hardware.
The constraints
The constraints
These are decisions, not oversights. Several of them look like obvious improvements from
the outside. Do not change one without asking — and if you do change one, change this
page and CLAUDE.md with it.
Async, on tokio and hyper
The original specification asked for zero dependencies and a thread per connection. Both were overridden deliberately, once the requirement became “absorb a professional provisioning burst”. A 2,000-machine rollout is 2,000 near-simultaneous connections, and a thread each is 2,000 stacks on a box with 512 MB.
What survived from the spec: no serde derive, no framework, and a very short direct
dependency list. See architecture.
hyper directly, not axum
axum gives no way to set a header-read timeout, which is precisely the slowloris guard
that motivated going async. Routing here is one if on method and path, so a framework
buys nothing and costs the one thing that mattered.
Bounded concurrency, even though tasks are cheap
A connection costs kilobytes rather than a thread — that is the whole point of the async rewrite. But cheap is not free, and unbounded accept still turns a burst into an out-of-memory.
A Semaphore of RESCRIPTUM_MAX_CONNECTIONS caps in-flight connections. Over the cap the
server writes a prompt 503 and closes rather than queueing: a client told to retry is
better off than one parked in a queue that will not drain.
Filesystem work goes through spawn_blocking
read_dir and read are blocking calls, and blocking an async worker thread stalls every
other connection that thread is driving. On a NAS with a sleeping disk that is not
theoretical — a spin-up is seconds, not milliseconds.
resolve() holds both the parse and the IO, and is only ever called inside
spawn_blocking. A panic there returns a 500; it cannot take the server down.
Never panic on malformed input
Any parse failure becomes an error response plus a log line. Write the code as if there were no safety net.
There is one, deliberately: the release profile does not set panic = "abort". With
unwinding, a panic is contained to the connection that caused it instead of killing a
server mid-install. Measured cost on ARMv7: +2416 bytes, +0.8%. Do not optimize it
back.
If the design ever moves to a thread pool, add catch_unwind at the worker boundary — a
pool thread that dies silently is worse than either.
[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
strip = true
# panic = "abort" is deliberately ABSENT
The store decides nothing
It hands back raw document text and a cheap version token. Matching, extends chains,
merging, rendering and check all live above it and are shared.
Keep it that way. The moment a backend starts deciding behaviour, the two drift — and
tests/stores.rs stops being able to prove they have not.
Storage layout is not the URL
Directories and database rows are a lookup space and must stay free to be reorganised. A URL is a public contract baked into an ISO and must not move because someone renamed a folder. An earlier design made the directory name be the URL segment and was discarded for exactly that reason.
The consequence is that a document’s key is (identifier, format), which is what the SQLite schema is built around.
Never build a filesystem path from request data
This is the path-traversal guard, and it is structural rather than a check: only direct
entries of the answers directory are ever read. Identifiers arriving at the admin API
are separately validated, at the API boundary and in both stores, because export turns
them back into filenames.
Answers must be valid documents
Before merging, an answer file was served as opaque bytes, so a malformed one reached the
installer. Now it is a 500 with the parse error in the log.
That is the better failure — an installer receiving half-valid TOML fails in a much more confusing way — but it is a behaviour change, and fixtures written as YAML-ish text stopped working when it landed.
Fail loudly
A missing group, an unfillable template, a document that will not parse: all are errors with a reason, never a best-effort answer.
The reasoning is always the same. A half-built answer installs a machine wrongly, and nobody finds out until it is running. A failed install is noticed in minutes.
Deliberate asymmetries
Two places where the obvious symmetry is wrong on purpose:
| The answer token is never rate-limited; the admin token is | a rack can sit behind one address, so shutting it out turns a bad token into a failed rollout. No installer talks to the admin API |
| A short answer token warns; a short admin token refuses to start | refusing to start would leave a fleet unable to install. Refusing to start the admin API costs nobody an install |
What the spec asked for and did not get
plans/rescriptum-spec.md (gitignored, so a contributor will not have it) is the record
of what was first asked for, not a description of what exists. The project outgrew it
in every direction: multi-OS, selectors, templating, an admin API, a database store.
Three specific departures, all listed above: async rather than a thread per connection,
panic = "abort" omitted, and the request body parsed as untyped JSON to harvest facts.
Where the spec and this page disagree, this page is right.
The request lifecycle
The request lifecycle
sequenceDiagram
participant C as Installer
participant L as accept loop
participant T as tokio task
participant B as blocking pool
C->>L: TCP connect
L->>L: try_acquire_owned()
alt no permit
L-->>C: 503, close
else
L->>T: spawn(connection)
Note over T: whole-connection timeout starts
C->>T: request headers
Note over T: header_read_timeout
T->>T: /health? token? method? Content-Length?
C->>T: body (capped at 1 MB)
T->>B: spawn_blocking(Facts + resolve)
B->>B: version() → cached listing or snapshot()
B->>B: match · layer · merge · fill · strip
B-->>T: Resolution | None | Err
T-->>C: 200 + document · 404 · 500
T->>T: log one line, capture if enabled
end
1. Accept
serve() loops on listener.accept() inside a tokio::select! with the shutdown signal
(SIGTERM, which DSM’s task scheduler sends, or Ctrl-C).
An accept failure — file-descriptor exhaustion, say — logs and continues. Ending the loop there would turn a transient resource problem into an outage.
A permit is taken from the semaphore before spawning. Without one, shed() writes a
503 and closes — answering honestly rather than dropping silently,
so the client knows to retry rather than guessing.
2. The connection
Two timeouts, and neither is redundant:
| Guard | Covers |
|---|---|
http1::Builder::header_read_timeout | a client that opens a connection and dribbles headers |
tokio::time::timeout around the whole connection | everything after the headers |
hyper has no body-read timeout. Without the second guard, a client that promises a body in
its Content-Length and then sends nothing would park a connection indefinitely — inside
a permit, so it costs a slot as well as memory.
hyper panics if a timeout is set without a timer.
header_read_timeoutrequires.timer(TokioTimer::new()). Omit it and every connection panics at runtime — it does not fail to compile. See traps.
3. Routing
One if on method and path, in this order:
GET /health→200 OK. Before authentication, before anything, so monitoring never goes dark.- The answer token, when
RESCRIPTUM_ANSWER_TOKENis set. Compared without an early return, so a wrong token cannot be recovered a byte at a time by whoever is timing the responses. Logged, never rate-limited. - Method — anything but
GETorPOSTis405. Content-Length— an aberrant declared size is refused from the header, rather than by lettingLimitedtrip after buffering a megabyte.- The body, through
Limited::new(…, MAX_BODY). A length-limit error becomes413, anything else400.
There is no path routing beyond that: POST and GET are answered on any path,
because the URL is baked into an ISO. The path is not ignored — it becomes
facts — it just does not decide whether to answer.
4. Resolution, off the async worker
let picked = tokio::task::spawn_blocking(move || {
let facts = Facts::from_request(Some(&request_path), query.as_deref(), &body);
answers.resolve(&facts)
}).await;
Both halves belong off the async worker: building facts is CPU work on an arbitrary-sized payload, and the lookup is blocking IO. Doing either on a runtime thread stalls every other connection that thread is driving.
Inside, resolve():
- asks the store for its
version()— onestatfor files, an atomic load for SQLite; - reuses the cached
Listing, or takes a freshsnapshot()and rebuilds it; - picks the best machine document and the best group (scoring);
- resolves
extends, within one format; - merges group chain → machine document;
- fills
{{ placeholders }}; - strips the control keys;
- renders.
5. Response
| Outcome | Response |
|---|---|
Ok(Ok(Some(resolution))) | 200, the document, Content-Type from its format, Connection: close |
Ok(Ok(None)) | 404 no answer file applies |
Ok(Err(e)) | 500, with the reason on the log line |
Err(join_error) | 500 answer lookup panicked — it cannot take the server with it, but it must not pass silently either |
Then exactly one log line, and a capture if one is configured. The body was cloned before resolution took it, and only when capturing is on.
The admin listener
A separate TcpListener, a separate serve() task, spawned only when
RESCRIPTUM_ADMIN_ADDR is set — and only after Config::validate has confirmed the store
is SQLite and the token is long enough. Its own pipeline is in
the admin API internals.
Shutdown
SIGTERM or Ctrl-C ends the accept loop and returns from serve(). In-flight connections
are not drained: there is no state to lose, the client retries, and a provisioning server
that refuses to stop is worse than one that drops a request.
Selection internals
Selection internals
src/select.rs and src/facts.rs hold the behaviour that matters. Both are pure logic
over data handed to them, and both are heavily unit-tested — 27 and 22 tests
respectively.
Normalization
pub fn normalize(input: &[u8]) -> String // lowercase ASCII alphanumerics, everything else dropped
It takes bytes, not &str, on purpose: a request body is arbitrary bytes and need not
be valid UTF-8. Filtering to ASCII alphanumerics sidesteps the question entirely — no
validation, no lossy conversion, no failure mode.
This is what makes matching indifferent to separator style and to how Proxmox structures its JSON this version. It is a substring test over bytes, not a schema.
normalize_patternis the other one. Ordinary normalization strips*and?along with the rest of the punctuation, which turns every glob into a literal — quietly. Selector patterns must go throughnormalize_pattern, which keeps them.
Facts
Facts is a map of label → values, plus the haystack. Three sources, layered from most to
least structured:
Query parameters — hand-rolled parsing with percent-decoding, rather than pulling in a
URL crate for twenty lines of work. Values also go into the haystack, so a document
named after a MAC resolves whether that MAC arrived in a POST body or a query string.
Without that, a GET — which has no body at all — could never match by name.
The path contributes three synthesized labels:
| Label | From |
|---|---|
path | the whole path, trimmed of slashes |
file | its last segment |
segment | every segment, as separate values |
file is not decoration: cloud-init’s NoCloud datasource fetches user-data and
meta-data from one URL and skips the datasource entirely if either is missing, so the
same server has to answer them differently. Path segments feed the haystack too, because
NoCloud can expand __dmi.chassis-serial-number__ into the URL.
The JSON body, flattened by flatten() into both full dotted paths and bare leaf
names. Array indices become part of the path but not of the leaf name, so
network_interfaces.0.mac is also reachable as plain mac.
The departure from “do not parse the JSON”
The original rule was that the request body is never parsed as JSON. That has been relaxed, deliberately and narrowly:
if let Ok(value) = serde_json::from_slice::<serde_json::Value>(body) {
flatten(&value, &mut String::new(), &mut facts);
}
Untyped, opportunistic, and non-fatal — a body that is not JSON simply contributes the haystack and nothing more. No struct is derived, so no assumption about Proxmox’s schema is baked into a type.
The leaf-name form is why this exists. Proxmox’s own documentation warns that the
contents of dmi “might vary wildly, depending on the system”. A serial cannot be reached
any other way, because the URL baked into an ISO is the same for every machine. A selector
saying “a field called serial, wherever it lives” survives a reorganisation that a
fixed path would not.
Scoring
const IDENTITY_SCORE: u32 = 1_000;
fn score(control: &Control, identity: &[String], facts: &Facts) -> Option<u32> {
if identity.iter().any(|n| !n.is_empty() && facts.haystack().contains(n)) {
return Some(IDENTITY_SCORE); // naming a machine is as specific as it gets
}
if control.matchers.is_empty() { return None; }
control.matchers.iter()
.all(|(k, p)| facts.matches(k, p))
.then_some(control.matchers.len() as u32)
}
identityis the normalized stem for a machine document, and the normalizedmembersfor a group.- All matchers must hold; the score is how many there are.
IDENTITY_SCOREis 1000 rather thanu32::MAXso that “an identity match beats any selector” stays readable, and a selector with a thousand criteria remains a theoretical problem rather than a subtle one.
Ties break on sorted name, alphabetically first:
.max_by(|(a, ca), (b, cb)| a.cmp(b).then_with(|| cb.id.cmp(&ca.id)))
The reversed inner comparison is what makes max_by prefer the smaller name. matchbox,
the closest prior art, documents that its own resolution between competing groups “will
not be deterministic”. This one is, and a test pins it.
Format filtering
fn wanted(facts: &Facts) -> Option<&'static [&'static str]> // from the `segment` facts
fn acceptable(wanted: Option<&…>, format: &str) -> bool // None ⇒ anything answers
Filtering is on the extension, never the family. .ks and .preseed are both
Kind::Text; filtering by family would let a preseed answer /rhel/ks.
None — a URL naming no alias — constrains nothing, which is what keeps /answer working
for a deployment that only ever serves one format.
The listing cache
struct Cached { version: Version, loaded_at: Instant, listing: Arc<Listing> }
Reused only when the store’s version() is unchanged, is Some, and less than
RELOAD_BACKSTOP (1 s) has passed.
The literal reading of the specification — re-read the directory on every request — is a
readdir plus a sort plus a normalization pass per request. With one answer document per
machine, throughput collapses:
| Documents | Literal re-read | mtime-cached |
|---|---|---|
| 10 | 11,954 req/s | 12,922 req/s |
| 200 | 3,198 req/s | 12,890 req/s |
| 2,000 | 311 req/s | 12,520 req/s |
| 10,000 | — | 6,924 req/s |
One stat replaces the whole walk, and a new machine is still picked up with no restart —
which is the guarantee the specification actually wanted. Normalized identities are computed
once per store read, not once per request.
The backstop is not redundant, and it does more work than it used to. Editing a document’s contents moves no directory mtime; neither does adding one inside a machine’s own directory, since that is one level below the mtime being watched; and a change made by another process moves no in-process atomic. So with a directory per identity, the backstop is what picks up everything except an identity appearing or leaving. Tests cover each case.
The figures above were measured against the flat layout. The read itself is now a
readdirper identity on top of the file it already opened — 28 ms to 63 ms at 2,000 machines — which the cache amortises over a second’s worth of requests, and which did not move throughput measurably. It is still the reason a group beats a directory per machine.
The remaining cost at 10,000 documents is a linear scan of precomputed needles — pure CPU, no syscalls. Bucketing needles by length and sliding a window over the body would remove it, but a 10,000-machine rollout already completes in under two seconds. Measure before adding it.
Building a Listing
build(snapshot) does everything expensive once:
- parse every document, keeping the error rather than failing the load;
- normalize every stem and every
membersentry; - resolve
extendschains, detecting cycles and missing parents — the broken group is dropped rather than half-applied, and the problem is recorded; - pre-merge each group’s chain, and pre-render it as a string when it carries no placeholders.
That last one is why grouping is the fast path: the common datacenter case parses nothing
per request. Group::has_placeholders is the flag that decides it.
problems is collected here, not at request time, which is what lets the admin API’s
rollback guard catch a broken
extends before anyone asks for it.
Resolution
resolve() is a match on (machine, machine_doc, group):
| Case | Behaviour |
|---|---|
| group only | serve the prepared string, or clone-fill-strip-render when templated |
| machine only | fill, strip, render |
| both | group chain, merge the machine on top, fill, strip, render |
| neither | fall back to default for the requested format, which may itself extends a group |
Template variables are the request’s facts plus machine and group, which the facts
cannot carry because they are only known once matching has happened.
machineis bound only when a machine document matched. A machine claimed by a group’smemberswith no document of its own resolves withmachine: None, so{{ machine }}in a group fails for exactly the members it was meant to serve. The templating guide says to use a request fact there instead.
Formats and merging
Formats and merging
src/format/mod.rs gives every document format one interface, so select.rs never has to
know which one it is holding.
enum Inner {
Toml(toml_edit::DocumentMut),
Yaml(serde_yaml_ng::Value),
Json(serde_json::Value),
Xml(xml::Document),
Text(String),
}
Doc wraps it and offers parse, merge, render, control, strip_control,
substitute and has_placeholders. Adding a format means adding a variant and filling in
those seven — nothing above this module changes.
Kind
Kind::for_extension is a deliberate allowlist. txt is not on it, so a stray notes
file next to the answers never becomes a candidate.
Kind is the family; the extension is kept separately, because they are not the same
thing:
Kinddecides how to parse, how to merge, and theContent-Type.- The extension decides whether an endpoint may be answered, and which validator
checkcalls.ksandpreseedare bothKind::Textbut do not share a validator — which is whyResolutioncarriesformat_namealongsideformat.
Filtering on the family instead of the extension would let a preseed answer /rhel/ks.
endpoint_formats
A small alias table mapping a URL segment to the extensions it accepts. Two traps live in it, both already paid for:
- Filter on the extension, not the
Kind— as above. - An alias must be specific enough that nobody reaches it by accident.
seedwas removed:s=http://server/seed/is an ordinary NoCloud seed URL, and it serves YAML.
A segment naming no alias constrains nothing, so /answer keeps working.
Merge rules
| Maps / objects | merge recursively |
| Any other value | replaced outright by the higher layer |
| Arrays | replace, they do not append |
Kind::Text | concatenation in layer order |
Arrays replace because appending would make a list impossible to shorten from a higher layer, and “this node has two disks, not four” has to be expressible. The rule is the same in every format so you never have to remember which one you are in.
merge.rs holds the TOML case, and uses as_table_like so [table] and
{ inline = "table" } merge with each other — a group can use one style and a machine the
other without surprises.
The text case is honest about being concatenation rather than pretending otherwise: whether that amounts to an override is the target format’s business (preseed’s last answer wins; kickstart’s does not always).
The XML tree
format/xml.rs is a small hand-built tree over quick-xml, because none of the
general-purpose crates preserve what an answer document needs preserved.
Pairing. Children are paired by element name plus a discriminating attribute:
const DISCRIMINATORS: [&str; 5] = ["name", "id", "key", "alias", "pass"];
That is what makes <component name="Microsoft-Windows-Shell-Setup"> and
<settings pass="specialize"> mergeable: overriding one pass leaves the others alone.
Repeated siblings are not always a list. Treating them as one replaced every
<component>in an unattend.xml with the one the overlay happened to mention. If they carry a discriminating attribute they are a keyed collection. AutoYaST’sconfig:type="list"is honoured for the genuine list case.
Fidelity. Declarations, doctypes, namespaces and attributes survive a merge. Original indentation and comment placement do not — the output is re-rendered, not patched.
quick-xml emits entity references as their own events. Ignoring them welds the surrounding text fragments together:
1 < 2 & 3came back as123. Numeric entities are resolved; unknown ones are refused rather than silently dropped.
It understands no schema. check calls xmllint where it is installed, and that is the
extent of the guarantee.
Control keys
pub const CONTROL_KEYS: [&str; 3] = ["extends", "members", "match"];
pub const XML_CONTROL_ELEMENT: &str = "answer-meta";
pub const TEXT_DIRECTIVE: &str = "answer:";
They travel in whatever the format allows — native top-level keys in the structured
formats, an <answer-meta> element in XML, # answer: (or // answer:) directives in
text — and strip_control() removes all of them before the answer is sent.
Control is the parsed form: extends: Option<String>, members: Vec<String>,
matchers: BTreeMap<String, String>.
Templating
Two rules, both load-bearing:
Substitution happens on parsed string values, never on raw document text. The value
goes into the document’s own data model and the format’s serializer writes it out, so the
serializer does the escaping. A value containing a quote cannot break the TOML it lands
in; one containing < cannot break the XML. A test feeds a"b'c<d>e&f into all four
structured formats and reparses the output.
A missing fact is an error, never an empty string. Serving node-.example.com
installs a machine with a broken hostname and nobody notices until later. Control
characters are refused for the same class of reason — a newline in a kickstart value
injects a directive into a file the installer executes.
Group::has_placeholders is why a group with no template costs no parsing per request:
the string prepared at load is served as-is.
The worked examples are part of the design
examples/ carries a commented
example of all thirteen extensions the allowlist names, and
RESCRIPTUM_ANSWERS_DIR=examples cargo run -- check
exercises them all. Keep it that way. They are the only place the formats are shown
composing together, and two of them — suse-node.autoyast and windows-node.unattend —
are what caught the missing doctype and the unpaired pass.
The stores
The stores
Answers come from either a directory of documents (RESCRIPTUM_STORE=files, the default)
or a SQLite database (RESCRIPTUM_STORE=sqlite), chosen at runtime.
The trait is deliberately thin
pub trait Store: Send + Sync {
fn version(&self) -> Version; // Option<String>, cheap per request
fn snapshot(&self) -> io::Result<Snapshot>; // only when version moved
fn describe(&self) -> String;
}
A Snapshot is raw document text and nothing else: RawMachine, RawGroup,
RawDefault, each carrying an identifier, a format and a body.
Every decision lives above this. Matching, extends chains, merging, rendering,
check — all in select.rs and merge.rs, shared. Keep it that way: the moment a
backend starts deciding behaviour, the two drift and the conformance suite stops being
able to prove they have not.
The write half is separate, because serving answers never needs it:
pub trait StoreWrite: Store {
fn put_machine(&self, id: &str, format: &str, body: &str) -> io::Result<()>;
fn delete_machine(&self, id: &str, format: &str) -> io::Result<bool>;
fn put_group(&self, name: &str, format: &str, body: &str) -> io::Result<()>;
fn delete_group(&self, name: &str, format: &str) -> io::Result<bool>;
fn put_default(&self, format: &str, body: &str) -> io::Result<()>;
fn delete_default(&self, format: &str) -> io::Result<bool>;
}
Every operation names a format. A document is keyed by what it is for — a machine and an operating system.
An earlier
putdeleted the other formats of a stem, to avoid “two answers for one machine”. That was the wrong model: they are that machine’s answers for two operating systems, and both are meant to exist. See traps.
tests/stores.rs is the guarantee
Every behavioural case runs twice, once per store, and asserts the identical outcome. 35 cases at last count.
A new behaviour belongs there, not in a store-specific test. A test that covers one backend proves half of what it claims.
The file store
One directory per identity. A machine is a directory named after it, holding one
document per format; groups/ holds the same shape for groups, and default/ the
fallbacks. Both names are reserved, so a machine cannot claim them — valid_machine_id
refuses them in both stores, because a database that accepted one would export into a
directory that cannot hold it.
Inside a directory, the extension is the format and the stem is nothing at all. That is
the rule that makes two documents of one format in one directory a reported problem rather
than a resolved one: there is no tiebreak an operator could have predicted. Sorted order
decides which of the two answers, so the choice at least does not depend on readdir — and
the loser is named in problems().
A servable document left at the top of the answers directory — the layout that came before —
is reported and not served, with its destination spelled out. Half-reading an old layout
would mean a machine whose answer moved silently between two files. pending_moves() is the
same knowledge exposed for migrate, so the command and the reader cannot disagree about
where a document belongs.
version() is the directory’s mtime:
fs::metadata(&self.dir).ok()
.and_then(|m| m.modified().ok())
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|d| d.as_nanos().to_string())
One stat replaces a whole directory walk — see
the listing cache.
The directory’s mtime moves when an entry is added or removed in it, not when one is edited, and not when something changes one level down. So a machine’s whole directory appearing or leaving is seen at once, while a document added or edited inside one waits for the 1-second reload backstop — which is what already covered a file edited in place. A unit test pins each half.
What the layout costs on a read. A full reload is now a
readdirper identity on top of the file it already opened. Measured at 2,000 machines on an M1 Pro: 28 ms flat, 63 ms with a directory each. It is amortised over a second’s worth of requests, and end-to-end throughput did not move measurably — but it is a real 2.2× on the one operation the backstop guarantees will run every second, and it is the reason to reach for a group before a directory per machine.
Writes go through a temporary file plus rename, which is atomic within a directory
on POSIX, so a reader never meets a half-written answer. The temporary name carries the
process id, and is removed if the rename fails. A test asserts no .tmp file survives.
Reading is DirEntry::file_type(), not fs::metadata. The file type comes back free
with the readdir on Unix; only a symlink needs the stat to resolve. That alone was worth
65% at 2,000 files, before caching was added.
The SQLite store
rusqlite with the bundled feature — SQLite is compiled from source into the binary, so
there is nothing to install. It cross-compiles to armv7-musl under zigbuild; CI builds
that target on every push precisely because a C dependency breaks there first.
WAL mode, so the admin API never stalls an install in progress.
version() reads an in-process atomic, not the database:
Some(self.revision.load(Ordering::Relaxed).to_string())
It is called per request, and a query per request would defeat the point of caching. The consequence is that a change made by another process does not move it — the reload backstop is what catches that.
Schema versions live in PRAGMA user_version. There is one, and nothing has been
released under an older one, so migrate() has no steps: it refuses a database from the
future, creates the schema when the version is 0, and stamps it. The shapes this went
through while it was being written never left the repository, and carrying migrations from
them would be carrying code that cannot run.
What the version is for is the rollback direction:
database schema is version 2, this binary understands 1
Refused rather than guessed at, because a database written by a newer binary may hold columns this one would silently ignore — and silently ignoring part of an answer set is how a machine gets installed wrongly.
import / export
$ rescriptum import <dir> # directory → the configured store
$ rescriptum export <dir> # the configured store → a directory
Both go through Snapshot, so they share every rule. The round trip is byte-identical
— import a directory, export it again, diff -r reports nothing, paths included. A test
compares both sides at the same path for exactly that reason: export writing a document
somewhere import would not look for it is what would make the database unsafe to leave. That is what makes the
database safe to adopt and safe to leave, and it is worth keeping true.
Identifiers become directory names
pub fn valid_id(id: &str) -> bool // letters, digits, - _ . : and no separators
pub fn valid_machine_id(id: &str) -> bool // …and not `groups` or `default`
Enforced at the admin API boundary and in both stores. The store is the layer that turns an identifier into a path, so it is the layer that must not be fooled — checking only at the boundary would make the guard depend on every future caller remembering.
valid_format is the equivalent for extensions: a document in a format nobody can read
never reaches the store in the first place.
The sqlite cargo feature
On by default, and removable:
| Build | ARMv7 size |
|---|---|
| default | 2,103,456 bytes |
--no-default-features | 944,928 bytes |
Dropping it also drops the admin API, which needs the database. CI builds
--release --no-default-features on every push so the smallest build cannot rot
unnoticed.
Admin API internals
Admin API internals
src/admin.rs, enabled only by RESCRIPTUM_ADMIN_ADDR, and only over SQLite. Three
properties are load-bearing — a change that quietly drops any of them is a regression.
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.
Config::validate refuses to start — as an error, not a warning — without a token, with a
token under 16 characters, or over the file store. Those checks run before the listener
is bound, so a misconfiguration is never briefly live.
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. The write that cannot break the fleet
fn guarded(admin, kind, id, format, body) -> Response<Body> {
let before = admin.answers.problems()?; // snapshot the damage
let previous = admin.store.snapshot()?…; // what was there, so it can be restored
let existed = apply(…)?; // put or delete
let after = admin.answers.problems();
let introduced = after.filter(|p| !before.contains(p));
if !introduced.is_empty() { restore(previous); return 409 }
200 with `problems: before`
}
- Only newly introduced problems roll back. A store that was already broken stays editable — otherwise a bad state would be unfixable through the API that caused it.
- A successful write still reports the pre-existing problems, so a clean response never implies the whole set is healthy.
- This is why a machine’s
extendspointing at a missing group is detected at load time inselect.rsrather than only when that machine asks. The guard can only catch whatproblems()reports. Adding a new class of breakage means adding it there, or the guard silently stops covering it.
Malformed documents are refused at write time (400) rather than becoming a 500 the
next time a machine asks for one.
Authentication
Constant-time comparison. An ordinary == returns early on the first differing byte,
which leaks the token one byte at a time to anyone timing the responses — a few thousand
requests instead of 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.
AuthGuard shuts out an address after repeated failures:
| Constant | Value |
|---|---|
MAX_FAILURES | 5 |
FAILURE_WINDOW | 60 s |
BASE_BLOCK | 60 s, doubling on repeats |
MAX_BLOCK | 900 s |
MAX_TRACKED | 4096 addresses |
Three details that are not accidents:
- The block applies to a correct token too. Otherwise guessing until you got it right would cost nothing.
MAX_TRACKEDis bounded, so the guard cannot itself be turned into a memory leak by an attacker cycling source addresses.GET /healthis checked before the guard and before auth, so monitoring does not go dark during an attack.
A block answers 429 with Retry-After.
Request handling
let segments: Vec<&str> = path.trim_matches('/').split('/').collect();
match (&method, segments.as_slice()) {
(&Method::GET, ["machines"]) => list(…),
(&Method::GET, ["resolve", id]) => resolve(…),
(&Method::PUT, ["groups", id]) => put(…).await,
…
_ => error(NOT_FOUND, "no such endpoint"),
}
?format= selects the document’s extension, defaulting to toml — which is what this
server started life serving.
Read the request body before rejecting a request. Answering and closing while the client is still writing earns a
ECONNRESETinstead of the response.put()drains first, then validates the identifier.
Every admin response must set
Connection: close. Without it, every test client waited out the connection timeout — the suite took 30 s instead of 0.4 s — and the eventual drop sometimes arrived as a reset rather than a clean EOF.
GET /resolve sets X-Answer-Source with the same description the log line uses.
GET /resolve/{id}ignores the path identifier when a query string is present — facts come from the query alone, so it can rehearse a real request. That makes?format=tomlon that endpoint actively wrong: it resolves nothing. Documented in the guide.
Identifiers
valid_id — letters, digits, - _ . :, no path separators — is enforced at the API
boundary and in both stores. export turns identifiers back into filenames, so
anything that could traverse a directory has to be rejected in the layer that builds the
path, not only in the layer that received it.
Known and accepted
- Per-address limiting does not stop an attacker with many addresses. The token’s length is what makes guessing hopeless — hence the 16-character floor at startup.
- It speaks plain HTTP. Put TLS in front if it leaves loopback.
- Binding beyond loopback logs a warning rather than refusing, because a management network is a legitimate choice.
Tests
tests/admin.rs (15 cases) covers routing, the rollback, identifier validation and the
status codes. tests/guards.rs (5) covers the lockout arithmetic and that /health stays
reachable through it.
Testing
Testing
619 tests. cargo test runs all of them in about twenty seconds — most of that is
tests/tftp.rs, which waits on real UDP timeouts because that is what it is testing.
cargo test does not run the harnesses that matter most: the boot rig, the DSM
package’s three, and the loader build. See The package is tested too
and the boot rig.
cargo test # everything
cargo test <name> # one, by substring
cargo test -- --nocapture # show stdout
cargo test --all-features # what CI runs
Where a test belongs
| Suite | Cases | For |
|---|---|---|
tests/integration.rs | 52 | the real binary over a real socket |
tests/cli.rs | 65 | render, check, import, export, config and the env file — against the real binary |
tests/media.rs | 45 | boot media against the real binary, with both listeners up |
src/config.rs | 56 | the environment, what refuses to start, and which of the file and the environment wins |
tests/stores.rs | 48 | every behaviour, against both stores |
tests/tftp.rs | 30 | TFTP over real UDP: the turn-taking, and what a failed bind must not cost |
src/select.rs | 29 | normalization, scoring, layering, template filling |
src/format/mod.rs | 28 | parsing, merging, control keys, endpoint aliases |
tests/admin.rs | 26 | the admin API end to end, formats included |
src/envfile.rs | 23 | the env-file parser and writer, and what each refuses |
src/facts.rs | 22 | query parsing, JSON flattening, globbing |
src/format/xml.rs | 18 | the XML tree — pairing, entities, fidelity |
src/merge.rs | 11 | the TOML deep merge |
tests/guards.rs | 7 | the answer token, and the lockout that deliberately is not there |
src/installed.rs | 6 | a machine reporting it installed, and what must never be disarmed |
src/log.rs | 15 | level parsing, and the timestamp arithmetic |
src/boot/*.rs | 128 | the ISO reader, probing, the catalogue, image sources, patch plans, the menu, the loader table, DHCP snippets, cpio and SHA-256 |
src/admin.rs, src/capture.rs, src/store/mod.rs | 21 | unit-level behaviour |
tests/common/mod.rs — the fixtures every suite shares
Answers are stored as a directory per identity, so a fixture cannot just be a filename
any more. seed() takes the name a test thinks in — 98fa9b50d810.toml,
groups/rack-a.toml, default.toml — and writes it through StoreWrite, so it lands
exactly where a write from the admin API would and cannot drift from the layout. A name the
store would refuse (an extension nobody serves) is written literally instead, because those
fixtures exist precisely to prove that a stray file answers nothing.
One copy, not one per suite — the same reasoning that makes loaders.rs a single table
read by both TFTP and the DHCP snippet. Four copies of a mapping are four chances for a
fixture to land somewhere the server does not look, and a test that seeds nothing passes
for the wrong reason.
tests/stores.rs — the conformance suite
Every behavioural case runs twice, once per store, and asserts the identical outcome. That suite is what keeps two backends from drifting.
A new behaviour belongs there, not in a store-specific test. A test that covers one backend proves half of what it claims — and the half it does not cover is exactly where a divergence hides.
tests/cli.rs — the commands people are told to run
check is what deploy.sh runs before it ships
anything, and what the documentation tells people to put in CI — so its exit code is a
contract, not a convenience. render’s stdout/stderr split is another: the document
goes to stdout so render … > answer.toml yields a usable file, and the provenance line
goes to stderr so it does not end up inside it.
Also pinned here: the import → export round trip is byte-identical, comments and
formatting included. That is what makes the database safe to adopt and safe to leave;
if it ever stops being exact, export is no longer a way back out.
tests/integration.rs — against the real binary
It starts the actual binary on an ephemeral port and talks HTTP to it. The binary prints the address it bound, so there is no port race and no sleep-and-hope.
This suite exists because some failures are invisible to unit tests. The clearest example:
hyper panics at runtime if header_read_timeout is set without .timer(…). It
compiles. Only a real connection finds it.
Explicitly covered:
- a truncated request, and one with no
Content-Length; - an aberrant
Content-Length— and a chunked body that outgrows the cap while streaming, which is the other way in and trips the limit mid-read; - an unknown method, an empty body, a 1 MB body, a body that is not valid UTF-8;
- the connection cap: over it, a prompt
503rather than a queue — and the permit coming back afterwards; - and, after each of those, that the server still answers. That last assertion is the one that matters — the abuse is only interesting if the server survives it.
cargo testdoes not rebuildtarget/debug/rescriptum. A manual check against a stale binary once “reproduced” a bug that had already been fixed. Rebuild before poking at the binary by hand.
tests/tftp.rs — a transfer is a conversation
Nothing here can be proved from inside a function. Blocks, acknowledgements, retransmission, the empty packet that ends a transfer — every bug worth catching lives in the turn-taking, and the first run found two of the “works by hand, never after a reboot” kind. A file whose length is an exact multiple of the block size must end with an empty data packet; leave it out and the client waits forever for a final block that never comes.
It also owns the one listener failure in this server that is not fatal. A TFTP port that
cannot be bound must not take answers and media down with it — measured on DSM, where the
capability is granted outside the package and an upgrade drops it — so the test holds the
port with a squatter, then asserts three things at once: the server came up, it warned and
said what still works, and boot check still exits non-zero.
That last one first passed for the wrong reason: three missing loaders were already failing the command. The fixture now writes every loader the table names, and a control run with TFTP off proves the directory is otherwise clean.
tests/media.rs — boot media against the real binary
Both listeners up, and every abuse case ends by proving the server still answers. One case proves the property the separate socket exists for: answers keep succeeding while four image transfers are in flight.
There is deliberately no binary ISO fixture in this repository. boot::iso::build
writes images in memory, behind the test-support feature so it never reaches a release
binary.
The boot chain belongs in the rig
packaging/boot-rig/run.sh is not Rust and cargo test does not run it. It boots a
claimed and an unclaimed machine in QEMU under TCG, on a private bridge with no uplink,
and asserts four markers: the DHCP handoff answered from our own generated snippet, a
loader fetched over TFTP, the unclaimed machine fell through to its local disk, and the
claimed machine reached its own answer. CI runs the same thing plus a deliberate break.
A QEMU guest bridged into a container has a MAC of its own, and Docker Desktop’s virtual switch does not forward frames from a MAC it did not assign — measured, which is why the primary rig is one container rather than four on a Docker network.
Check that a test can fail
A test that passes for the wrong reason is worse than no test: it reports coverage that does not exist. Before trusting a new one, break the thing it guards and watch it go red.
One in this suite did not survive that check. It claimed to protect the version.is_some()
clause in the listing cache; removing the clause left it green, because with either store a
version is unreadable only when the store is also empty, so the clause cannot currently
fire at all. The test proves something real — a directory that appears after startup is
served on the very next request — and now says so instead.
Assertions worth copying
- Assert on parsed values, not on formatting. Replacing a table with a scalar leaves
the key’s original decor, so the output can read
value= 3— valid TOML, different text. A string comparison there fails for the wrong reason, or passes for one. - Cache-invalidation tests must share one
Answersinstance. A test that constructs a fresh one per call bypasses the cache entirely and silently proves nothing. Config::from_lookuptakes a closure, so configuration tests never touch the process environment — and therefore never race each other under a parallel test runner.- Assert the old text was found before writing. Two
python/sedpatches in this project’s history silently matched nothing and were only caught by checking test counts afterwards.
The example answers are a test too
RESCRIPTUM_ANSWERS_DIR=examples cargo run -- check
examples/ holds a worked example
of every format, and it is the only place they are shown composing together. Two of them
caught real bugs — a missing doctype and an unpaired pass attribute. Keep them working.
The package is tested too, in three places
cargo test does not touch the DSM package, because none of it is Rust. Three harnesses
do, and each proves something the others cannot.
| Proves | Cost | |
|---|---|---|
packaging/dsm/check-spk.sh | the archive is structurally what DSM expects — uncompressed outer tar, six INFO fields, an all-numeric version, os_min_ver at least 7.1, 64×64 and 256×256 icons, executable scripts with no CRLF, the packaged binary’s own --version, and the desktop application: dsmappname naming a class its ui/config actually declares, a JavaScript filename that carries the version, and a backend that still checks the DSM session and administrators | seconds, on every push |
packaging/dsm/lifecycle-test.sh | everything the package’s scripts decide, against a fake /var/packages tree: the env file written once and only once, the wizard’s values and their absence, the service surviving its own start script and answering /health, the exit codes Package Center reads, an upgrade that must not touch a hand-edited configuration, an uninstall that must not touch the answers — and the desktop application’s backend, driven with a stubbed authenticator: refusing no session, refusing a non-administrator, refusing a write with no intent header, refusing one that would stop the server starting, and never handing a token to the browser | seconds, on every push |
packaging/dsm/vm/on-dsm.sh | DSM’s own machinery — the data-share worker and its ACL, the port-config worker, the generated systemd unit, logrotate against a live descriptor, whether Package Center accepts the archive — and that a machine asking for its configuration gets one: a POST with hardware in the body, answered by that machine’s file merged over the group claiming it. It also owns the only route to port 69 and whether this NAS can reach a vendor’s image index: that 69/udp survives into the acquired firewall entry, that the package still answers without the capability, and that setcap cap_net_bind_service=+ep plus a restart binds udp/69 as the unprivileged package process | minutes, on a DSM 7 VM — and then on the DS416j |
packaging/dsm/lifecycle-test.sh # the first .spk in dist/ that runs here
docker compose -f packaging/dsm/vm/docker-compose.yml up -d # a DSM 7.2 machine
packaging/dsm/vm/on-dsm.sh admin@<host> -p 2222 # against it
packaging/dsm/vm/on-dsm.sh admin@nas # the verdict
The VM is vdsm/virtual-dsm, which installs Synology’s own Virtual DSM release — no loader
image to find. KVM makes it fast rather than possible: without /dev/kvm it emulates, about
ten times slower, which is what docker-compose.emulated.yml is for. It does want 14 GiB
free for the storage, hardcoded in the image.
The last one is destructive on purpose — it upgrades over a hand-edited env file and a
canary in the shared folder, then uninstalls, then checks both survived. Those two guards
are the most expensive things in the package to get wrong, and the first published .spk
is the one whose uninstall scripts will run during everybody’s first upgrade.
packaging/dsm/vm/README.md
is the rig: what it is evidence about, and what it is not.
The same rule as everywhere else applies to these: break the thing they guard and watch
them go red. Reverting the postinst upgrade guard, making postuninst delete the
share, returning 1 for a stopped package and refusing prestart turns 33 green checks
into 25 green and 8 red — which is how we know the harness is testing anything at all.
Today it is 85 checks in lifecycle-test.sh, 28 in check-spk.sh and 52 on
the machine; the three most recently added were each watched red the same way — by putting
RESCRIPTUM_TFTP_ADDR=off back, by deleting the panel’s report of the TFTP state, and by
making it claim to be serving with nothing bound.
CI
.github/workflows/ci.yml, on every push to main and develop and on every pull
request:
| Job | Runs |
|---|---|
| gates | cargo fmt --all --check, cargo clippy --all-targets --all-features -D warnings, cargo test --all-features, cargo build --release --no-default-features |
| docs | builds the public site and runs notabene lint |
| audit | cargo audit --deny warnings over the dependency tree |
| cross | a full ARMv7 build against the glibc floor DSM has, asserting it needs nothing newer, then assembles both .spks, checks them structurally and drives the package lifecycle |
The cross job is not redundant. SQLite is compiled from source, and armv7-musl is the
least forgiving target shipped — it is where a C dependency breaks first. Catching that
on a push beats catching it while cutting a release.
The audit job is the other half of the rule that adding a dependency needs a reason: a
reason to add one is not a reason to keep it. --deny warnings fails on an unmaintained or
yanked crate too, not only on a vulnerability. When something appears with no fix, add
--ignore RUSTSEC-… with a line saying why rather than dropping the flag.
Every action used is an official actions/* one, and both Zig and cargo-audit are
installed directly rather than through a third-party action. That is deliberate: this
toolchain vets and links a binary people run as root.
The docs site has its own gate — see the docs site.
Building
Building
./build.sh # this machine, and print the size
./build.sh --all # every target a release ships
./build.sh --no-sqlite # the smallest binary
./build.sh armv7-unknown-linux-gnueabihf
./build.sh --help
build.sh adds a missing Rust target for you and warns if a musl build came out
dynamically linked — which DSM would refuse to run, at exec time on the NAS rather than
at build time on your laptop.
Plain cargo build works too; build.sh exists for the size report and that warning.
The release targets
| Target | For | Cross |
|---|---|---|
armv7-unknown-linux-gnueabihf | the DS416j, the reason this project exists — glibc, not musl, see below | zigbuild, floor 2.17 |
aarch64-unknown-linux-musl | modern ARM NAS, Raspberry Pi | zigbuild |
x86_64-unknown-linux-musl | most other Linux hosts | zigbuild |
aarch64-apple-darwin | local development | native |
x86_64-apple-darwin | local development | native |
Cross-compiling
cargo-zigbuild uses Zig as the linker,
which avoids a full cross toolchain per target:
cargo install cargo-zigbuild
cargo zigbuild --release --target armv7-unknown-linux-gnueabihf.2.17
Why armv7 is the one target that is not musl
Every other target is static musl. ARMv7 is glibc, and it is not a preference — it is the only way the machine this project exists for runs the binary at all.
Synology’s ARMv7 kernels are 3.10, and they answer the time64 syscalls with EINVAL
rather than ENOSYS. musl 1.2 made time_t 64-bit on 32-bit architectures and tries
clock_gettime64 (and clock_nanosleep, and the timed futex) first, falling back to the
32-bit syscall only on ENOSYS. On a kernel that says EINVAL the fallback never
happens, so every call for the time fails. Measured on a DS416j running DSM 7.1, kernel
3.10.108:
$ ./probe
libc clock_gettime(CLOCK_REALTIME) -> -1 errno=22 (Invalid argument)
syscall 263 (time32) -> 0 ok
syscall 403 (time64) -> -1 errno=22 (Invalid argument)
The symptom is a binary that answers --version and then panics the moment it wants a
timestamp — time.rs:131, Os { code: 22, kind: InvalidInput }. It is not an ABI problem
and not a kernel-too-old-for-the-instructions problem, which is what it looks like.
glibc on 32-bit uses the time32 syscalls, and DSM ships its own (2.20 on armada38x). So
the armv7 build targets a glibc floor of 2.17 — low enough for DSM, and since glibc is
backward compatible, the same binary runs on newer ARMv7 Linux as well.
What to verify, then, is not that it is static — it is that it needs no glibc newer than the floor. Anything newer fails at exec time on the NAS, naming a symbol version and nothing else:
$ readelf --dyn-syms target/armv7-unknown-linux-gnueabihf/release/rescriptum \
| grep -o 'GLIBC_[0-9.]*' | sort -uV | tail -1
GLIBC_2.17
CI asserts exactly that on every push. The musl targets are still checked for being static, because for them that is the promise.
Installing Zig on the maintainer’s machine
Zig is not a Homebrew install here: brew install aborts on that machine over
untrusted third-party taps unrelated to Zig. It lives in ~/.local/zig, symlinked at
~/.local/bin/zig. To upgrade, replace that directory — brew upgrade zig does
nothing.
Verified toolchain: Rust 1.93, cargo-zigbuild 0.23.0, Zig 0.16.0, with targets
aarch64-apple-darwin and armv7-unknown-linux-gnueabihf installed.
The release profile
[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
strip = true
panic = "abort" is deliberately absent — see
constraints. Measured cost of keeping
unwinding on ARMv7: +2416 bytes, +0.8%.
Size
| Build | ARMv7 |
|---|---|
| default | 2,103,456 bytes |
--no-default-features (no SQLite, no admin API) | 944,928 bytes |
Most of the difference is bundled SQLite, compiled from source. CI builds
--release --no-default-features on every push so the small build cannot rot unnoticed.
Features
| Feature | Default | Gives |
|---|---|---|
sqlite | on | the SQLite store and the admin API |
cargo build --no-default-features # smallest
cargo test --all-features # what CI runs
The Synology package
A .spk is a release format, not a build: the binary is finished before packaging
begins, there is no DSM-specific build, and nothing in src/ knows Synology exists.
./build.sh --spk x86_64-unknown-linux-musl # build, then wrap it
packaging/dsm/make-spk.sh armv7 # wrap a build that already exists
packaging/dsm/check-spk.sh # structural check over dist/*.spk
The package carries the loaders, so build them first or it will not pass its own
check. make-spk.sh takes them from packaging/ipxe/out (override with
RESCRIPTUM_LOADERS), and check-spk.sh fails a package that has none — a TFTP server
with nothing to hand out boots nothing. Building iPXE needs a Linux C toolchain, which on
a Mac means a container:
docker run --rm --platform linux/amd64 -v "$PWD:/w" -w /w debian:bookworm-slim sh -c '
apt-get update -qq &&
apt-get install -y --no-install-recommends build-essential liblzma-dev mtools \
xorriso isolinux gcc-aarch64-linux-gnu git ca-certificates perl &&
packaging/ipxe/build.sh --out /w/packaging/ipxe/out'
Once, not per package: the loaders are the same bytes in every ABI’s .spk, because they
run on the machines being booted, not on the NAS. packaging/ipxe/out is gitignored —
no binaries in git, ever.
| ABI | arch in INFO | From |
|---|---|---|
x86_64 | x86_64 — the family name, so it covers every Intel platform | x86_64-unknown-linux-musl |
armv7 | armada38x — the family shorthand does not reach the Marvell platforms | armv7-unknown-linux-gnueabihf |
aarch64 | armv8 | aarch64-unknown-linux-musl, once the binary has been run on one |
The rule for widening that: claim an ABI once the binary has run on the oldest-kernel member of it, never because a platform is plausible.
make-spk.sh is deterministic — fixed mtimes, ownership 0:0, ustar, gzip -n, a
pre-sorted file list — so the same inputs give a byte-identical .spk, which is what makes
the published checksum worth something.
check-spk.sh runs in CI on every push. It asserts the outer archive is an uncompressed
tar, that INFO has its six required fields and an all-numeric version, that the icons are
exactly 64×64 and 256×256, that the lifecycle scripts parse and are executable, and that
the packaged binary’s own --version matches INFO — the x86_64 build runs on the
runner, so that last one is a real assertion rather than a re-read of the same string.
lifecycle-test.sh then drives the package’s own scripts against a fake /var/packages
tree — install, start, /health, the exit codes, an upgrade over a hand-edited
configuration, an uninstall over a canary in the share — and also runs on every push.
packaging/dsm/lifecycle-test.sh
What none of that can prove is that DSM will accept the package; only installing it can.
That is the rig in
packaging/dsm/vm/:
a QEMU launcher, and one script that runs the on-machine checks against the VM while you
iterate and against the DS416j for the verdict. See
testing.
Deploying a build
./deploy.sh admin@nas
./deploy.sh admin@nas /volume1/netboot
Builds, checks the answers and refuses to ship if they do not come back clean, copies
under a temporary name, restarts, and confirms /health. See
deployment.
| Environment | Default |
|---|---|
TARGET | armv7-unknown-linux-gnueabihf |
ANSWERS | <remote-dir>/answers |
PORT | 8000 |
Branching and releases
Branching and releases
The model mirrors the sibling project notabene deliberately — same maintainer, same
expectations.
Branches
| Branch | Rule |
|---|---|
main | stable. Only release commits and vX.Y.Z tags land here. Never push feature work directly |
develop | integration. Kept at the in-progress next version |
feature/<name>, fix/<name> | branch from develop, PR back into develop |
main ──●────────────────────────●─(tag vX.Y.Z)──▶ releases
\ /
develop ●───●───●───●───●────● ────────────────▶ CI gates only, publishes nothing
\ / \ /
feature/… ● fix/… ● (PRs into develop)
develop publishes nothing. It runs the gates — build, tests, clippy, fmt — and stops
there. No prereleases, no artifacts. Binaries are produced only by a vX.Y.Z tag on
main.
That is the one thing that does not carry over from notabene, which is an npm package
and publishes prereleases to a @dev dist-tag. This project ships a compiled binary, so
the release artifact is a GitHub Release with cross-compiled binaries attached, built by a
CI matrix.
Commits
Conventional commits with a scope:
feat(http): answer GET as well as POST
fix(select): normalize member strings before comparing
chore: release v0.2.0
Keep PRs focused. Adding a dependency needs a reason in the commit message — this binary runs as root on other people’s hardware.
Cutting a release
# on develop, with everything green
$EDITOR Cargo.toml # bump version
cargo build # refresh Cargo.lock
git commit -am "chore: release vX.Y.Z"
git checkout main && git merge --no-ff develop
git tag -a vX.Y.Z -m "rescriptum vX.Y.Z"
git push origin main --follow-tags
.github/workflows/release.yml then:
- Refuses the tag if it disagrees with
Cargo.toml. A release whose binary reports a different version than its tag is a support problem that outlives the release. - Cross-compiles the five published targets.
- Packages each as
rescriptum-<version>-<target>.tar.gz, withREADME.mdandLICENSEalongside the binary, plus a SHA-256 sum — whoever runs this as root should be able to check what they downloaded. - Builds the branded iPXE loaders from the pinned commit and attaches them as
rescriptum-boot-assets-<version>.tar.gz, after askingboot checkwhether the directory satisfies the loader table the server hands out from. Without this the release is incomplete and quietly so: a deployment gets a TFTP server with nothing to hand out, and every machine the generated DHCP snippet sends there asks for a file, gets nothing, and stops. They are their own download, never part of a binary archive or an.spk— they are iPXE, GPLv2, and separate files served alongside is mere aggregation, withpackaging/ipxe/as the written offer. - Wraps the Linux musl builds as Synology packages,
rescriptum-<version>-<build>-<abi>.spk, and checks each structurally before it can be published. - Cuts the GitHub Release with
ghand--generate-notes, or uploads into it if it already exists.
It is re-runnable by hand through workflow_dispatch with a tag, for when a job fails
after the tag is already pushed.
A packaging-only fix needs no tag. SPK versions are all-numeric segments and the last
one is a package build number, so v0.1.0 produces 0.1.0-1; dispatching by hand with
spk_build: 2 attaches rescriptum-0.1.0-2-<abi>.spk to the same Release. A prerelease
does not produce an .spk at all — the archives are the prerelease channel.
A tag must not be the first time an .spk is installed on a DSM machine. The
structural check catches a broken archive; only Package Center catches a broken package,
and the first published one is the one whose uninstall scripts will run during everybody’s
first upgrade. The checklist is in
packaging/dsm/README.md.
Every action used is an official actions/* one, and gh is already on the runner. That
is deliberate for the same reason as everything else in this file.
Versioning
SemVer. The tag is vX.Y.Z and must match Cargo.toml exactly.
Answer documents are data, not state: nothing migrates, and a new binary reads the same
directory. The exception is the SQLite schema, which carries a user_version — see
stores. There is one version so far. Adding a second means
writing the migration step and a minor bump at least, and the release notes have to say
so, because an older binary will refuse the upgraded database rather than half-read it.
Documentation
The documentation site is published from main, so a docs change
ships with the next release — or by running the docs workflow by hand
(workflow_dispatch) when it should not wait.
Traps already hit
Traps already hit
Each of these cost real time. None is obvious from the code alone.
Runtime, not compile time
hyper panics if a timeout is set without a timer. http1::Builder::header_read_timeout
requires .timer(TokioTimer::new()). Omit it and every connection panics at runtime —
it does not fail to compile. The integration tests caught this; unit tests could not have.
header_read_timeout stops at the end of the headers. hyper has no body-read timeout,
so a client that promises a body and sends nothing would park a connection indefinitely.
The whole-connection tokio::time::timeout in connection() is what covers that. Both
are needed; neither is redundant.
hyper emits header names lowercased. That is correct — they are case-insensitive — so
assert on a lowercased copy. See has_header in the integration tests.
Performance
fs::metadata per directory entry is a stat syscall each. DirEntry::file_type()
comes back free with the readdir on Unix; only a symlink needs the stat to resolve. That
alone was worth 65% at 2,000 files, before caching was added.
Editing a group file’s contents changes no directory mtime. Only RELOAD_BACKSTOP
(1 s) picks that up, which is why the backstop is not redundant with the mtime check. An
integration test covers it.
An aberrant Content-Length must be refused from the header, not by letting Limited
trip after buffering a megabyte.
Closing on a peer that is still writing discards the response you just wrote. The
kernel sends a reset, and the reset throws away the unread bytes — so the client sees a
dropped connection, not your answer. shed() had exactly this: it wrote its 503 and
closed immediately, so the installer it was trying to tell “retry” got a connection
reset instead. It now drains briefly first, the way the admin API’s put() already did.
A test at the connection cap pins it.
- macOS lets an unprivileged process bind UDP port 69; Linux does not. So a test that
reaches the default TFTP address takes a different branch on each platform —
boot checkcalls an obtainable-but-silent port a note and an unbindable one a problem, which is the right rule and exactly what makes the test platform-dependent. It passed locally and failed in CI for a reason that had nothing to do with the change. Any test that setsRESCRIPTUM_BOOT_DIRmust also setRESCRIPTUM_TFTP_ADDR=offunless the probe is the subject;tests/tftp.rscovers the unbindable port on a high one. - A branch developed entirely offline has never met the CI. This one accumulated 57 commits before its first push, and the first run failed on two things no local run could see: a clippy five versions newer than the pinned local toolchain, and a Linux-only port permission. Push early enough to find out, or expect to.
Selection and formats
A Mac editing the answers directory over SMB can hijack a machine’s answer. macOS writes
an AppleDouble ._<name> beside a file whose extended attributes the filesystem will not
take — ._proxmox.toml has an extension that is on the allowlist. With a directory per
identity it is worse than it was when answers were flat: it is a second .toml in a
directory that may hold only one, and it sorts before the real one, so a rule that took
the first would hand every request a binary body. The machine being configured then receives
a parse error instead of its answer. .DS_Store is harmless only by luck (its extension is
not on the list). The file store skips every entry whose name starts with .; found on a
real NAS, not by reading anything.
Normalizing a selector pattern strips * and ? unless you use normalize_pattern —
which turns every glob into a literal, quietly.
In a text format, a placeholder inside a comment is still a placeholder. Kind::Text
is an opaque string, so substitution runs over the whole document — a {{ mac }} written
in a # comment to explain templating still has to resolve, and fails check exactly
like a real one. Found while adding the .ipxe and .cfg worked examples.
A GET has no body, so the haystack is empty. Query values and path segments must feed it too, or a document named after a MAC can never answer a preseed or kickstart fetch.
quick-xml emits entity references as their own events. Ignoring them welds the
surrounding text fragments together: 1 < 2 & 3 came back as 123.
Repeated XML siblings are not always a list. If they carry a discriminating attribute
they are a keyed collection; treating them as a list replaced every <component> in an
unattend.xml with the one the overlay happened to mention.
Two documents with the same stem are not duplicates. An earlier put deleted the other
formats of a stem to avoid “two answers for one machine”. That was the wrong model: they
are that machine’s answers for two operating systems.
Filter endpoints on the extension, not the Kind. .ks and .preseed are both
Kind::Text; filtering by family would let a preseed answer /rhel/ks.
An alias must be specific enough that nobody reaches it by accident. seed was removed
as an endpoint alias: s=http://server/seed/ is an ordinary NoCloud seed URL, and it
serves YAML.
The admin API
Read the request body before rejecting a request. Answering and closing while the
client is still writing earns an ECONNRESET instead of the response. put() drains
first, then validates the identifier.
Admin responses must set Connection: close. Without it every test client waited out
the connection timeout — the suite took 30 s instead of 0.4 s — and the eventual drop
sometimes arrived as a reset rather than a clean EOF.
Identifiers become filenames. export and the file store build paths from machine ids
and group names, so valid_id is enforced at the API boundary and in both stores.
Testing
cargo test does not rebuild target/debug/rescriptum. A manual check against a stale
binary once “reproduced” a bug that had already been fixed. Rebuild before poking at the
binary by hand.
Cache-invalidation tests must share one Answers instance. A test that constructs a
fresh one per call bypasses the cache entirely and silently proves nothing.
Assert on parsed values, not on formatting. Replacing a table with a scalar leaves the
key’s original decor, so the output can read value= 3 — valid TOML, different text.
A python/sed patch that “succeeds” may have matched nothing. Two edits in this
project’s history silently no-opped and were only caught by checking test counts
afterwards. Assert the old text was found before writing.
Packaging for DSM
A shell script that works on macOS is not a shell script that works on CI. Two found by
running the harnesses in a Linux container rather than trusting them: stat -f '%Lp' is the
format flag on BSD and filesystem status on GNU — where it succeeds, printing overlayfs
trivia into a variable that was supposed to hold a file mode, so the fallback never fires.
Ask GNU first (stat -c '%a' || stat -f '%Lp'), which fails cleanly on macOS. And shasum
is a Perl script that a minimal Debian does not have: sha256sum is coreutils and is
everywhere on Linux. Ubuntu runners carry both, which is exactly how a script like that ships
broken to everyone else.
musl 1.2 cannot run on Synology’s ARMv7 kernels, and the symptom names nothing. Those
kernels are 3.10 and answer the time64 syscalls with EINVAL; musl falls back to the
32-bit ones only on ENOSYS, so clock_gettime, clock_nanosleep and the timed futex all
fail. The binary installs, answers --version, and panics at time.rs:131 with
Os { code: 22, kind: InvalidInput } the moment it wants a timestamp — which looks like an
ABI or a too-old-kernel problem and is neither. The armv7 target is glibc with a 2.17 floor
for this reason; 64-bit targets have no time32/time64 split and are unaffected. Proven with
a ten-line C probe on the machine, not by reading anything.
SYNOPKG_PKGDEST is /volume1/@appstore/<package>, not /var/packages/<package>/target.
The second is a symlink to the first, so dirname "$SYNOPKG_PKGDEST" is /volume1/@appstore
and everything hung off it — etc/, var/, shares/ — lands where nothing reads it. The
package root is a fixed path. This one costs a service that installs perfectly and never
starts, and a fake-tree harness cannot catch it: in a tree you built yourself, dirname is
right by construction.
$SYNOPKG_TEMP_UPGRADE_FOLDER outlives the upgrade that created it. A fresh install
that reads it finds the configuration of an installation the user removed, and silently
restores it — tokens and all. Restoring from it has to require SYNOPKG_PKG_STATUS = UPGRADE.
etc/ and var/ survive an uninstall. They are symlinks into /volume1/@appconf/<pkg>
and /volume1/@appdata/<pkg>, which DSM keeps. So the env file, tokens included, stays on
the volume after the package is gone — which the documentation has to say, and which makes
a rig that does not clear them fail on the next run for reasons belonging to the last one.
A DSM account named after the package user is destroyed with it. conf/privilege’s
username creates a system user at install; an administrator of the same name is shadowed
by it and removed on uninstall.
The firewall directory is /usr/local/etc/services.d/ — plural. The developer guide says
service.d, which does not exist. The port-config worker acquires after postinst, so
the wizard’s port does reach the firewall entry on a fresh install.
port-config and usr-local-linker acquire when the package is enabled, not when
postinst runs: checked any earlier they are always absent.
The generated unit has no Restart= — Type=oneshot, RemainAfterExit=yes,
TimeoutStartSec=3600. DSM does not restart the process if it dies.
postinst runs on an upgrade too, and it runs before postupgrade. So “the env
file is absent” is not the same question as “this is a fresh install”: on an upgrade where
etc/ did not survive, writing defaults there destroys the user’s port and tokens before
the restore ever runs. postinst checks $SYNOPKG_TEMP_UPGRADE_FOLDER before it decides.
Found by simulating that exact case, not by reading the documented sequence.
The old version’s preuninst/postuninst run during an upgrade. Anything destructive
in them therefore runs every time somebody upgrades — and the first published .spk is
the one whose uninstall scripts will run during everybody’s first upgrade. They cannot be
fixed later.
status returning 1 means “crashed, stale pidfile”, not “stopped”. A cleanly stopped
package is 3. Returning 1 tells Package Center the service died.
prestart runs at boot, and DSM calls it whether or not you wrote it —
precheckstartstop defaults to "yes". A case that exits non-zero on an unrecognised
verb stops the package from ever starting after a reboot, with a symptom (“works by hand,
never after a reboot”) that looks like anything but a missing case arm.
The lifecycle scripts are not root. run-as: package governs them, not only the
service — so a chown outside the package tree, or synopkghelper, fails, possibly
silently.
data-share runs at package start, not at install, so nothing in postinst may
assume the shared folder exists. And a username that does not match its permission list
creates the share and grants it to nobody, without a word.
A logrotate stanza without copytruncate silently ends logging: log::init opens the
file once and never reopens it, so a rotation moves the inode out from under a server that
carries on writing to a file with no name.
A .spk whose outer tar is gzipped is rejected with “invalid file format” and no
further detail. So is one carrying macOS ._ members. check-spk.sh asserts both.
There is exactly one route to port 69 on DSM 7, and it is setcap. All four were
tried on a 7.2.2 machine on 2026-08-27, because the claim “DSM 7 does not let an unsigned
package run as root” had sat in CLAUDE.md for a while with no measurement behind it —
true, but by luck.
| Route | Result |
|---|---|
"defaults": {"run-as": "root"} in conf/privilege | refused — synopkg error 319, invalid package privilege content, stage: install_failed |
"ctrl-script": [{"action":"start","run-as":"root"}] — the shape Synology’s own packages use (FileStation, QuickConnect and StorageManager all do) | refused, same error 319 |
cap_net_bind_service embedded as a security.capability xattr in package.tgz | installs fine — the pax inner format is accepted — but Package Center strips the xattr, and getcap comes back empty |
setcap cap_net_bind_service=+ep on the installed binary, as root, after install | works; the package then binds udp/69 as its own unprivileged user alongside 8000 and 8001 |
net.ipv4.ip_unprivileged_port_start does not exist on that kernel, so that route is
closed too. /volume1 is btrfs with nodev but not nosuid, so file capabilities do
work there, and /usr/bin/setcap exists at mode 0700.
Root on DSM 7 is gated on being a Synology package, and libsynopkg.so.1 says so in
so many words. Reading its strings on a 7.2.2 machine turns the measurement above into
an explanation. A package that does not pass the signature check (verifyPackageSignature
lives in the same library) is refused all of this:
Failed to pass privilege check, ctrl-script and executable section should not exist
Failed to pass privilege check, defaults should be provided and defaults.run-as should be package
Failed to pass privilege check, join-groupname should not contains admin group
Failed to pass privilege check, tool capabilities should not exist
Failed to pass privilege check, tool user should be package
Failed to pass privilege check, non-synology package should not use privilege migration
Which is why FileStation, StorageManager, QuickConnect and SecureSignIn all carry
"ctrl-script": [{"action": "start", "run-as": "root"}] in their own conf/privilege and
we cannot: the shape is legal, the signature is what makes it legal for them.
The line that matters most is tool capabilities should not exist. DSM’s privilege
format has a native capabilities field — documented as
"capabilities": "cap_chown,cap_net_raw" on a tool entry since 7.0-40656, and
SYNOPackageTool::Privilege::ChangeCapabilities is right there in the library. A signed
package declares cap_net_bind_service and never needs setcap at all. The mechanism we
want exists, is documented, and is closed to us.
Synology’s developer guide states the rule outright: “If you are developing a package with root privilege, you are not able to install that package unless it is signed by synology.” So it is their signature, not any trusted publisher’s — which answers what the library string left open. SynoCommunity hit the same wall (spksrc#4170, #4215).
There is one documented bypass and it is not a distribution path: a development
token. Generate debug.dat from Support Center → Support Services, send it to Synology,
receive a signed token, drop it at /var/packages/syno_dev_token. It is valid only on
the NAS that generated the debug.dat, so shipping this way would mean every single user
doing a round trip with Synology before they could install. setcap is one local command
and strictly better for them.
Conclusion, and it is settled rather than provisional: the manual setcap is the price
of not being signed by Synology, and no packaging change removes it. If the package is
ever signed, the manual step and the boot-up task are both replaced by three lines in
conf/privilege.
setcap works on a DS416j too, and that was not a given. The four routes to port 69
were measured on a 7.2.2 VM, which is x86_64 with /volume1 on btrfs mounted nodev but
not nosuid — and a volume mounted nosuid makes the kernel ignore file capabilities
entirely, which would have closed the last open route on the one machine this project
exists for. Measured on the DS416j (ARMv7, armada38x): the capability holds, the package
binds udp/69 as its unprivileged user, and boot check reports
0.0.0.0:69 handed over ipxe-arm64.efi — a real read request answered with real data.
The capability belongs to the file, so an upgrade drops it. A new version replaces the binary and the capability goes with the old one — which is why the package documents a Task Scheduler boot-up task rather than a one-off command, and why a failed TFTP bind is not fatal: when it was, that upgrade took the answer endpoint down too.
Binding is not a health check, and it proves the opposite of what it looks like. A
bind that succeeds on the TFTP port means nothing is listening — the degraded state, not
the healthy one — and a bind that fails cannot tell this server apart from another daemon
squatting the port, because both are AddrInUse. boot check therefore sends a real read
request and reports what a machine would get. The first version of it reported “already in
use — that is this server, if it is running” and a test with a squatter on the port
immediately showed that to be a guess.
A new setting never reaches an installation that already exists, unless something
puts it there. The live env file is written only when absent — correct, because an upgrade
must never replace somebody’s port and tokens with defaults — but on its own that makes a
new feature invisible to every install that predates it. Boot media shipped with the
folders created, the loaders seeded and 69/udp registered with the firewall, and
RESCRIPTUM_BOOT_DIR never arriving, so boot check answered “boot assets are off” on a
DS416j that had everything else in place. etc/ surviving an uninstall means even removing
and reinstalling does not fix it. The .env.example was no help, because nothing makes
anybody read it.
postinst now appends keys the live file has never heard of, touching nothing that is
present. A commented-out key counts as present, and that is the safety property: it is
how an operator says “I know about this one and I do not want it”. Deleting a line means
“never heard of it” and gets it back; commenting it out means no, and is respected.
The DSM desktop application
Eight things, measured on a DSM 7.2.2 virtual machine and on a DS416j running 7.1.1, and none of them in the developer guide.
A default computed at runtime has to be computed in settings() too. The panel renders
a variable’s default as the field’s value, so a default that exists only where the server
consumes it shows as an empty box — while the server runs on an address it derived and
never displayed. RESCRIPTUM_PUBLIC_HOST shipped that way; the operator had no way to see
which address their machines would be sent to short of reading the startup log. Two entries
in KNOWN are like this, and both are special-cased in settings(): the worker count and
the public host. A third would need the same treatment, and nothing in the type system says
so.
A CGI under /webman/3rdparty/<pkg>/ runs as the owner of the script. Not as http,
and not as root — as whoever owns the file. DSM chowns a package’s tree to the package
user, so the application’s backend runs as rescriptum and can read the 0600 env file it
owns, which is the entire reason the configuration can be edited while the server is
stopped. Proven by chowning the same script two ways and watching id change. A script
left owned by root does run as root there, so do not leave one lying about.
That path is not authenticated by DSM. An unauthenticated request reaches the script
and is answered 200. Whatever guards a package’s CGI, the package wrote it — here that is
authenticate.cgi plus an administrators check, and losing either would be silent.
su in a CGI hangs the request. Without </dev/null it inherits the CGI’s stdin — a
pipe from the web server that nothing will close — reads from it, and never returns. The
status page simply stopped mid-answer. Then, once that was fixed, it failed anyway with
“Permission denied”, because a non-root process cannot become anybody. Both were wasted
effort: the script already is the user in question, so a plain test -r was the answer
all along.
The framework a package can use is the machine’s choice, not Synology’s guide’s. DSM
7.2 ships a Vue UI framework and the current guide documents only that one. The DS416j is
capped at DSM 7.1.1, where Vue is undefined — so an application built on it installs and
gives that machine an icon that opens nothing. ExtJS is on both (7.1.1 and 7.2.2 measured),
which is why there is one application rather than two.
The guide’s own ExtJS example does not run. It declares classes with Ext.define and
chains with callParent; against SYNO.SDS.AppInstance that throws Cannot read properties of null (reading 'apply') before the window ever appears. This is ExtJS 3.4.1 with an
Ext.define shim over it: use Ext.define for the declaration — DSM’s launcher finds the
class that way and it does set superclass — and then call
MyClass.superclass.constructor.call(this, config) rather than callParent.
DSM’s taskbar calls getWindowTitle() on the window. Without a title it throws from
inside DSM’s own taskbar bundle, and the application then fails to open at all — with a
stack trace that names Synology’s code and not yours.
Do not name a method show. Ext.Window.prototype.show() is what DSM calls to display
the window, so a show(which) added for switching tabs silently overrode it: the window was
built, laid out, and rendered a correct thumbnail in the taskbar preview — and never
appeared. Nothing threw, on either DSM version, which is what made it expensive: it was
found by bisecting from the guide’s minimal example upwards. Everything added to that
prototype shares a namespace with every method of Ext.Window, and that is a large
namespace.
fieldLabel is drawn by the form layout, not by the field. A syno_displayfield in a
plain Ext.Panel renders its value and silently drops its label, which turned the status
page into a bare column of values with nothing saying what they were. SYNO.ux.FormPanel,
or layout: 'form'.
Reproducible builds and browser caches disagree, and the browser wins. make-spk.sh
gives every packaged file a fixed mtime so the same inputs produce a byte-identical .spk.
nginx turns that into Last-Modified: 2019 with no Cache-Control, and a browser’s
heuristic freshness is a tenth of the file’s apparent age — years. An upgraded package went
on running the old JavaScript against the new backend, through a reinstall and a hard
reload. The application’s file is therefore named after the version and everything it
fetches itself carries ?v=; check-spk.sh asserts the name still moves.
Behaviour changes worth remembering
Answer documents must now be valid. Before merging they were served as opaque bytes, so
a malformed one reached the installer; now it is a 500 with the parse error in the log.
That is the better failure, but it is a behaviour change — fixtures written as YAML-ish
text stopped working when it landed.
{{ machine }} is bound only when a machine document matched. A machine claimed by a
group’s members, with no document of its own, resolves with machine: None — so
{{ machine }} in a group fails for exactly the members it was meant to cover. Use a
request fact such as {{ mac }} there.
The documentation site
The documentation site
This site is docs/ in the repository, rendered by
notabene and published to GitHub Pages. The Rust
binary knows nothing about any of it; the docs toolchain is a package.json and one
config file, and removing it would leave docs/ as perfectly readable Markdown.
Why a site and not a longer README
The README had grown to 28 KB and was three documents wearing one coat: a pitch, a user manual, and an architecture note. A reader looking for the DSM firewall step had to scroll past the merge semantics. So:
docs/guide/— using rescriptum: install, write answers, run it in production.docs/development/— building rescriptum: the constraints, the internals, the release.README.md— what it is, a 30-second demonstration, and links into the site.
The two spaces have different audiences and no reason to interleave.
Two languages
The site is bilingual: English is the source, French is a translation of it. The
suffix i18n strategy means the English files keep their paths and URLs and the French
ones are *.fr.md siblings:
docs/guide/answers/grouping.md → /guide/answers/grouping
docs/guide/answers/grouping.fr.md → /fr/guide/answers/grouping
That layout was chosen over a folder per locale because it can be added to an existing doc without moving anything — the English URLs and their comment threads survive.
Rules that follow from it:
- Write English first, then translate. A change to an English page that is not mirrored leaves the French page stale rather than broken; the reader falls back with a banner.
- Links keep the base name. From a French page, write
./selection.md, not./selection.fr.md— notabene resolves the locale. But anchors must be the French heading’s slug:./templating.md#machine-exige-un-document-machine. - Comments are per language. A comment left on the French page is its own thread and maps to the French source file.
- The site chrome, search and
llms.txtare per locale too.
README.md and README.fr.md follow the same rule and link to each other.
Working on the docs
npm install # once
npm run docs # → http://localhost:3009
That opens the site with the review loop enabled: select any text on the rendered page and leave a comment, exactly where the problem is. The anchored comment is the instruction — no quoting a passage into a chat box and hoping the agent re-finds it.
Then tell your agent “address the doc comments”. It reads docs/.notabene/, edits the
source, marks each comment handled, and appends a journal entry saying what changed and
why.
| Script | Does |
|---|---|
npm run docs | the review server, live-reloading |
npm run docs:build | the public static site into ./_site |
npm run docs:preview | serve what was built |
npm run docs:lint | validate every internal link against the routes the last build emitted |
npm run docs:status / docs:stop | manage a detached dev server |
Using Claude Code? /plugin marketplace add z29k/notabene then
/plugin install notabene@z29k, and say “set up notabene”. The plugin runs its own
pinned renderer, so it does not conflict with the one in package.json.
Review mode is approve
notabene.config.mjs sets review: "approve", so the agent proposes rather than
resolves: each edit is validated against its real git diff at /review before the
comment is closed. Documentation that describes root passwords and boot-time configuration
is worth reading before it ships. Change it to "auto" if that ceremony is not earning
its keep.
Writing a page
Every page is CommonMark with optional YAML frontmatter:
---
title: Groups and merging
description: One sentence — it becomes the meta description and the search snippet.
sidebar:
label: Grouping # sidebar text, if the title is too long for it
order: 3 # position among siblings, ascending
---
Everything has a default: a page with no frontmatter renders fine, ordered
alphabetically. A folder is named and positioned by its index.md.
Conventions in this repository:
- Relative links between pages, with the
.mdextension —./selection.md,../reference/configuration.md. They become routes on the site and stay clickable on GitHub. - Absolute GitHub URLs for repository files —
answers/,CLAUDE.md, a workflow. They are outsidedocs/and have no route. - English, like everything else written to disk here.
- Mermaid diagrams are rendered natively, in a
```mermaidfence — see architecture and the request lifecycle. - Every page needs its
*.fr.mdsibling, with the frontmatter translated too — thetitle,descriptionandsidebar.labelare all reader-facing.
Configuration
notabene.config.mjs at the repository root. The parts that matter:
roots: [
{ key: "guide", label: "Guide", path: "docs/guide" },
{ key: "development", label: { en: "Development", fr: "Développement" }, path: "docs/development" },
],
store: "docs/.notabene",
home: { en: "docs/home.md", fr: "docs/home.fr.md" },
i18n: { locales: ["en", "fr"], defaultLocale: "en", strategy: "suffix" },
branding: {
logo: "assets/rescriptum-logo.jpg",
favicon: "assets/rescriptum-logo.jpg",
socialImage: "assets/rescriptum-logo.jpg",
},
editPattern: "https://github.com/z29k/rescriptum/edit/develop/{path}",
review: "approve",
publish: { site: "https://z29k.github.io", base: "/rescriptum" },
Every reader-facing string in the config takes a per-locale map — a space’s label and
description, the home page, every nav link label, the sidebar block title, the footer.
Unset for a locale, it falls back to the default one.
The logo is assets/rescriptum-logo.jpg: a sealed rescript on a floppy disk — a written
answer, delivered by a machine. It serves as the topbar logo, the favicon and the social
card, and it is the image the README uses too.
editPattern points at develop, not main: docs are merged there like everything
else, and main only receives release commits.
docs/.notabene/ is the comment and journal store — plain JSON, committed, diffable in a
PR. Commit it.
The CI gate
.github/workflows/ci.yml has a docs job: npm ci, build the public site, then
notabene lint, which checks every internal link against the routes the build actually
emitted and suggests near-misses. A dead link in published documentation is cheap to
prevent and embarrassing to ship.
It runs on the same pushes as the Rust gates.
Publishing
.github/workflows/docs.yml builds --public and deploys to GitHub Pages on every push
to main that touches docs/, the config, or the workflow — plus
workflow_dispatch, for publishing a docs fix without waiting for a release.
Because main only receives release commits, documentation normally ships with a
release. Run the workflow by hand when it should not wait.
The artifact is the read-only public build: no review UI, no store data, plus llms.txt,
a Markdown twin per page, a sitemap and OpenGraph metadata. pagefind is a dev dependency,
so npm ci gives the site full-text search with no further configuration.
One-time repository setup: Settings → Pages → Source = GitHub Actions.
The dependency situation
npm audit reports advisories in Astro, esbuild and sharp, transitively under notabene,
with no fixes currently available upstream. They are development-only: nothing from
node_modules is executed by the published site or reaches the Rust binary, and the CI
job builds static HTML from Markdown this repository owns.
Worth re-checking when notabene updates, not worth blocking on.