rescriptum · an answer written for this machine
EN
On this page

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.

Updated Edit this page