← Back to site Loading…

rescriptum

Traps already hit

Development

Generated on August 30, 2026

Traps already hit

Traps already hit

Each of these cost real time. None is obvious from the code alone.

Runtime, not compile time

hyper panics if a timeout is set without a timer. http1::Builder::header_read_timeout requires .timer(TokioTimer::new()). Omit it and every connection panics at runtime — it does not fail to compile. The integration tests caught this; unit tests could not have.

header_read_timeout stops at the end of the headers. hyper has no body-read timeout, so a client that promises a body and sends nothing would park a connection indefinitely. The whole-connection tokio::time::timeout in connection() is what covers that. Both are needed; neither is redundant.

hyper emits header names lowercased. That is correct — they are case-insensitive — so assert on a lowercased copy. See has_header in the integration tests.

Performance

fs::metadata per directory entry is a stat syscall each. DirEntry::file_type() comes back free with the readdir on Unix; only a symlink needs the stat to resolve. That alone was worth 65% at 2,000 files, before caching was added.

Editing a group file’s contents changes no directory mtime. Only RELOAD_BACKSTOP (1 s) picks that up, which is why the backstop is not redundant with the mtime check. An integration test covers it.

An aberrant Content-Length must be refused from the header, not by letting Limited trip after buffering a megabyte.

Closing on a peer that is still writing discards the response you just wrote. The kernel sends a reset, and the reset throws away the unread bytes — so the client sees a dropped connection, not your answer. shed() had exactly this: it wrote its 503 and closed immediately, so the installer it was trying to tell “retry” got a connection reset instead. It now drains briefly first, the way the admin API’s put() already did. A test at the connection cap pins it.

  • macOS lets an unprivileged process bind UDP port 69; Linux does not. So a test that reaches the default TFTP address takes a different branch on each platform — boot check calls an obtainable-but-silent port a note and an unbindable one a problem, which is the right rule and exactly what makes the test platform-dependent. It passed locally and failed in CI for a reason that had nothing to do with the change. Any test that sets RESCRIPTUM_BOOT_DIR must also set RESCRIPTUM_TFTP_ADDR=off unless the probe is the subject; tests/tftp.rs covers the unbindable port on a high one.
  • A branch developed entirely offline has never met the CI. This one accumulated 57 commits before its first push, and the first run failed on two things no local run could see: a clippy five versions newer than the pinned local toolchain, and a Linux-only port permission. Push early enough to find out, or expect to.

Selection and formats

A Mac editing the answers directory over SMB can hijack a machine’s answer. macOS writes an AppleDouble ._<name> beside a file whose extended attributes the filesystem will not take — ._proxmox.toml has an extension that is on the allowlist. With a directory per identity it is worse than it was when answers were flat: it is a second .toml in a directory that may hold only one, and it sorts before the real one, so a rule that took the first would hand every request a binary body. The machine being configured then receives a parse error instead of its answer. .DS_Store is harmless only by luck (its extension is not on the list). The file store skips every entry whose name starts with .; found on a real NAS, not by reading anything.

Normalizing a selector pattern strips * and ? unless you use normalize_pattern — which turns every glob into a literal, quietly.

In a text format, a placeholder inside a comment is still a placeholder. Kind::Text is an opaque string, so substitution runs over the whole document — a {{ mac }} written in a # comment to explain templating still has to resolve, and fails check exactly like a real one. Found while adding the .ipxe and .cfg worked examples.

A GET has no body, so the haystack is empty. Query values and path segments must feed it too, or a document named after a MAC can never answer a preseed or kickstart fetch.

quick-xml emits entity references as their own events. Ignoring them welds the surrounding text fragments together: 1 &lt; 2 &amp; 3 came back as 123.

Repeated XML siblings are not always a list. If they carry a discriminating attribute they are a keyed collection; treating them as a list replaced every <component> in an unattend.xml with the one the overlay happened to mention.

