← Back to site Loading…

rescriptum

Guide

an answer written for this machine

Generated on August 30, 2026

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, and check will 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

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.

TargetFor
armv7-unknown-linux-gnueabihfSynology DS416j and other ARMv7 NAS boxes (glibc ≥ 2.17)
aarch64-unknown-linux-muslmodern ARM NAS, Raspberry Pi
x86_64-unknown-linux-muslmost other Linux hosts
aarch64-apple-darwinlocal development, Apple silicon
x86_64-apple-darwinlocal 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:

FieldMeaning
listening onthe 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

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.

Preparing installer media

Preparing installer media

Every installer is told, at build time, where to fetch its configuration. That URL does two jobs here:

  1. It reaches the server. Any path works — POST and GET are answered on all of them, precisely so the URL baked into an ISO is never wrong.
  2. 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:

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

InstallerBoot parameter
RHEL / CentOS / Fedora / Alma / Rockyinst.ks=http://SERVER:8000/rhel/ks?mac=${net0/mac}
Debian preseedurl=http://SERVER:8000/debian/preseed?mac=${net0/mac}
Ubuntu autoinstallautoinstall ds=nocloud-net;s=http://SERVER:8000/ubuntu/?mac=${net0/mac}
Flatcar / Fedora CoreOSignition.config.url=http://SERVER:8000/flatcar/config?mac=${net0/mac}
openSUSE / SLESautoyast=http://SERVER:8000/suse/profile?mac=${net0/mac}
Windowsfetched 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 segmentServes 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, ipxethe 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

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 in members, or by a match block tested against the request.
  • default/ answers when nothing else does. One document per format: a TOML default must not answer a client that asked for kickstart.

The extension decides; the name does not

Inside a directory, the extension is the format and the part before it means nothing. proxmox.toml and answer.toml are the same document to the server; the name is there for whoever opens the folder. rescriptum writes readable ones — proxmox.toml, ubuntu.yaml, debian.preseed, boot.ipxe — and never renames yours.

The one rule that follows: a directory holds at most one document per format. Two .toml in one directory is reported as a problem rather than resolved, because nothing could pick between them that you would have predicted. Two different formats are not a duplicate at all — that is the whole point of the directory.

Storage layout and URL are still deliberately kept apart: a folder can be reorganised, a URL baked into an ISO cannot. See formats.

:::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 pickedBy 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 systemThe extension is the format, the endpoint chooses between them, and a machine can exist as several operating systems at once
Groups and mergingLayers 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
Validatingrender 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:

KeyDoes
membersthe machines this group answers for
matchcriteria tested against the request’s facts
extendsthe 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:

FactIs
paththe whole path, trimmed of slashes — rhel/ks
fileits last segment — ks. This is what tells cloud-init’s user-data from its meta-data
segmentevery 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:

  1. 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.
  2. Among selectors, more criteria wins. Three matching criteria beat two; a more deliberate rule is a more specific one.
  3. 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/ks asks for kickstart;
  • the document carries it as its extensiongroups/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

ExtensionForLayering
tomlProxmox VEstructural merge
yaml, ymlUbuntu autoinstall, cloud-initstructural merge
json, ignIgnition, Flatcar, Fedora CoreOSstructural merge
xml, autoyast, unattendAutoYaST, Windows unattend.xmlstructural merge, by element
kskickstart — RHEL, CentOS, Fedora, Alma, Rockyconcatenation
preseed, seedDebian preseedconcatenation
cfg, ipxeboot scripts and other line-oriented configconcatenation

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 attributename, 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

Layersgroup chain first, machine document last — the machine always wins
Mapsmerge recursively, including TOML’s inline and dotted tables
Other valuesreplaced outright by the higher layer
Arraysreplace, they do not append
Text formatsconcatenated 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 baserack-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:

LayoutThroughput
2,000 machine documents, no group12,132 req/s
one group of 2,000 members, no machine documents13,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

PlaceholderFilled 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 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 match block and says it could not try them, rather than implying they were verified — a selector needs a real request.
  • Flags a group with neither members nor match as reachable only via extends, 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

FormatTool
tomlproxmox-auto-install-assistant validate-answer
xml, autoyast, unattendxmllint --noout
ksksvalidator
yaml, json, ign, preseed, cfg, ipxenone 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 processno supervisor tree, no workers to size, no sidecar
One port by defaultplus a second, only if you enable the admin API
No writesoutside the answers directory or database, and none at all unless you enable the admin API or request capture
No statebetween requests. Restarting loses nothing
Graceful shutdownon SIGTERM (what DSM’s scheduler sends) and Ctrl-C

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

What it needs from the network

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

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

Deployment

Deployment

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

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

An environment file

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

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

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

A systemd unit

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

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

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

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

Adjust for what you actually enable:

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

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

In a container

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

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

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

Sizing it

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

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

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

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

Replacing a running instance

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

What it does, in order:

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

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

Upgrading

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

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

Synology DSM 7

Synology DSM 7

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

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

Install the package

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

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

Not sure which? Ask the machine:

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

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

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

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

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

What the package does not do

Five things worth knowing before they surprise you.

  • It cannot bind port 69 on its own. DSM 7 does not let an unsigned package run as root, so TFTP takes one root command from you, once — see TFTP needs one root command. Until it is given, the server warns, keeps answering and keeps serving media, and only the loader handoff is down.
  • It does not open the firewall. Registering the port makes rescriptum appear by name in the rule editor instead of you typing a number. If your firewall is on with a default-deny rule, you still have to create the rule.
  • It does not tell you about updates. There is no package source to poll — the distribution model is: download the new .spk from the releases page and install it by hand, for an upgrade as much as for a first install. Watch the releases.
  • A custom answers path is yours to permission. The package runs unprivileged and cannot grant itself access to a folder you name; if you point it outside the rescriptum share, give the rescriptum user read access yourself.
  • The share’s permission is reapplied at every start. If you deliberately narrow it, you will find it restored the next time the package starts.

Where everything lives

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

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

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

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

The desktop application

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

It has three tabs:

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

Three properties are worth knowing rather than discovering:

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

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

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

Configuring it

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

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

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


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

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

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

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

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

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

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

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

Putting answers in place

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

$ sudo -u rescriptum rescriptum-cli check

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

The firewall

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

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

If you change the port later, edit RESCRIPTUM_LISTEN_ADDR in the env file and then move the firewall entry, which does not follow by itself:

$ sudo /usr/syno/sbin/synopkghelper update rescriptum port-config

Serving installer media, and PXE

The package can also serve the installer itself — kernels, initrds and images — from the same NAS that decides the answer. It is off until you turn it on:

  1. Uncomment RESCRIPTUM_MEDIA_DIR in the env file and restart the package.
  2. Drop an ISO into the rescriptum share’s media folder, over File Station or SMB.
  3. Register it, so it is verified and probed once rather than per request:
$ rescriptum-cli media add /volume1/rescriptum/media/proxmox-ve_8.4-1.iso \
    --sha256 9f86d081884c7d65…
$ rescriptum-cli media list

The media listener is on port 8001, already registered with the firewall alongside the answer port — you still have to create the rule.

No image ships with the package, and none ever will: an ISO is somebody else’s artefact, gigabytes, on its own schedule. That folder is where you keep them, and it is the archive — nothing here modifies an image after it lands. Preparing a Proxmox image produces a two-hundred-byte sidecar and an injection applied on the wire, so the bytes on disk stay exactly what Proxmox published and their checksum stays verifiable against Proxmox’s own. See Serving boot media.

