rescriptum
Traps already hit
Development
Traps already hit
Traps already hit
Each of these cost real time. None is obvious from the code alone.
Runtime, not compile time
hyper panics if a timeout is set without a timer. http1::Builder::header_read_timeout
requires .timer(TokioTimer::new()). Omit it and every connection panics at runtime —
it does not fail to compile. The integration tests caught this; unit tests could not have.
header_read_timeout stops at the end of the headers. hyper has no body-read timeout,
so a client that promises a body and sends nothing would park a connection indefinitely.
The whole-connection tokio::time::timeout in connection() is what covers that. Both
are needed; neither is redundant.
hyper emits header names lowercased. That is correct — they are case-insensitive — so
assert on a lowercased copy. See has_header in the integration tests.
Performance
fs::metadata per directory entry is a stat syscall each. DirEntry::file_type()
comes back free with the readdir on Unix; only a symlink needs the stat to resolve. That
alone was worth 65% at 2,000 files, before caching was added.
Editing a group file’s contents changes no directory mtime. Only RELOAD_BACKSTOP
(1 s) picks that up, which is why the backstop is not redundant with the mtime check. An
integration test covers it.
An aberrant Content-Length must be refused from the header, not by letting Limited
trip after buffering a megabyte.
Closing on a peer that is still writing discards the response you just wrote. The
kernel sends a reset, and the reset throws away the unread bytes — so the client sees a
dropped connection, not your answer. shed() had exactly this: it wrote its 503 and
closed immediately, so the installer it was trying to tell “retry” got a connection
reset instead. It now drains briefly first, the way the admin API’s put() already did.
A test at the connection cap pins it.
- macOS lets an unprivileged process bind UDP port 69; Linux does not. So a test that
reaches the default TFTP address takes a different branch on each platform —
boot checkcalls an obtainable-but-silent port a note and an unbindable one a problem, which is the right rule and exactly what makes the test platform-dependent. It passed locally and failed in CI for a reason that had nothing to do with the change. Any test that setsRESCRIPTUM_BOOT_DIRmust also setRESCRIPTUM_TFTP_ADDR=offunless the probe is the subject;tests/tftp.rscovers the unbindable port on a high one. - A branch developed entirely offline has never met the CI. This one accumulated 57 commits before its first push, and the first run failed on two things no local run could see: a clippy five versions newer than the pinned local toolchain, and a Linux-only port permission. Push early enough to find out, or expect to.
Selection and formats
A Mac editing the answers directory over SMB can hijack a machine’s answer. macOS writes
an AppleDouble ._<name> beside a file whose extended attributes the filesystem will not
take — ._proxmox.toml has an extension that is on the allowlist. With a directory per
identity it is worse than it was when answers were flat: it is a second .toml in a
directory that may hold only one, and it sorts before the real one, so a rule that took
the first would hand every request a binary body. The machine being configured then receives
a parse error instead of its answer. .DS_Store is harmless only by luck (its extension is
not on the list). The file store skips every entry whose name starts with .; found on a
real NAS, not by reading anything.
Normalizing a selector pattern strips * and ? unless you use normalize_pattern —
which turns every glob into a literal, quietly.
In a text format, a placeholder inside a comment is still a placeholder. Kind::Text
is an opaque string, so substitution runs over the whole document — a {{ mac }} written
in a # comment to explain templating still has to resolve, and fails check exactly
like a real one. Found while adding the .ipxe and .cfg worked examples.
A GET has no body, so the haystack is empty. Query values and path segments must feed it too, or a document named after a MAC can never answer a preseed or kickstart fetch.
quick-xml emits entity references as their own events. Ignoring them welds the
surrounding text fragments together: 1 < 2 & 3 came back as 123.
Repeated XML siblings are not always a list. If they carry a discriminating attribute
they are a keyed collection; treating them as a list replaced every <component> in an
unattend.xml with the one the overlay happened to mention.
Two documents with the same stem are not duplicates. An earlier put deleted the other
formats of a stem to avoid “two answers for one machine”. That was the wrong model: they
are that machine’s answers for two operating systems.
Filter endpoints on the extension, not the Kind. .ks and .preseed are both
Kind::Text; filtering by family would let a preseed answer /rhel/ks.
An alias must be specific enough that nobody reaches it by accident. seed was removed
as an endpoint alias: s=http://server/seed/ is an ordinary NoCloud seed URL, and it
serves YAML.
The admin API
Read the request body before rejecting a request. Answering and closing while the
client is still writing earns an ECONNRESET instead of the response. put() drains
first, then validates the identifier.
Admin responses must set Connection: close. Without it every test client waited out
the connection timeout — the suite took 30 s instead of 0.4 s — and the eventual drop
sometimes arrived as a reset rather than a clean EOF.
Identifiers become filenames. export and the file store build paths from machine ids
and group names, so valid_id is enforced at the API boundary and in both stores.
Testing
cargo test does not rebuild target/debug/rescriptum. A manual check against a stale
binary once “reproduced” a bug that had already been fixed. Rebuild before poking at the
binary by hand.
Cache-invalidation tests must share one Answers instance. A test that constructs a
fresh one per call bypasses the cache entirely and silently proves nothing.
Assert on parsed values, not on formatting. Replacing a table with a scalar leaves the
key’s original decor, so the output can read value= 3 — valid TOML, different text.
A python/sed patch that “succeeds” may have matched nothing. Two edits in this
project’s history silently no-opped and were only caught by checking test counts
afterwards. Assert the old text was found before writing.
Packaging for DSM
A shell script that works on macOS is not a shell script that works on CI. Two found by
running the harnesses in a Linux container rather than trusting them: stat -f '%Lp' is the
format flag on BSD and filesystem status on GNU — where it succeeds, printing overlayfs
trivia into a variable that was supposed to hold a file mode, so the fallback never fires.
Ask GNU first (stat -c '%a' || stat -f '%Lp'), which fails cleanly on macOS. And shasum
is a Perl script that a minimal Debian does not have: sha256sum is coreutils and is
everywhere on Linux. Ubuntu runners carry both, which is exactly how a script like that ships
broken to everyone else.
musl 1.2 cannot run on Synology’s ARMv7 kernels, and the symptom names nothing. Those
kernels are 3.10 and answer the time64 syscalls with EINVAL; musl falls back to the
32-bit ones only on ENOSYS, so clock_gettime, clock_nanosleep and the timed futex all
fail. The binary installs, answers --version, and panics at time.rs:131 with
Os { code: 22, kind: InvalidInput } the moment it wants a timestamp — which looks like an
ABI or a too-old-kernel problem and is neither. The armv7 target is glibc with a 2.17 floor
for this reason; 64-bit targets have no time32/time64 split and are unaffected. Proven with
a ten-line C probe on the machine, not by reading anything.
SYNOPKG_PKGDEST is /volume1/@appstore/<package>, not /var/packages/<package>/target.
The second is a symlink to the first, so dirname "$SYNOPKG_PKGDEST" is /volume1/@appstore
and everything hung off it — etc/, var/, shares/ — lands where nothing reads it. The
package root is a fixed path. This one costs a service that installs perfectly and never
starts, and a fake-tree harness cannot catch it: in a tree you built yourself, dirname is
right by construction.
$SYNOPKG_TEMP_UPGRADE_FOLDER outlives the upgrade that created it. A fresh install
that reads it finds the configuration of an installation the user removed, and silently
restores it — tokens and all. Restoring from it has to require SYNOPKG_PKG_STATUS = UPGRADE.
etc/ and var/ survive an uninstall. They are symlinks into /volume1/@appconf/<pkg>
and /volume1/@appdata/<pkg>, which DSM keeps. So the env file, tokens included, stays on
the volume after the package is gone — which the documentation has to say, and which makes
a rig that does not clear them fail on the next run for reasons belonging to the last one.
A DSM account named after the package user is destroyed with it. conf/privilege’s
username creates a system user at install; an administrator of the same name is shadowed
by it and removed on uninstall.
The firewall directory is /usr/local/etc/services.d/ — plural. The developer guide says
service.d, which does not exist. The port-config worker acquires after postinst, so
the wizard’s port does reach the firewall entry on a fresh install.
port-config and usr-local-linker acquire when the package is enabled, not when
postinst runs: checked any earlier they are always absent.
The generated unit has no Restart= — Type=oneshot, RemainAfterExit=yes,
TimeoutStartSec=3600. DSM does not restart the process if it dies.
postinst runs on an upgrade too, and it runs before postupgrade. So “the env
file is absent” is not the same question as “this is a fresh install”: on an upgrade where
etc/ did not survive, writing defaults there destroys the user’s port and tokens before
the restore ever runs. postinst checks $SYNOPKG_TEMP_UPGRADE_FOLDER before it decides.
Found by simulating that exact case, not by reading the documented sequence.
The old version’s preuninst/postuninst run during an upgrade. Anything destructive
in them therefore runs every time somebody upgrades — and the first published .spk is
the one whose uninstall scripts will run during everybody’s first upgrade. They cannot be
fixed later.
status returning 1 means “crashed, stale pidfile”, not “stopped”. A cleanly stopped
package is 3. Returning 1 tells Package Center the service died.
prestart runs at boot, and DSM calls it whether or not you wrote it —
precheckstartstop defaults to "yes". A case that exits non-zero on an unrecognised
verb stops the package from ever starting after a reboot, with a symptom (“works by hand,
never after a reboot”) that looks like anything but a missing case arm.
The lifecycle scripts are not root. run-as: package governs them, not only the
service — so a chown outside the package tree, or synopkghelper, fails, possibly
silently.
data-share runs at package start, not at install, so nothing in postinst may
assume the shared folder exists. And a username that does not match its permission list
creates the share and grants it to nobody, without a word.
A logrotate stanza without copytruncate silently ends logging: log::init opens the
file once and never reopens it, so a rotation moves the inode out from under a server that
carries on writing to a file with no name.
A .spk whose outer tar is gzipped is rejected with “invalid file format” and no
further detail. So is one carrying macOS ._ members. check-spk.sh asserts both.
There is exactly one route to port 69 on DSM 7, and it is setcap. All four were
tried on a 7.2.2 machine on 2026-08-27, because the claim “DSM 7 does not let an unsigned
package run as root” had sat in CLAUDE.md for a while with no measurement behind it —
true, but by luck.
| Route | Result |
|---|---|
"defaults": {"run-as": "root"} in conf/privilege | refused — synopkg error 319, invalid package privilege content, stage: install_failed |
"ctrl-script": [{"action":"start","run-as":"root"}] — the shape Synology’s own packages use (FileStation, QuickConnect and StorageManager all do) | refused, same error 319 |
cap_net_bind_service embedded as a security.capability xattr in package.tgz | installs fine — the pax inner format is accepted — but Package Center strips the xattr, and getcap comes back empty |
setcap cap_net_bind_service=+ep on the installed binary, as root, after install | works; the package then binds udp/69 as its own unprivileged user alongside 8000 and 8001 |
net.ipv4.ip_unprivileged_port_start does not exist on that kernel, so that route is
closed too. /volume1 is btrfs with nodev but not nosuid, so file capabilities do
work there, and /usr/bin/setcap exists at mode 0700.
Root on DSM 7 is gated on being a Synology package, and libsynopkg.so.1 says so in
so many words. Reading its strings on a 7.2.2 machine turns the measurement above into
an explanation. A package that does not pass the signature check (verifyPackageSignature
lives in the same library) is refused all of this:
Failed to pass privilege check, ctrl-script and executable section should not exist
Failed to pass privilege check, defaults should be provided and defaults.run-as should be package
Failed to pass privilege check, join-groupname should not contains admin group
Failed to pass privilege check, tool capabilities should not exist
Failed to pass privilege check, tool user should be package
Failed to pass privilege check, non-synology package should not use privilege migration
Which is why FileStation, StorageManager, QuickConnect and SecureSignIn all carry
"ctrl-script": [{"action": "start", "run-as": "root"}] in their own conf/privilege and
we cannot: the shape is legal, the signature is what makes it legal for them.
The line that matters most is tool capabilities should not exist. DSM’s privilege
format has a native capabilities field — documented as
"capabilities": "cap_chown,cap_net_raw" on a tool entry since 7.0-40656, and
SYNOPackageTool::Privilege::ChangeCapabilities is right there in the library. A signed
package declares cap_net_bind_service and never needs setcap at all. The mechanism we
want exists, is documented, and is closed to us.
Synology’s developer guide states the rule outright: “If you are developing a package with root privilege, you are not able to install that package unless it is signed by synology.” So it is their signature, not any trusted publisher’s — which answers what the library string left open. SynoCommunity hit the same wall (spksrc#4170, #4215).
There is one documented bypass and it is not a distribution path: a development
token. Generate debug.dat from Support Center → Support Services, send it to Synology,
receive a signed token, drop it at /var/packages/syno_dev_token. It is valid only on
the NAS that generated the debug.dat, so shipping this way would mean every single user
doing a round trip with Synology before they could install. setcap is one local command
and strictly better for them.
Conclusion, and it is settled rather than provisional: the manual setcap is the price
of not being signed by Synology, and no packaging change removes it. If the package is
ever signed, the manual step and the boot-up task are both replaced by three lines in
conf/privilege.
setcap works on a DS416j too, and that was not a given. The four routes to port 69
were measured on a 7.2.2 VM, which is x86_64 with /volume1 on btrfs mounted nodev but
not nosuid — and a volume mounted nosuid makes the kernel ignore file capabilities
entirely, which would have closed the last open route on the one machine this project
exists for. Measured on the DS416j (ARMv7, armada38x): the capability holds, the package
binds udp/69 as its unprivileged user, and boot check reports
0.0.0.0:69 handed over ipxe-arm64.efi — a real read request answered with real data.
The capability belongs to the file, so an upgrade drops it. A new version replaces the binary and the capability goes with the old one — which is why the package documents a Task Scheduler boot-up task rather than a one-off command, and why a failed TFTP bind is not fatal: when it was, that upgrade took the answer endpoint down too.
Binding is not a health check, and it proves the opposite of what it looks like. A
bind that succeeds on the TFTP port means nothing is listening — the degraded state, not
the healthy one — and a bind that fails cannot tell this server apart from another daemon
squatting the port, because both are AddrInUse. boot check therefore sends a real read
request and reports what a machine would get. The first version of it reported “already in
use — that is this server, if it is running” and a test with a squatter on the port
immediately showed that to be a guess.
A new setting never reaches an installation that already exists, unless something
puts it there. The live env file is written only when absent — correct, because an upgrade
must never replace somebody’s port and tokens with defaults — but on its own that makes a
new feature invisible to every install that predates it. Boot media shipped with the
folders created, the loaders seeded and 69/udp registered with the firewall, and
RESCRIPTUM_BOOT_DIR never arriving, so boot check answered “boot assets are off” on a
DS416j that had everything else in place. etc/ surviving an uninstall means even removing
and reinstalling does not fix it. The .env.example was no help, because nothing makes
anybody read it.
postinst now appends keys the live file has never heard of, touching nothing that is
present. A commented-out key counts as present, and that is the safety property: it is
how an operator says “I know about this one and I do not want it”. Deleting a line means
“never heard of it” and gets it back; commenting it out means no, and is respected.
The DSM desktop application
Eight things, measured on a DSM 7.2.2 virtual machine and on a DS416j running 7.1.1, and none of them in the developer guide.
A default computed at runtime has to be computed in settings() too. The panel renders
a variable’s default as the field’s value, so a default that exists only where the server
consumes it shows as an empty box — while the server runs on an address it derived and
never displayed. RESCRIPTUM_PUBLIC_HOST shipped that way; the operator had no way to see
which address their machines would be sent to short of reading the startup log. Two entries
in KNOWN are like this, and both are special-cased in settings(): the worker count and
the public host. A third would need the same treatment, and nothing in the type system says
so.
A CGI under /webman/3rdparty/<pkg>/ runs as the owner of the script. Not as http,
and not as root — as whoever owns the file. DSM chowns a package’s tree to the package
user, so the application’s backend runs as rescriptum and can read the 0600 env file it
owns, which is the entire reason the configuration can be edited while the server is
stopped. Proven by chowning the same script two ways and watching id change. A script
left owned by root does run as root there, so do not leave one lying about.
That path is not authenticated by DSM. An unauthenticated request reaches the script
and is answered 200. Whatever guards a package’s CGI, the package wrote it — here that is
authenticate.cgi plus an administrators check, and losing either would be silent.
su in a CGI hangs the request. Without </dev/null it inherits the CGI’s stdin — a
pipe from the web server that nothing will close — reads from it, and never returns. The
status page simply stopped mid-answer. Then, once that was fixed, it failed anyway with
“Permission denied”, because a non-root process cannot become anybody. Both were wasted
effort: the script already is the user in question, so a plain test -r was the answer
all along.
The framework a package can use is the machine’s choice, not Synology’s guide’s. DSM
7.2 ships a Vue UI framework and the current guide documents only that one. The DS416j is
capped at DSM 7.1.1, where Vue is undefined — so an application built on it installs and
gives that machine an icon that opens nothing. ExtJS is on both (7.1.1 and 7.2.2 measured),
which is why there is one application rather than two.
The guide’s own ExtJS example does not run. It declares classes with Ext.define and
chains with callParent; against SYNO.SDS.AppInstance that throws Cannot read properties of null (reading 'apply') before the window ever appears. This is ExtJS 3.4.1 with an
Ext.define shim over it: use Ext.define for the declaration — DSM’s launcher finds the
class that way and it does set superclass — and then call
MyClass.superclass.constructor.call(this, config) rather than callParent.
DSM’s taskbar calls getWindowTitle() on the window. Without a title it throws from
inside DSM’s own taskbar bundle, and the application then fails to open at all — with a
stack trace that names Synology’s code and not yours.
Do not name a method show. Ext.Window.prototype.show() is what DSM calls to display
the window, so a show(which) added for switching tabs silently overrode it: the window was
built, laid out, and rendered a correct thumbnail in the taskbar preview — and never
appeared. Nothing threw, on either DSM version, which is what made it expensive: it was
found by bisecting from the guide’s minimal example upwards. Everything added to that
prototype shares a namespace with every method of Ext.Window, and that is a large
namespace.
fieldLabel is drawn by the form layout, not by the field. A syno_displayfield in a
plain Ext.Panel renders its value and silently drops its label, which turned the status
page into a bare column of values with nothing saying what they were. SYNO.ux.FormPanel,
or layout: 'form'.
Reproducible builds and browser caches disagree, and the browser wins. make-spk.sh
gives every packaged file a fixed mtime so the same inputs produce a byte-identical .spk.
nginx turns that into Last-Modified: 2019 with no Cache-Control, and a browser’s
heuristic freshness is a tenth of the file’s apparent age — years. An upgraded package went
on running the old JavaScript against the new backend, through a reinstall and a hard
reload. The application’s file is therefore named after the version and everything it
fetches itself carries ?v=; check-spk.sh asserts the name still moves.
Behaviour changes worth remembering
Answer documents must now be valid. Before merging they were served as opaque bytes, so
a malformed one reached the installer; now it is a 500 with the parse error in the log.
That is the better failure, but it is a behaviour change — fixtures written as YAML-ish
text stopped working when it landed.
{{ machine }} is bound only when a machine document matched. A machine claimed by a
group’s members, with no document of its own, resolves with machine: None — so
{{ machine }} in a group fails for exactly the members it was meant to cover. Use a
request fact such as {{ mac }} there.