Two documents with the same stem are not duplicates. An earlier put deleted the other formats of a stem to avoid “two answers for one machine”. That was the wrong model: they are that machine’s answers for two operating systems.

Filter endpoints on the extension, not the Kind. .ks and .preseed are both Kind::Text; filtering by family would let a preseed answer /rhel/ks.

An alias must be specific enough that nobody reaches it by accident. seed was removed as an endpoint alias: s=http://server/seed/ is an ordinary NoCloud seed URL, and it serves YAML.

The admin API

Read the request body before rejecting a request. Answering and closing while the client is still writing earns an ECONNRESET instead of the response. put() drains first, then validates the identifier.

Admin responses must set Connection: close. Without it every test client waited out the connection timeout — the suite took 30 s instead of 0.4 s — and the eventual drop sometimes arrived as a reset rather than a clean EOF.

Identifiers become filenames. export and the file store build paths from machine ids and group names, so valid_id is enforced at the API boundary and in both stores.

Testing

cargo test does not rebuild target/debug/rescriptum. A manual check against a stale binary once “reproduced” a bug that had already been fixed. Rebuild before poking at the binary by hand.

Cache-invalidation tests must share one Answers instance. A test that constructs a fresh one per call bypasses the cache entirely and silently proves nothing.

Assert on parsed values, not on formatting. Replacing a table with a scalar leaves the key’s original decor, so the output can read value= 3 — valid TOML, different text.

A python/sed patch that “succeeds” may have matched nothing. Two edits in this project’s history silently no-opped and were only caught by checking test counts afterwards. Assert the old text was found before writing.

Packaging for DSM

A shell script that works on macOS is not a shell script that works on CI. Two found by running the harnesses in a Linux container rather than trusting them: stat -f '%Lp' is the format flag on BSD and filesystem status on GNU — where it succeeds, printing overlayfs trivia into a variable that was supposed to hold a file mode, so the fallback never fires. Ask GNU first (stat -c '%a' || stat -f '%Lp'), which fails cleanly on macOS. And shasum is a Perl script that a minimal Debian does not have: sha256sum is coreutils and is everywhere on Linux. Ubuntu runners carry both, which is exactly how a script like that ships broken to everyone else.

musl 1.2 cannot run on Synology’s ARMv7 kernels, and the symptom names nothing. Those kernels are 3.10 and answer the time64 syscalls with EINVAL; musl falls back to the 32-bit ones only on ENOSYS, so clock_gettime, clock_nanosleep and the timed futex all fail. The binary installs, answers --version, and panics at time.rs:131 with Os { code: 22, kind: InvalidInput } the moment it wants a timestamp — which looks like an ABI or a too-old-kernel problem and is neither. The armv7 target is glibc with a 2.17 floor for this reason; 64-bit targets have no time32/time64 split and are unaffected. Proven with a ten-line C probe on the machine, not by reading anything.

SYNOPKG_PKGDEST is /volume1/@appstore/<package>, not /var/packages/<package>/target. The second is a symlink to the first, so dirname "$SYNOPKG_PKGDEST" is /volume1/@appstore and everything hung off it — etc/, var/, shares/ — lands where nothing reads it. The package root is a fixed path. This one costs a service that installs perfectly and never starts, and a fake-tree harness cannot catch it: in a tree you built yourself, dirname is right by construction.

$SYNOPKG_TEMP_UPGRADE_FOLDER outlives the upgrade that created it. A fresh install that reads it finds the configuration of an installation the user removed, and silently restores it — tokens and all. Restoring from it has to require SYNOPKG_PKG_STATUS = UPGRADE.

etc/ and var/ survive an uninstall. They are symlinks into /volume1/@appconf/<pkg> and /volume1/@appdata/<pkg>, which DSM keeps. So the env file, tokens included, stays on the volume after the package is gone — which the documentation has to say, and which makes a rig that does not clear them fail on the next run for reasons belonging to the last one.