TFTP needs one root command

rescriptum is the TFTP server here, not DSM. Port 69 is privileged and DSM 7 refuses to let an unsigned package run as root, so the package cannot grant itself the port — but it does not need root to use it, only to be given permission once:

$ sudo setcap cap_net_bind_service=+ep /volume1/@appstore/rescriptum/bin/rescriptum
$ sudo synopkg restart rescriptum

After that the package binds udp/69 as its own unprivileged rescriptum user, alongside 8000 and 8001. All three are registered with the firewall.

Make it durable, because an upgrade drops it. Installing a new version replaces the binary, and file capabilities belong to the file — so the capability goes with the old one. Control Panel → Task Scheduler → Create → Triggered Task → User-defined script, user root, event Boot-up, with the setcap line as the script. Run it once from that page after every upgrade, or reboot.

Nothing else breaks while it is missing. A TFTP port that cannot be bound is the one listener in this server whose failure is not fatal, deliberately: answers are the product, and an upgrade must not take a fleet’s installs down to report that a second port could not be opened. What you get instead is a warning in the log, a tftp: line in the settings panel’s Status tab, and:

$ rescriptum-cli boot check
  BROKEN nothing answers on 0.0.0.0:69 and it cannot be bound either: Permission denied.
  Port 69 is privileged: run as root and set RESCRIPTUM_USER to drop afterwards, or grant
  the binary cap_net_bind_service with setcap — the server still answers and still serves
  media, but a machine sent here by DHCP asks for a loader and gets nothing

Note it asks the port for a loader rather than trying to bind it. Binding proves the opposite of what it looks like: a bind that succeeds means nothing is listening.

The loaders are in the package. The share’s boot folder arrives filled the first time you start it, and an upgrade refreshes them — there is no second download. They are iPXE, GPLv2, separate files served alongside rather than linked into anything, and the NOTICE beside them names the exact upstream commit they were built from.

$ rescriptum-cli boot check
  ok   0.0.0.0:69 handed over ipxe-undionly.kpxe

Replacing them is possible but not by editing that folder — an upgrade rewrites the filenames this package ships. Point RESCRIPTUM_BOOT_DIR somewhere else instead, and nothing here will ever write to it.

Then point DHCP at this NAS — Control Panel → DHCP Server → PXE if the NAS serves DHCP, or your own server with what this prints:

$ rescriptum-cli boot dhcp-snippet --format dnsmasq

If you would rather not use setcap

RESCRIPTUM_TFTP_ADDR takes an unprivileged port, which needs no capability at all — your DHCP server has to be told, since a PXE ROM has 69 burned into it and only a chainloading first stage can be redirected. Or set it to off and let another daemon on this NAS hand the loader over; DSM has its own TFTP server under Control Panel → File Services → Advanced, pointed at the share’s boot folder. Both are workarounds for a deployment that wants them, not what the package expects.

The Images tab

The application has a fourth tab, and it is where installer images are managed without touching a terminal: what is held, a catalogue to pick from, and a URL field for anything the catalogue does not offer.

The catalogue is not a list this package ships. Each entry names the checksum index the vendor already publishes beside its own images; picking one reads that index over the network, so the versions offered are whatever the vendor has today and the digest that gets verified is theirs. That also means the tab needs the NAS to reach the internet — the only part of this package that does.

A download of a 1.5 GB image cannot be held open by a web request, so the tab starts it and follows it: media add writes into a .part file beside its destination and renames it only once the digest checks out, so the partial file’s size is the progress and its disappearance is the completion. Closing the window does not stop the download.

Preparing a Proxmox image is a button there too. Every other family takes its answer’s URL on the kernel command line, so there is nothing to prepare and the tab says so rather than offering a step that would do nothing.

One setting worth filling in

RESCRIPTUM_PUBLIC_HOST=192.168.1.10

Every generated script names this address. Left empty it is derived by asking the routing table, and the settings panel shows what that came out as rather than an empty box — so on a NAS with one interface there is nothing here to fill in.

A NAS with two is the case worth reading. The derived answer is one of them, and the startup log names the others beside it:

warning: RESCRIPTUM_PUBLIC_HOST is not set — derived 192.168.1.10, which is what every
generated URL will name. This host also has 10.0.0.10. If the machines reach it on one of
those instead, set it explicitly.

Getting it wrong produces a machine that boots, chains, and hangs on an address that does not exist, which is a slow thing to diagnose from the machine’s end.

The log

RESCRIPTUM_LOG_FILE points the server at /var/packages/rescriptum/var/rescriptum.log, and the package installs a logrotate stanza for it — weekly, eight kept, copytruncate (the server opens its log once and never reopens it, so anything else would silently end logging). Beside it, var/startup.log holds what the server says before it knows where its log lives: a configuration error, a malformed env file.

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

When it will not start

Three places say why, in this order:

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

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

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

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

Verify

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

Without the package

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

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

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

If ARMv7 misbehaves, confirm the real architecture before assuming:

$ ssh admin@nas uname -m
armv7l

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

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

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

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

FieldValue
EventBoot-up
Userroot
Commandsee below

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

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

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

Details of the format are in the configuration reference.

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

Replacing a running instance

$ ./deploy.sh admin@nas

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

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

Shutdown

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

What to expect from a DS416j

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

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

Security

Security

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

The answer endpoint is open by default

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

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

RESCRIPTUM_ANSWER_TOKEN

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

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

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

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

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

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

The admin API token

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

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

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

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

Full details on the admin API page.

Why constant-time comparison

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

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

Do not put a token on a command line

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

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

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

What the server refuses on its own

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

TLS

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

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

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

The desktop application

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

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

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

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

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

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

Known and accepted

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

Capturing requests

Capturing requests

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

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

Off unless set.

What it writes

Two files per request:

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

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

Replaying one

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

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

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

The limits, and why

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

Before you attach one to a bug report

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

The SQLite store

The SQLite store

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

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

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

Why you would

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

Why you might not

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

The behaviour is identical

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

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

Moving between them

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

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

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

Schema versions

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

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

database schema is version 2, this binary understands 1

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

Operational notes

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

The admin API

The admin API

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

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

Three properties that are load-bearing

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

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

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

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

Endpoints

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

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

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

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

Examples

$ AUTH="Authorization: Bearer $RESCRIPTUM_ADMIN_TOKEN"

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

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

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

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

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

Rehearsing a real request

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

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

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

It will not let you break the fleet

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

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

Two things follow from how this works:

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

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

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

Identifiers

Letters, digits and - _ . : only. They become directory names under export and in the file store, so anything that could traverse a directory is rejected — at the API boundary and in both stores. groups and default are reserved as machine ids for the same reason: those are the directories the layout keeps for itself, and a database that accepted one would export into a directory that cannot hold it.

Status codes

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

Looking after the token

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

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

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

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

What the server does on its side:

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

Two limits to plan around

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

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

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

127.0.0.1 plus an SSH tunnel is the safe default.

Troubleshooting

Troubleshooting

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

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

Reading a line

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

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

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

Common failures

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

The server starts but everything 404s

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

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

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

… store=files:/srv/answers …

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

Reproducing it offline

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

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

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

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

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

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

Checking the whole set

$ rescriptum check

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

Reporting something

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

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

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

Serving boot media

Serving boot media

An answer tells a machine how to install. It says nothing about where the installer comes from — and until now that was somebody else’s web server, holding images that nobody checked against the answers written for them.

With a media directory, the same server does both. A machine’s MAC selects its answer and the image that answer was written for, and the two cannot drift apart because one component decided both.

$ export RESCRIPTUM_MEDIA_DIR=/srv/media

Unset is the whole off switch. Nothing changes for an existing deployment until you set it.

Where the base images live

No installer image is in this project, and none is in a release. An ISO is somebody else’s artefact, it is one to four gigabytes, and it changes on its own schedule — three separate reasons it belongs on your disk rather than in ours. RESCRIPTUM_MEDIA_DIR is where you keep them, and that directory is the archive: what a vendor published, on disk, never modified afterwards.

That last part is a property rather than a promise. Nothing here rewrites an image — preparing one produces a sidecar and an injection applied on the wire (see Preparing a Proxmox image), so the bytes on disk stay exactly what the vendor published and their digest stays checkable against the vendor’s own SHA256SUMS. media list says which entries are the archive and which derive from it.

Getting an image in

Three ways, and the first is the one to reach for.

Pick one from a catalogue

$ rescriptum media sources
SOURCE       NAME              WHAT IT INSTALLS
proxmox-ve   Proxmox VE        the founding case — answers come from a file injected into the image
debian       Debian            netinst images; the answer is a preseed on the kernel command line
ubuntu       Ubuntu LTS        autoinstall, via a cloud-init datasource on the kernel command line
almalinux    AlmaLinux 9       kickstart, named on the kernel command line
rocky        Rocky Linux 9     kickstart, named on the kernel command line

$ rescriptum media sources proxmox-ve
reading https://enterprise.proxmox.com/iso/SHA256SUMS …
Proxmox VE — the founding case — answers come from a file injected into the image
  proxmox-ve_9.2-1.iso
  proxmox-ve_9.2-1-arm64.iso
  proxmox-ve_9.1-1.iso

$ rescriptum media add --from proxmox-ve proxmox-ve_9.2-1.iso

Nothing about a specific image is stored in this server. Each catalogue names the checksum index the vendor already publishes beside its own images, and the names and digests are read from it when you ask — so the list is whatever that vendor has today, and the digest is theirs. A table of URLs baked into a release would be offering last quarter’s images, some of them since deleted.

What that is worth, said plainly. Taking the digest from the same host that serves the image is not a signature check. Over HTTPS it authenticates the vendor’s domain and it catches a truncated download, a corrupt mirror and a file that changed underneath — most of what actually goes wrong — and nothing beyond that. If you want more, use the next section with a digest you obtained yourself.

Let the server fetch it

$ rescriptum media add https://enterprise.proxmox.com/iso/proxmox-ve_8.4-1.iso \
    --sha256 9f86d081884c7d65…
fetching https://enterprise.proxmox.com/iso/proxmox-ve_8.4-1.iso
  with curl, into /srv/media/proxmox-ve_8.4-1.iso.part
######################################################################## 100.0%
verifying 1.5G …
fetched 1.5G via curl, digest verified

It lands on a .part name and is renamed only once the digest matches, so a partial download never becomes a catalogue entry — the catalogue probes whatever it finds, and a truncated ISO probes as an unknown image a machine would then try to boot. An interrupted fetch leaves the .part in place and running the command again resumes it.

--sha256 is required here, because nothing else would check what arrived. Vendors publish a SHA256SUMS beside the image. If you genuinely mean to go without, say --unverified — the point is that skipping it is a deliberate act rather than the default, since this decides what every machine on the network installs.

--as NAME.iso picks the filename when the URL does not imply a usable one.

::: tip There is no TLS in this binary rustls plus a root store is forty-odd crates and over a megabyte on ARMv7, for a job every host already has a tool for. So this runs curl, or wget if that is what is installed, and says plainly when it finds neither — in which case the answer is the one below. :::

Or put it there yourself

Over SMB, over scp, from wherever the ISO already is — the native act on a NAS — and then register it:

$ rescriptum media add /srv/media/pve-8.4.iso --sha256 9f86d081884c7d65…
hashing /srv/media/pve-8.4.iso …
  10% (152.0M of 1.5G)

pve-8.4  9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
  proxmox Proxmox Virtual Environment 8.4-1
  kernel /boot/linux26
  initrd /boot/initrd.img
  wrote /srv/media/pve-8.4.media

--sha256 is optional and worth giving: a mismatch is either a truncated download or the wrong file, and both would install the wrong thing on every machine that asks. Nothing is recorded when it does not match.

Nothing is copied and the image is never modified. What media add writes is the .media sidecar beside it, recording the digest and what the probe found. That is the whole point: hashing 1.5 GB takes the better part of a minute, and the server must never spend a minute inside a request.

An image with no sidecar still appears and is still served — it just has no digest to re-check, and it is probed on sight.

What it can tell about an image

$ rescriptum media list
ID                   FAMILY   ARCH       VERSION                          SIZE  PINNED
pve-8.4              proxmox  x86_64     Proxmox Virtual Environment…     1.5G  9f86d0818
ubuntu-24.04         ubuntu   x86_64     Ubuntu-Server 24.04.1 LTS        2.1G  —
gparted-1.6          unknown  —          GPARTED-LIVE                   420.0M  —

Six families are recognised — Proxmox, Debian, Ubuntu, RHEL and its rebuilds, SUSE and Fedora CoreOS — from a table of markers inside the image. Where a vendor left a version string it is used; the volume identifier is the fallback.

An image nothing recognises is still listed and still served. Not describable is not the same as not usable: it can be sanbooted, or written to a stick, or fetched whole by firmware. What it cannot do is produce a boot stanza, and the server says so rather than guessing.

The endpoints

The media listener is its own socket, on 0.0.0.0:8001 by default.

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

Nothing is extracted and nothing is unpacked. A file in an ISO9660 image is one contiguous run of bytes, so serving /pve-8.4/kernel is a seek and a length — the same few kilobytes of work whether the image is 400 MB or 4 GB.

Ranges, ETag, If-Range and HEAD are all answered, because real clients need them: Ubuntu’s casper and Red Hat’s anaconda both range-fetch, and UEFI HTTP Boot sends HEAD before it fetches.

Why it is a second listener

Not preference — three separate reasons, any one of which would be enough:

  • The answer endpoint answers on any path, because the URL is baked into an ISO. A /media/… prefix would carve a reserved space out of one that is deliberately open.
  • RESCRIPTUM_TIMEOUT_SECS is a whole-connection deadline of ten seconds. A 1.5 GB transfer is fifteen seconds on gigabit and two minutes on 100 Mbit, so every download would be killed mid-flight — and it would look like a flaky network, not a setting.
  • A download holds a connection permit for minutes. Sharing that budget with answers means a rollout starves its own installs.

The two have separate budgets, and a test proves it rather than hoping: answers keep succeeding with four transfers in flight.

Booting a machine from it

media ipxe writes the boot stanza for one image:

$ rescriptum media ipxe pve-8.4
#!ipxe
# Proxmox Virtual Environment 8.4-1 — generated by `rescriptum media ipxe pve-8.4`.
# An ordinary answer document: selection, layering and templating all apply.
kernel http://192.0.2.10:8001/pve-8.4/kernel ramdisk_size=16777216 rw quiet initrd=initrd.img \
    splash=silent proxmox-start-auto-installer
initrd http://192.0.2.10:8001/pve-8.4/initrd initrd.img
initrd http://192.0.2.10:8001/pve-8.4/iso proxmox.iso
boot

It prints a script; it does not install one. Save it into the answers directory and it is an ordinary answer document — selected, layered and templated like any other:

$ rescriptum media ipxe pve-8.4 > /srv/answers/groups/rack-a/boot.ipxe