A DSM account named after the package user is destroyed with it. conf/privilege’s username creates a system user at install; an administrator of the same name is shadowed by it and removed on uninstall.

The firewall directory is /usr/local/etc/services.d/ — plural. The developer guide says service.d, which does not exist. The port-config worker acquires after postinst, so the wizard’s port does reach the firewall entry on a fresh install.

port-config and usr-local-linker acquire when the package is enabled, not when postinst runs: checked any earlier they are always absent.

The generated unit has no Restart=Type=oneshot, RemainAfterExit=yes, TimeoutStartSec=3600. DSM does not restart the process if it dies.

postinst runs on an upgrade too, and it runs before postupgrade. So “the env file is absent” is not the same question as “this is a fresh install”: on an upgrade where etc/ did not survive, writing defaults there destroys the user’s port and tokens before the restore ever runs. postinst checks $SYNOPKG_TEMP_UPGRADE_FOLDER before it decides. Found by simulating that exact case, not by reading the documented sequence.

The old version’s preuninst/postuninst run during an upgrade. Anything destructive in them therefore runs every time somebody upgrades — and the first published .spk is the one whose uninstall scripts will run during everybody’s first upgrade. They cannot be fixed later.

status returning 1 means “crashed, stale pidfile”, not “stopped”. A cleanly stopped package is 3. Returning 1 tells Package Center the service died.

prestart runs at boot, and DSM calls it whether or not you wrote it — precheckstartstop defaults to "yes". A case that exits non-zero on an unrecognised verb stops the package from ever starting after a reboot, with a symptom (“works by hand, never after a reboot”) that looks like anything but a missing case arm.

The lifecycle scripts are not root. run-as: package governs them, not only the service — so a chown outside the package tree, or synopkghelper, fails, possibly silently.

data-share runs at package start, not at install, so nothing in postinst may assume the shared folder exists. And a username that does not match its permission list creates the share and grants it to nobody, without a word.

A logrotate stanza without copytruncate silently ends logging: log::init opens the file once and never reopens it, so a rotation moves the inode out from under a server that carries on writing to a file with no name.

A .spk whose outer tar is gzipped is rejected with “invalid file format” and no further detail. So is one carrying macOS ._ members. check-spk.sh asserts both.

There is exactly one route to port 69 on DSM 7, and it is setcap. All four were tried on a 7.2.2 machine on 2026-08-27, because the claim “DSM 7 does not let an unsigned package run as root” had sat in CLAUDE.md for a while with no measurement behind it — true, but by luck.

RouteResult
"defaults": {"run-as": "root"} in conf/privilegerefusedsynopkg error 319, invalid package privilege content, stage: install_failed
"ctrl-script": [{"action":"start","run-as":"root"}] — the shape Synology’s own packages use (FileStation, QuickConnect and StorageManager all do)refused, same error 319
cap_net_bind_service embedded as a security.capability xattr in package.tgzinstalls fine — the pax inner format is accepted — but Package Center strips the xattr, and getcap comes back empty
setcap cap_net_bind_service=+ep on the installed binary, as root, after installworks; the package then binds udp/69 as its own unprivileged user alongside 8000 and 8001

net.ipv4.ip_unprivileged_port_start does not exist on that kernel, so that route is closed too. /volume1 is btrfs with nodev but not nosuid, so file capabilities do work there, and /usr/bin/setcap exists at mode 0700.

Root on DSM 7 is gated on being a Synology package, and libsynopkg.so.1 says so in so many words. Reading its strings on a 7.2.2 machine turns the measurement above into an explanation. A package that does not pass the signature check (verifyPackageSignature lives in the same library) is refused all of this:

Failed to pass privilege check, ctrl-script and executable section should not exist
Failed to pass privilege check, defaults should be provided and defaults.run-as should be package
Failed to pass privilege check, join-groupname should not contains admin group
Failed to pass privilege check, tool capabilities should not exist
Failed to pass privilege check, tool user should be package
Failed to pass privilege check, non-synology package should not use privilege migration