Which is the point. The server does not become clever about booting; it gains a generator, and the composition engine you already have does the rest. A {{ mac }} in the generated answer URL is filled per request from the machine’s own facts.

Each family gets what it actually needs, and they are not alike:

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

Proxmox is the odd one out, and it is worth knowing why: it is the only one that carries the answer’s location inside the image rather than on the kernel command line. That is also why it is the only one that needs prepare-iso run over it once — see Preparing installer media.

::: tip Already ran prepare-iso --pxe? That leaves a directory holding vmlinuz, initrd.img and a trimmed ISO. Point RESCRIPTUM_MEDIA_DIR at it and it works as-is — the trimmed image is still recognised as Proxmox, and the kernel and initrd beside it are found and served. :::

Preparing a Proxmox image

Proxmox is the only family that carries the answer’s location inside the image, in /auto-installer-mode.toml. That used to mean running proxmox-auto-install-assistant prepare-iso somewhere else first.

$ rescriptum media prepare pve-8.4
pve-8.4-http  prepared from pve-8.4
  answer   http://192.0.2.10:8000/proxmox
  injects  /auto-installer-mode.toml (198 bytes)
  image    1610612736 bytes (source 1610610688 + 2048 appended)
  wrote    /srv/media/pve-8.4-http.media

Nothing was copied. Serve it as /pve-8.4-http/iso, or write it to a stick with
  rescriptum media export pve-8.4-http /tmp/pve-8.4-http.iso

What that wrote is a sidecar: about two hundred bytes standing in for 1.5 GB. The source is never modified, never copied, and its published digest stays verifiable. The file is injected on the wire, so changing the answer URL later rewrites those two hundred bytes rather than a gigabyte — and both entries appear in media list, backed by one image on disk.

--as NAME picks the derived entry’s name, and --url, --cert-fingerprint and --token say what goes in the file.

For a USB stick

$ rescriptum media export pve-8.4-http /tmp/pve-auto.iso

Materialises exactly what the listener would have served, through the same code path. A stick written any other way would be a second implementation to keep honest, and the difference would only show up on somebody’s desk.

When it refuses

Refusing is a complete answer here, because the fallback is one command on any Debian box and this server is perfectly happy to serve its output:

$ proxmox-auto-install-assistant prepare-iso pve.iso --fetch-from http --url …

It refuses when the image has neither Rock Ridge nor Joliet — the file could then only exist under a mangled 8.3 name like AUTO_INS.TOM;1, and the installer would never find it. It refuses a UDF image, because a Windows ISO keeps its large files only in the UDF tree and patching the ISO9660 tree would produce something that looks right and is not. And it refuses when the root directory has no slack in any of its sectors: relocating the extent would drag in the path tables, which is deliberately not done.

It also refuses to prepare a non-Proxmox image, and names the alternative: every other family takes the URL on the kernel command line, where media ipxe already puts it.

If the source changes underneath

The injection offsets are computed against one image. A source that changed would be patched in the wrong place, producing an image that mounts and is wrong — so the sidecar records the source’s length and the catalogue refuses when it no longer matches:

  problem: pve-8.4-http.media: pve-8.4 was 1610610688 bytes when this was prepared and
  is 1610612736 now. The injection offsets no longer apply — re-run `media prepare`.

Telling the server its own name

The moment it writes URLs into scripts, the server needs a name for itself that a machine can actually reach. 0.0.0.0:8001 is not one.

$ export RESCRIPTUM_PUBLIC_HOST=192.0.2.10

A host, never a URL. No scheme, no port, no path — the server writes URLs for two listeners, and a value carrying one port would pin every generated script to one of them. Each URL appends its own listener’s port. A value with any of the three is refused at startup, naming which.

Left unset, it asks the routing table which of this host’s addresses faces outward — and on a segment with no default route, falls back to the interface list, which on a host with one address is not a guess at all. Either way it says at startup what it settled on, and whether there was anything to settle:

RESCRIPTUM_PUBLIC_HOST is not set — using 192.0.2.10, the only address this host has.
Every generated URL will name it.
warning: RESCRIPTUM_PUBLIC_HOST is not set — derived 192.0.2.10, which is what every
generated URL will name. This host also has 10.8.0.4. If the machines reach it on one of
those instead, set it explicitly.

The second is the one to take seriously: a wrong guess produces a machine that boots, chains, and hangs on an address that does not exist. Naming the alternatives is what makes that answerable from the log itself, rather than by going to look at the host. NAT is the case neither line can catch — the address is genuinely this host’s, and genuinely not the one the machines reach.

Keeping it honest

$ rescriptum media check
checking media in /srv/media
  2 image(s), 1 verified against a recorded digest
  note: ubuntu-24.04 has no recorded digest — `media add` records one
  ok — everything recorded still matches

Its exit code is a contract, like check’s: zero when everything recorded still matches, one when something drifted. deploy.sh keys on it.

An image that changed under a recorded digest is the one failure that silently installs something nobody reviewed, so it is loud:

  FAIL pve-8.4: the image no longer matches what was recorded
       recorded 9f86d081884c7d65…
       found    7d793037a0760186…

What this proves is integrity, not authenticity: what is served is what was registered. Whether what was registered is what the vendor published is a question for the vendor’s own signatures, and --sha256 at media add is where that check belongs.

Who may fetch

Boot traffic is unauthenticated, and necessarily so — a PXE ROM has no credentials, the same necessity that already governs the answer endpoint. The controls are therefore structural: read-only, catalogue-bound, and no filesystem path is ever built from a request. Plus one that can say not you:

$ export RESCRIPTUM_BOOT_ALLOW=10.0.0.0/8,192.168.0.0/16

Unset means anyone who can reach the port, which on a provisioning VLAN is the honest configuration. A boot VLAN is the recommendation that actually works; see Security.

Tuning

VariableDefaultWhat it is for
RESCRIPTUM_MEDIA_ADDR0.0.0.0:8001The listener
RESCRIPTUM_MEDIA_TIMEOUT_SECS600Whole-transfer deadline
RESCRIPTUM_MEDIA_MAX_CONNECTIONS16Concurrent transfers

Sixteen is low on purpose. Each transfer holds its permit for minutes, and the small end of what this has to run on is a NAS with one spinning disk: sixteen transfers at 64 KiB a chunk is about two megabytes of buffers, which is arithmetic that has to hold in 512 MB of RAM.

On a datacenter host, raise it. The answer endpoint has its own budget and is untouched either way.

Netbooting a machine

Netbooting a machine

A machine powers on. Four links later it is installing itself the way somebody decided — or, if nobody has decided anything about it yet, sitting in a menu where a human can.

 power on

(1) ├── DHCP says where to boot from ......... THEIRS. Two options, and we
    │   generate the snippet that sets them.

(2) ├── TFTP hands over a loader ............. OURS
    │   arch-matched iPXE, chaining through ${next-server}

(3) ├── iPXE asks what to do ................. OURS
    │   known machine  → its own unattended answer
    │   unknown machine → the menu

(4) └── the bits arrive ..................... OURS
        kernel, initrd, the image itself — HTTP with ranges

Link 1 is somebody else’s and stays that way. rescriptum speaks no DHCP at all — not as a server, not as a proxy, not behind a flag. Sites that deploy this already run one, and pointing it at a boot server is a solved problem with thirty years of tooling.

Turning it on

$ export RESCRIPTUM_MEDIA_DIR=/srv/media     # the images
$ export RESCRIPTUM_BOOT_DIR=/srv/boot       # the loaders
$ export RESCRIPTUM_PUBLIC_HOST=192.0.2.10   # what generated scripts will name

RESCRIPTUM_BOOT_DIR says where the loaders are: unset, there is no TFTP listener and nothing at /boot/…. Naming it starts TFTP on 0.0.0.0:69 unless you say otherwise.

Port 69 is privileged, and it is the only privileged port this server ever wants — with no DHCP responder there is nothing after 67 or 4011. Four ways to deal with it, all portable:

$ export RESCRIPTUM_USER=rescriptum          # start as root, bind, then drop
$ setcap cap_net_bind_service=+ep rescriptum # or grant just that one capability
$ export RESCRIPTUM_TFTP_ADDR=0.0.0.0:6969   # or move it, if their DHCP can say so
$ export RESCRIPTUM_TFTP_ADDR=off            # or have no listener at all

off is a value, not an absence — it is how you say another daemon on this host hands the loader over while rescriptum serves the rest of the chain. The loaders stay served over HTTP at /boot/… and stay checked by boot check; only the listener is gone. It is a deployment workaround for somebody who wants it, never how anything here ships: rescriptum is the TFTP server, and a build that turned it off by default would have traded away the thing it is for. The Synology package binds port 69 with a setcap.

A TFTP port that cannot be bound does not stop the server, and that is the one place this project’s “a listener that cannot bind is fatal” rule inverts. Port 69 is the only privileged port in the design, so it is the only bind that can fail for something nobody configured — a capability an upgrade quietly dropped, most often. Answers are the product; dying here would fail every install in flight to report that a second port could not be opened. So it warns, keeps serving, and boot check exits non-zero:

$ rescriptum boot check
  BROKEN nothing answers on 0.0.0.0:69 and it cannot be bound either: Permission denied.
  Port 69 is privileged: run as root and set RESCRIPTUM_USER to drop afterwards, or grant
  the binary cap_net_bind_service with setcap — the server still answers and still serves
  media, but a machine sent here by DHCP asks for a loader and gets nothing

It asks the port for a real loader rather than trying to bind it, because binding proves the opposite of what it looks like: a bind that succeeds means nothing is listening, and a bind that fails cannot tell this server apart from another daemon squatting the port.

Binding happens first and dropping second, always. The other order works in testing as root and fails on deployment, at a reboot, which is the one moment nobody is watching.

Their DHCP server’s two lines

$ rescriptum boot dhcp-snippet --format dnsmasq
# rescriptum 0.2.0 - boot handoff for 192.0.2.10
# Architecture values are IANA option 93 codes; see docs/guide/boot/dhcp.
# Generated from the same table the TFTP server serves from.
dhcp-match=set:bios,option:client-arch,0
dhcp-match=set:efi64,option:client-arch,7
dhcp-match=set:efi64,option:client-arch,9
dhcp-match=set:efiarm64,option:client-arch,11

--format covers dnsmasq, isc, kea, powershell, pfsense and mikrotik; --one-loader emits the single-line form for a fleet that is all one architecture.

The snippet and the TFTP server are generated from one table, so what you paste in and what the server hands out cannot drift apart. What they can do is name a loader nobody has downloaded yet, and that fails silently at the ROM — the machine asks, gets nothing, and stops with no message on any console. One command catches it:

$ rescriptum boot check
checking boot assets in /srv/boot
  ok   ipxe-arm64.efi (1.0M)
  MISSING ipxe-undionly.kpxe — every machine the snippet sends here will ask for it,
  get nothing, and stop

Its exit code is a contract, like check’s. Put it in the same place.

Four details the generated snippet gets right

Each is a way this fails quietly on somebody else’s network, and none is obvious:

  • Both the BOOTP file field and option 67. Some ROMs read only one, and which is not predictable from the vendor.
  • An untagged default at the end. Every architecture line is tag-matched, so a ROM that sends no option 93 would match nothing and get no boot file at all.
  • HTTPClient echoed back in option 60 for UEFI HTTP Boot clients. The firmware filters offers on it: a reply carrying only the URL is discarded, silently, which is indistinguishable from having no DHCP server.
  • A next-server for those clients too, even though they fetch over HTTP. Without one the loader’s embedded script reads an empty ${next-server} and chains into nowhere.

::: tip Windows Server A DHCP policy cannot condition on option 93 — the condition types are vendor class, user class, MAC, client id, FQDN and relay information. The architecture reaches a policy only inside the option 60 string, so the generated PowerShell defines vendor classes on PXEClient:Arch:00007* and hangs the policies off those. Same outcome, different mechanism, and it is exactly the sort of thing that gets half-remembered. :::

The loader

TFTP hands over one file, and the rule is written into the code:

TFTP hands over the loader. Everything after that is HTTP.

At 1468 bytes a round-trip, TFTP moves about 1.4 MB/s on a millisecond of latency. The loader is a megabyte — two seconds. A 1.5 GB image would be the better part of twenty minutes, against fifteen seconds over HTTP on the same wire.

Which loader depends on what the firmware announced:

Option 93ClientServed
0x0000BIOS PXEipxe-undionly.kpxe
0x0007, 0x0009UEFI x86-64ipxe-x86_64.efi, plus -snp / -snponly
0x000bUEFI ARM64ipxe-arm64.efi
0x0010, 0x0013UEFI HTTP Bootthe same files, over HTTP, no TFTP at all
everything else32-bit UEFI, EBC, U-Bootrefused, with the reason

0x0009 needs a word. RFC 4578 defined it as “EFI x86-64”; IANA’s registry, rewritten by RFC 5970, lists it as “EBC”. Real x64 firmware sends either, so both map to x64 — a table generated from the registry alone would hand half a fleet nothing.

snponly exists because the plain UEFI build cannot always see the NIC. All the variants are served and the table picks; this is precisely the knowledge an operator should not have to acquire.

Getting them

Every release attaches rescriptum-boot-assets-<version>.tar.gz. Unpack it where the server can read it, name the directory, and check it:

$ tar -xzf rescriptum-boot-assets-0.2.0.tar.gz -C /srv
$ export RESCRIPTUM_BOOT_DIR=/srv/rescriptum-boot-assets-0.2.0
$ rescriptum boot check

It carries the eight loaders, a SHA256SUMS, a bootable ipxe.iso and ipxe.usb for a machine with no usable PXE ROM, and a NOTICE — they are iPXE, GPLv2, built from a pinned upstream commit. They are a separate download and not part of any binary archive or .spk, deliberately: separate files served alongside is mere aggregation, and packaging/ipxe/ is the written offer that goes with them.

To build them yourself instead — the same script the release runs, from the same pin:

$ packaging/ipxe/build.sh --out /srv/boot

A loader from elsewhere works too, provided it chains to this server rather than to the internet — see below for why a stock one does not.

What happens on the second boot

The first question anybody asks after a successful install, and it has a real answer.

A machine that was just installed reboots, and if network boot is still first in its BIOS order it arrives back here. What happens next is decided by one setting:

RESCRIPTUM_BOOT_UNCLAIMEDA machine no answer claims
menu (default)gets the menu, whose first entry is the local disk and whose timeout falls through to it — fifteen seconds, then the disk
localis handed straight back to its firmware, which moves to the next boot device

These are opposite readings of what an answer file means, and the choice belongs to the deployment.

With the menu, a file claiming a machine is how you say leave this one alone — because without one it lands in a menu somebody could click. That is right while machines are being provisioned, and it is the project’s thesis: a machine nobody has decided anything about should end up where a human can decide.