Which is why FileStation, StorageManager, QuickConnect and SecureSignIn all carry "ctrl-script": [{"action": "start", "run-as": "root"}] in their own conf/privilege and we cannot: the shape is legal, the signature is what makes it legal for them.

The line that matters most is tool capabilities should not exist. DSM’s privilege format has a native capabilities field — documented as "capabilities": "cap_chown,cap_net_raw" on a tool entry since 7.0-40656, and SYNOPackageTool::Privilege::ChangeCapabilities is right there in the library. A signed package declares cap_net_bind_service and never needs setcap at all. The mechanism we want exists, is documented, and is closed to us.

Synology’s developer guide states the rule outright: “If you are developing a package with root privilege, you are not able to install that package unless it is signed by synology.” So it is their signature, not any trusted publisher’s — which answers what the library string left open. SynoCommunity hit the same wall (spksrc#4170, #4215).

There is one documented bypass and it is not a distribution path: a development token. Generate debug.dat from Support Center → Support Services, send it to Synology, receive a signed token, drop it at /var/packages/syno_dev_token. It is valid only on the NAS that generated the debug.dat, so shipping this way would mean every single user doing a round trip with Synology before they could install. setcap is one local command and strictly better for them.

Conclusion, and it is settled rather than provisional: the manual setcap is the price of not being signed by Synology, and no packaging change removes it. If the package is ever signed, the manual step and the boot-up task are both replaced by three lines in conf/privilege.

setcap works on a DS416j too, and that was not a given. The four routes to port 69 were measured on a 7.2.2 VM, which is x86_64 with /volume1 on btrfs mounted nodev but not nosuid — and a volume mounted nosuid makes the kernel ignore file capabilities entirely, which would have closed the last open route on the one machine this project exists for. Measured on the DS416j (ARMv7, armada38x): the capability holds, the package binds udp/69 as its unprivileged user, and boot check reports 0.0.0.0:69 handed over ipxe-arm64.efi — a real read request answered with real data.

The capability belongs to the file, so an upgrade drops it. A new version replaces the binary and the capability goes with the old one — which is why the package documents a Task Scheduler boot-up task rather than a one-off command, and why a failed TFTP bind is not fatal: when it was, that upgrade took the answer endpoint down too.

Binding is not a health check, and it proves the opposite of what it looks like. A bind that succeeds on the TFTP port means nothing is listening — the degraded state, not the healthy one — and a bind that fails cannot tell this server apart from another daemon squatting the port, because both are AddrInUse. boot check therefore sends a real read request and reports what a machine would get. The first version of it reported “already in use — that is this server, if it is running” and a test with a squatter on the port immediately showed that to be a guess.

A new setting never reaches an installation that already exists, unless something puts it there. The live env file is written only when absent — correct, because an upgrade must never replace somebody’s port and tokens with defaults — but on its own that makes a new feature invisible to every install that predates it. Boot media shipped with the folders created, the loaders seeded and 69/udp registered with the firewall, and RESCRIPTUM_BOOT_DIR never arriving, so boot check answered “boot assets are off” on a DS416j that had everything else in place. etc/ surviving an uninstall means even removing and reinstalling does not fix it. The .env.example was no help, because nothing makes anybody read it.

postinst now appends keys the live file has never heard of, touching nothing that is present. A commented-out key counts as present, and that is the safety property: it is how an operator says “I know about this one and I do not want it”. Deleting a line means “never heard of it” and gets it back; commenting it out means no, and is respected.

The DSM desktop application

Eight things, measured on a DSM 7.2.2 virtual machine and on a DS416j running 7.1.1, and none of them in the developer guide.

A default computed at runtime has to be computed in settings() too. The panel renders a variable’s default as the field’s value, so a default that exists only where the server consumes it shows as an empty box — while the server runs on an address it derived and never displayed. RESCRIPTUM_PUBLIC_HOST shipped that way; the operator had no way to see which address their machines would be sent to short of reading the startup log. Two entries in KNOWN are like this, and both are special-cased in settings(): the worker count and the public host. A third would need the same treatment, and nothing in the type system says so.

A CGI under /webman/3rdparty/<pkg>/ runs as the owner of the script. Not as http, and not as root — as whoever owns the file. DSM chowns a package’s tree to the package user, so the application’s backend runs as rescriptum and can read the 0600 env file it owns, which is the entire reason the configuration can be edited while the server is stopped. Proven by chowning the same script two ways and watching id change. A script left owned by root does run as root there, so do not leave one lying about.

That path is not authenticated by DSM. An unauthenticated request reaches the script and is answered 200. Whatever guards a package’s CGI, the package wrote it — here that is authenticate.cgi plus an administrators check, and losing either would be silent.

su in a CGI hangs the request. Without </dev/null it inherits the CGI’s stdin — a pipe from the web server that nothing will close — reads from it, and never returns. The status page simply stopped mid-answer. Then, once that was fixed, it failed anyway with “Permission denied”, because a non-root process cannot become anybody. Both were wasted effort: the script already is the user in question, so a plain test -r was the answer all along.

The framework a package can use is the machine’s choice, not Synology’s guide’s. DSM 7.2 ships a Vue UI framework and the current guide documents only that one. The DS416j is capped at DSM 7.1.1, where Vue is undefined — so an application built on it installs and gives that machine an icon that opens nothing. ExtJS is on both (7.1.1 and 7.2.2 measured), which is why there is one application rather than two.

The guide’s own ExtJS example does not run. It declares classes with Ext.define and chains with callParent; against SYNO.SDS.AppInstance that throws Cannot read properties of null (reading 'apply') before the window ever appears. This is ExtJS 3.4.1 with an Ext.define shim over it: use Ext.define for the declaration — DSM’s launcher finds the class that way and it does set superclass — and then call MyClass.superclass.constructor.call(this, config) rather than callParent.

DSM’s taskbar calls getWindowTitle() on the window. Without a title it throws from inside DSM’s own taskbar bundle, and the application then fails to open at all — with a stack trace that names Synology’s code and not yours.

Do not name a method show. Ext.Window.prototype.show() is what DSM calls to display the window, so a show(which) added for switching tabs silently overrode it: the window was built, laid out, and rendered a correct thumbnail in the taskbar preview — and never appeared. Nothing threw, on either DSM version, which is what made it expensive: it was found by bisecting from the guide’s minimal example upwards. Everything added to that prototype shares a namespace with every method of Ext.Window, and that is a large namespace.

fieldLabel is drawn by the form layout, not by the field. A syno_displayfield in a plain Ext.Panel renders its value and silently drops its label, which turned the status page into a bare column of values with nothing saying what they were. SYNO.ux.FormPanel, or layout: 'form'.

Reproducible builds and browser caches disagree, and the browser wins. make-spk.sh gives every packaged file a fixed mtime so the same inputs produce a byte-identical .spk. nginx turns that into Last-Modified: 2019 with no Cache-Control, and a browser’s heuristic freshness is a tenth of the file’s apparent age — years. An upgraded package went on running the old JavaScript against the new backend, through a reinstall and a hard reload. The application’s file is therefore named after the version and everything it fetches itself carries ?v=; check-spk.sh asserts the name still moves.

Behaviour changes worth remembering

Answer documents must now be valid. Before merging they were served as opaque bytes, so a malformed one reached the installer; now it is a 500 with the parse error in the log. That is the better failure, but it is a behaviour change — fixtures written as YAML-ish text stopped working when it landed.

{{ machine }} is bound only when a machine document matched. A machine claimed by a group’s members, with no document of its own, resolves with machine: None — so {{ machine }} in a group fails for exactly the members it was meant to cover. Use a request fact such as {{ mac }} there.