With local, an answer file means install this one, and its absence is the safe state. Nothing happens to a machine you have not written a file for — it boots its own disk, every time, with no menu to click by accident. That is the reading a fleet in production needs, and it is the one that scales: the number of machines you want to reinstall is always smaller than the number you do not.

The payoff is that netboot can stay first in the BIOS order forever. Reinstalling a machine becomes add a file, reboot — no console, no boot menu, no hands on the hardware. Removing the file is what stops it happening twice.

$ rescriptum config set RESCRIPTUM_BOOT_UNCLAIMED=local

Either way the machine’s identity still goes up first. The setting decides only what happens when nothing claimed it — not whether to ask.

Installing a machine once, and only once

A machine claimed by an .ipxe answer installs, reboots, is claimed again, and installs again — wiping its disk every time. Every provisioning system answers this the same way: a machine is armed for install, and something disarms it afterwards.

The machine is what knows. Proxmox calls a webhook after a successful install and before the reboot, with its network interfaces in the body:

[post-installation-webhook]
url = "http://192.0.2.10:8000/installed"
auth-token = "nas:s3cr3t"
$ rescriptum config set RESCRIPTUM_INSTALLED_TOKEN=nas:s3cr3t

That is the whole of it. The machine finishes, says so, and its .ipxe moves from 98fa9b50d810/ to installed-98fa9b50d810/ — a directory name that no longer matches it, because the prefix is part of the name that gets compared. It boots its own disk from then on, and re-arming it is moving the document back.

The disarmed document goes to a sibling directory rather than staying inside the machine’s own, so 98fa9b50d810/ keeps meaning “this machine’s configuration” and nothing in it has to be read as switched off.

No token, no endpoint — absent rather than open. Without one, /installed is an ordinary answer request like any other path, which is what keeps a URL bakeable into an ISO.

Three things it will not do, and each is deliberate:

  • It never touches a group. A group claims a whole rack, and one machine finishing its install must not disarm its neighbours. The lookup does not consult groups at all rather than filtering them out afterwards.
  • It never touches anything but the .ipxe. The machine’s own .toml, beside it in the same directory, is what the installer read to build it, and it stays as the record of how.
  • It moves, it does not delete. This is the one path where something arriving over the network changes the answer set, so nothing it does is irreversible.

Arriving twice is not an error — a webhook may be retried, and a machine installed from the menu was never claimed at all. A disarm that fails is logged as still armed, because the consequence is otherwise silent: the machine reinstalls on its next boot and nothing else would say so.

Every other family reports back too

Proxmox is the only one with a webhook of its own. The claim is not Proxmox-specific — it is an .ipxe document, which is about the loader rather than the operating system — so every family needs the same disarm, and every family has somewhere to run one line at the end of its install:

curl -fsS -X POST -H "Authorization: Bearer nas:s3cr3t" \
  "http://192.0.2.10:8000/installed?mac=$(cat /sys/class/net/*/address | head -1)"

No body, no JSON: the query says which machine, the header says it is allowed. Where that line goes:

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

Pick the booting interface’s MAC, not the first one alphabetically. The example above takes whichever /sys/class/net entry comes first, which is fine on a machine with one NIC and wrong on a machine with four — and the wrong MAC disarms the wrong machine, or nothing at all. On a machine with several, name the interface.

The endpoint takes the credential either way — Proxmox’s auth-token arrives inside the JSON body because that is what Proxmox sends, and a bearer header because that is what a shell script sends. Same secret, same constant-time comparison.

When everything is right and the machine still will not install

The chain can be perfect and fail at the last step, on the machine rather than on the server. Two that have actually happened, both on a Lenovo with vPro:

Intel AMT with a static address, on a NIC it shares with the host. The installer’s own dhclient sends two requests about eleven seconds apart and then gives up; if the Management Engine holds the interface with a static configuration while the host asks for DHCP, those eleven seconds pass with no offer and the install aborts with Fetching answer file via HTTP failed: Network is unreachable. Set AMT to DHCP too. Running dhclient -v eno1 by hand from the installer’s shell afterwards succeeds immediately, which is what makes this so confusing to diagnose: the network is fine, the timing is not.

A switch port that does not forward straight away, for the same reason and with the same symptom — RSTP converging, or a link still negotiating after the kernel takes the NIC over from iPXE. There is nothing this server can do about either: eleven seconds is the installer’s window, not ours.

The installer drops to a root shell when it aborts, and that shell is the fastest diagnosis there is:

# ip link                 # is the interface up at all?
# dhclient -v eno1        # does an offer come back when asked by hand?
# ip addr show eno1

An address appearing there and not during the install means the network works and the machine simply asked too early.

How iPXE ends up talking to us

The question nobody expects to have to answer. Whatever delivers the loader:

  • A plain undionly.kpxe from ipxe.org does DHCP, is told to load iPXE, and loads itself forever — iPXE’s documented chainloading loop.
  • A stock netboot.xyz binary has an embedded script that goes straight to the public boot.netboot.xyz. No loop, but your menu and your answers are never consulted.

The loaders rescriptum ships carry a three-line script that chains through ${next-server} — the value option 66 already set, which is how the loader arrived in the first place. That makes one generic build work in every deployment, with no second condition in a configuration file somebody else owns.

The script chains to port 8001, and that is a contract rather than a preference: it is baked into the loader before any deployment exists and can read no configuration. Moving RESCRIPTUM_MEDIA_ADDR is allowed and boot check warns about it.

What a machine sees

Stage two puts the machine’s identity in the query string, which is the one thing DHCP cannot do — a DHCP option cannot carry ${net0/mac}:

$ rescriptum boot bootstrap
#!ipxe
chain http://192.0.2.10:8000/ipxe/boot?mac=${netX/mac}&uuid=${uuid}\
&serial=${serial:uristring}&asset=${asset:uristring}\

|| chain http://192.0.2.10:8001/ipxe/menu

Two details in there are load-bearing. netX, not net0net0 is merely the first interface, so a server booting from its second port would identify as its unused first. And :uristring on every SMBIOS string, because ${manufacturer} expands to Dell Inc. with the space and iPXE percent-encodes nothing on its own.

That final || is the whole of “a menu is the default answer”: a machine something claims gets its own unattended answer, and a machine nothing claims falls through to the menu. It is default/’s job description word for word, applied to a different format.

The menu

$ rescriptum boot menu

Rendered from the catalogue at request time, not kept in sync as a file: drop an ISO in the media directory and it is in the menu on the next fetch.

  • Boot from the local disk is first, and the timeout falls through to it. A machine that PXE-boots by accident, and that nothing claims, ends up on its own disk after fifteen seconds. It never sits waiting for a human who is not coming, and it never installs anything. Combined with the rule that an unclaimed machine gets a menu rather than an install, the worst case of being wrong about which machines reach this server is a few seconds added to a boot.
  • Entries are gated on the client’s architecture, so an ARM64 image is not offered to an x86 machine — that is an entry that boots the wrong kernel.
  • An image no probe could place is still offered, as a CD.
  • The diagnostics entries — a shell, netinfo, and one that boots a different rescriptum — are what every boot server ends up needing. The last is how you test a candidate server on site, from the running one, without touching DHCP or the loaders.

RESCRIPTUM_BOOT_TIMEOUT_SECS (default 15) sets the wait, and RESCRIPTUM_BOOT_TITLE the title bar. The logo is fetched with console --picture … ||, which tolerates its own failure: a serial console over IPMI has no framebuffer, and that is how half of all datacenter installs are watched.

What breaks when this server is down

Worth stating plainly, because “a boot server” sounds load-bearing and is not:

rescriptum down
DHCP addressing, DNS, routingunaffected — it speaks none of those protocols
Machines already installed and runningunaffected
Machines rebootingunaffected — they boot from disk
A machine that PXE-boots by accidentfalls to its next boot device, as it would anyway
Starting a new installationstops

Nothing rescriptum installs depends on rescriptum afterwards. The answer endpoint is consulted during an install and never again.

Security

Boot traffic is unauthenticated, and necessarily — a PXE ROM has no credentials, the same necessity that already governs the answer endpoint. So the controls are structural, and one of them can say not you:

$ export RESCRIPTUM_BOOT_ALLOW=10.0.0.0/8    # shared by TFTP and media

UDP is forgeable and TFTP is UDP, so the server never answers a broadcast or multicast destination — amplification hygiene rather than politeness — caps concurrent transfers in total and per peer, and logs every one. It is read-only: a write request is refused as an access violation, because writing a loader over unauthenticated UDP would be a way to change what every machine on the segment boots.

A boot VLAN is the honest recommendation and the one that actually works. See Security.

::: tip Secure Boot Our loaders are unsigned, and shim only loads what its distro’s vendor key signed — so serving a shim beside an unsigned iPXE is not Secure Boot support, it is a boot that stops at a signature error. What does work: turn Secure Boot off, enrol a MOK, or let firmware PXE-boot the target distro’s own signed shim and GRUB, served from the media listener like any other file. We sign nothing and strip nothing, and nothing here weakens a machine that has Secure Boot on. :::

When their DHCP genuinely cannot be touched

None of this costs a line of code, and all three work:

  • UEFI HTTP Boot with a URL typed into firmware setup. Modern server firmware lets you enter a boot URL directly. The chain then starts on the media listener with no DHCP option involved at all.
  • iPXE from IPMI virtual media, a USB stick, or the NIC’s own ROM, carrying this server’s address. A one-megabyte image, mounted once per machine.
  • dnsmasq in proxy-DHCP mode, for a site that truly has a DHCP server it cannot edit. It exists, it is mature, it is three lines of configuration, and it is not ours to rewrite. Naming it is the honest answer.

Reference

Reference

The exhaustive counterpart to the rest of the guide. Tables and contracts, one page per surface:

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

VariableDefaultMeaning
RESCRIPTUM_CONFIGunsetRead defaults from this TOML file — see below
RESCRIPTUM_ENV_FILEunsetRead defaults from this KEY=value file — see below
RESCRIPTUM_STOREfilesfiles (a directory) or sqlite (a database)
RESCRIPTUM_ANSWERS_DIR/srv/answersDirectory of answer documents
RESCRIPTUM_DB_PATH/srv/answers.dbDatabase path, when RESCRIPTUM_STORE=sqlite
RESCRIPTUM_LISTEN_ADDR0.0.0.0:8000Listen address. :0 picks a free port, and the bound one is printed
RESCRIPTUM_WORKERSCPU countAsync runtime threads. Not a concurrency limit
RESCRIPTUM_MAX_CONNECTIONS2048In-flight connections before shedding with 503
RESCRIPTUM_TIMEOUT_SECS10Header-read timeout and whole-connection deadline
RESCRIPTUM_ANSWER_TOKENunsetBearer token the answer endpoint requires. Unset means open
RESCRIPTUM_ADMIN_ADDRunsetAdmin API listener. Unset means the admin API is off
RESCRIPTUM_ADMIN_TOKENunsetAdmin bearer token, 16+ characters. Required with RESCRIPTUM_ADMIN_ADDR
RESCRIPTUM_CAPTURE_DIRunsetRecord request bodies here. Unset means no capture
RESCRIPTUM_LOGallall, problems or off — see below
RESCRIPTUM_LOG_FILEunsetA file to append to, or stdout / stderr. Unset means stderr
RESCRIPTUM_MEDIA_DIRunsetInstaller images. Unset means no media and no media listener
RESCRIPTUM_MEDIA_ADDR0.0.0.0:8001The media listener, when there is a media directory
RESCRIPTUM_MEDIA_TIMEOUT_SECS600Whole-transfer deadline. Deliberately not the answer listener’s 10
RESCRIPTUM_MEDIA_MAX_CONNECTIONS16Concurrent transfers. Low on purpose: each holds its permit for minutes
RESCRIPTUM_PUBLIC_HOSTderivedThe host generated URLs name. A host, never a URL
RESCRIPTUM_BOOT_ALLOWunsetClient CIDRs allowed to fetch boot media. Unset means anyone who can reach the port
RESCRIPTUM_BOOT_DIRunsetLoaders and menus, handed out over TFTP. Unset means no TFTP at all
RESCRIPTUM_TFTP_ADDR0.0.0.0:69The TFTP listener, or off for none. Port 69 is privileged; see RESCRIPTUM_USER
RESCRIPTUM_TFTP_PORT_RANGEunsetThe 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_BLKSIZE1468The 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_SECS15Seconds before the menu falls through to local boot
RESCRIPTUM_BOOT_UNCLAIMEDmenuWhat 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_TOKENunsetProxmox’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_LOGObuilt-inA PNG to show behind the menu
RESCRIPTUM_BOOT_TITLEbuilt-inThe menu’s title bar
RESCRIPTUM_USER / _GROUPunsetDrop 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.

WhatRESCRIPTUM_LOG:

ValueKeeps
all (default)every request, plus startup, warnings and errors
problemsstartup, warnings, errors, and only the requests that did not succeed
off / nonenothing 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.

WhereRESCRIPTUM_LOG_FILE:

ValueGoes to
unset, or stderrstderr, which is what a supervisor reads
stdoutstdout
any other valuethat 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 fileVariable
answers_dirRESCRIPTUM_ANSWERS_DIR
listen_addrRESCRIPTUM_LISTEN_ADDR
log, log_fileRESCRIPTUM_LOG, RESCRIPTUM_LOG_FILE
public_hostRESCRIPTUM_PUBLIC_HOST
user, groupRESCRIPTUM_USER, RESCRIPTUM_GROUP
store.kind, store.db_pathRESCRIPTUM_STORE, RESCRIPTUM_DB_PATH
server.workers, server.max_connections, server.timeout_secsRESCRIPTUM_WORKERS, RESCRIPTUM_MAX_CONNECTIONS, RESCRIPTUM_TIMEOUT_SECS
admin.addr, admin.tokenRESCRIPTUM_ADMIN_ADDR, RESCRIPTUM_ADMIN_TOKEN
answer.token, answer.capture_dirRESCRIPTUM_ANSWER_TOKEN, RESCRIPTUM_CAPTURE_DIR
media.dir, media.addr, media.timeout_secs, media.max_connectionsthe RESCRIPTUM_MEDIA_* four
boot.dir, boot.allow, boot.unclaimed, boot.timeout_secs, boot.logo, boot.titlethe RESCRIPTUM_BOOT_* six
tftp.addr, tftp.port_range, tftp.blksizethe RESCRIPTUM_TFTP_* three
installed.tokenRESCRIPTUM_INSTALLED_TOKEN

The format

Any TOML scalara number may be written as a number (workers = 2) or as a string; both reach the server as the same setting
A # commentanywhere, 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 spacefine, 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 twicerefused by TOML itself, so the file does not load
A key this program does not reada warning naming it, so admin.tokenn is caught rather than ignored
A list or a table where a value belongsa 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 reada 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 lineleading export is accepted, so the same file can also be sourced
# at the start of a linea comment
# anywhere elsepart 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 twicea startup error, rather than a guess about which was meant
a key this program does not reada warning naming the key — so RESCRIPTUM_ADMIN_TOKENN is caught rather than ignored
a file others can reada 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

CaseWhat happens
Exported but empty (RESCRIPTUM_LISTEN_ADDR=)treated as unset — an empty value is a mistake, not an instruction
Whitespace-onlysame, and values are trimmed
A zero or unparseable numberfalls back to the default, rather than starting a server that accepts connections and never answers
RESCRIPTUM_STORE set to anything elsea warning, and files is used
RESCRIPTUM_ENV_FILE or RESCRIPTUM_CONFIG naming a missing, unreadable or malformed filea startup error
A TOML setting given a list or a tablea startup error, unlike a misspelled key, which warns
RESCRIPTUM_STORE=sqlite on a binary built without the featurea startup error

Startup errors

These stop the server rather than warning, because starting anyway would be worse:

ConditionWhy it is fatal
RESCRIPTUM_ADMIN_ADDR set with RESCRIPTUM_STORE not sqlitetwo ways to change the same configuration, racing
RESCRIPTUM_ADMIN_ADDR set with no RESCRIPTUM_ADMIN_TOKENan open API that rewrites root credentials
RESCRIPTUM_ADMIN_TOKEN under 16 charactersshort enough to guess
The listen address cannot be boundnothing to do
The store cannot be openednothing to serve
RESCRIPTUM_MEDIA_ADDR set with no RESCRIPTUM_MEDIA_DIRa listener with nothing to serve
RESCRIPTUM_MEDIA_ADDR equal to the answer or admin addressthe second bind loses, and which one depends on start order
RESCRIPTUM_PUBLIC_HOST carrying a scheme, a port or a pathit 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_DIRa listener with no loaders to hand out
The boot directory cannot be resolvedevery path check compares against it
RESCRIPTUM_USER names an account that does not existnothing to become

Startup warnings

These are printed and the server carries on:

ConditionLine
Answers directory missingwarning: … does not exist yet — every request will 404 until it does
Answers path exists but is not a directorywarning: … is not a directory — every request will 404 until it is
Answers directory present but unreadablewarning: … 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 loopbackwarning: the admin API is not bound to loopback — …
RESCRIPTUM_ANSWER_TOKEN under 16 charactersa warning, not an error — refusing to start would leave a fleet unable to install
Any problem in the answer setone warning: line each, the same set check reports
RESCRIPTUM_PUBLIC_HOST unsetThe 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 bindwarning: 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 unlistableone warning: media: … line — a fleet must never be unable to install because one image is odd

Compile-time options

FeatureDefaultEffect
sqliteonThe SQLite store and the admin API
bootonThe 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.

BuildBytes
both (default)2,813,712
sqlite only2,557,592
boot only1,649,048
neither1,392,544

Fixed limits

Not configurable, and deliberately so:

LimitValueWhere
Request body1 MBanswer endpoint — an aberrant Content-Length is refused from the header
Document size256 KBadmin API PUT
Captured requests1000 capturescounted from the directory at startup, so a restart does not start again
Admin failures before a block5 within 60 sblock doubles to a maximum of 900 s
Addresses tracked by the guard4096so the guard cannot be turned into a memory leak
Listing reload backstop1 sforces 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

RequestResponse
POST any paththe answer, content-typed by its format
GET any paththe same
GET /health200 OK, body OK\n — no token needed, never rate-limited
any other method405

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

CodeWhen
200an answer applied
400the body could not be read
401RESCRIPTUM_ANSWER_TOKEN is set and the request did not present it
404nothing claimed the request and there is no default for the format asked for
405a method other than GET or POST
413body over 1 MB, or a Content-Length claiming one
500a document would not parse, a group is missing, a template could not be filled, or the lookup panicked
503at RESCRIPTUM_MAX_CONNECTIONS — written promptly, then the connection closes

Response headers

HeaderValue
Content-Typefrom the answer’s format — see the table below
Content-Lengthalways set
Connectionclose
WWW-AuthenticateBearer, on 401
FormatContent-Type
toml, and every text format (ks, preseed, cfg, seed, ipxe)text/plain; charset=utf-8
yaml, ymltext/yaml; charset=utf-8
json, ignapplication/json
xml, autoyast, unattendapplication/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 cap1 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 timeoutRESCRIPTUM_TIMEOUT_SECS, default 10 s
Whole-connection deadlinethe 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
ConcurrencyRESCRIPTUM_MAX_CONNECTIONS in flight; over that, a 503 and close rather than queueing
Authenticationonly 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.

RequestDoes
GET /machines, GET /groupslist identifiers
GET /machines/{id}, GET /groups/{name}, GET /defaultthe stored document, as written
PUT /machines/{id}, PUT /groups/{name}, PUT /defaultstore a document
DELETE /machines/{id}, DELETE /groups/{name}, DELETE /defaultremove one
GET /resolve/{id}the merged answer that machine would receive
GET /checkcurrent problems
GET /healthliveness — no token, never blocked

All document endpoints take ?format=<ext>, defaulting to toml.

CodeWhen
200done
400malformed document, invalid identifier, or a non-UTF-8 body
401missing or wrong token
404no such document or endpoint; nothing resolves for that identifier
409the write would have broken the answer set (rolled back), or a resolve that could not render
413document over 256 KB
429this address is blocked; Retry-After says for how long
500the 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.

ExtensionFamilyLayeringContent-Type
tomlTOMLstructural mergetext/plain; charset=utf-8
yaml, ymlYAMLstructural mergetext/yaml; charset=utf-8
json, ignJSONstructural mergeapplication/json
xml, autoyast, unattendXMLstructural merge, by elementapplication/xml; charset=utf-8
ks, cfg, preseed, seed, ipxetextconcatenation in layer ordertext/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.

SegmentServesTypical use
proxmox, pve, toml.tomlProxmox VE
debian, preseed.preseed, .seedDebian preseed
rhel, centos, fedora, alma, rocky, kickstart, ks.kskickstart
ubuntu, autoinstall, cloudinit, nocloud, yaml, yml.yaml, .ymlUbuntu autoinstall, cloud-init
flatcar, coreos, ignition, ign.ign, .jsonIgnition
suse, opensuse, autoyast.autoyast, .xmlAutoYaST
windows, unattend.unattend, .xmlWindows 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. .ks and .preseed are both text documents; filtering by family would let a preseed answer /rhel/ks.
  • seed is 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 .seed extension still exists, and /debian/ serves it.)

Control keys, per format

Stripped before the answer is sent.

FormatSpelling
TOMLtop-level extends = "base", members = […], [match] table
YAMLtop-level extends:, members:, match:
JSONtop-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 formatsText formats
Maps / objects / elementsmerge recursively
Scalarshigher layer replaces
Arrays / listsreplace, never append
Whole documentconcatenated 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

FormatToolInvoked as
tomlproxmox-auto-install-assistantvalidate-answer <file>
xml, autoyast, unattendxmllint--noout <file>
ksksvalidator<file>
everything elsenone 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.

CommandDoes
rescriptumrun 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 checkrender 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 --applymove those documents into a directory each
rescriptum configshow the configuration, and where each value comes from
rescriptum config --jsonthe same, for a settings panel
rescriptum config --value KEYone 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 --helpusage 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
FormFacts 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 FILEthe 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. So render … > answer.toml gives 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

StatusMeans
0success
1the 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.