Recipe for a hardened OpenRC service — the Alpine
sibling of recipe_debian_systemd.ts's hardened_systemd_unit.
hardened_openrc_service builds the /etc/init.d/<name> script for a
long-running network service (a compiled Rust daemon, a Deno process, a fuz_app
server), driven by
supervise-daemon so OpenRC restarts it on failure.
Hardening tiers
OpenRC's ceiling is below systemd's: it replicates the privilege axis
natively but not the filesystem-isolation axis.
- Tier A (default, OpenRC-native):
command_user (drop to the service
account), capabilities (the ambient set the option names, and every other
capability dropped from the bounding set), no_new_privs, umask (0077 unless
loosened), rc_ulimit, and supervise-daemon respawn — the privilege axis. No extra packages. Tier A
also self-provisions the service-owned log directory in a start_pre()
checkpath hook (which runs as root, before the privilege drop), so the
non-root service can write its output_log with no external directory resource. - Tier B (opt-in via
isolation): filesystem isolation (ProtectSystem=strict
/ PrivateTmp / ReadWritePaths) has no OpenRC-native equivalent, so the command
is wrapped in bubblewrap: a read-only root, a private /tmp, and the declared
read_write_paths bound read-write. It layers over Tier A (the sandboxed process
still drops to command_user, holds no capabilities (bwrap empties the bounding
set, so an ambient set capabilities raises doesn't reach the command), and sets
no_new_privs). Under supervise-daemon the wrap must be launched via
sh -c 'exec bwrap … -- cmd'
(passing bwrap as command=/command_args= mangles its -- separator), and
--die-with-parent tears the sandbox down cleanly on stop. Needs bubblewrap on
the target and a kernel allowing unprivileged user namespaces (Alpine's default).
The syscall-filter residual (SystemCallFilter) has no OpenRC analogue and is
documented and accepted.
Quoting
openrc-run sources the init script as a shell script, so every value is
written as one single-quoted word (single_quote): the sourcing reads it
literally, whatever it holds. OpenRC's supervise-daemon start then rebuilds
its command line from these variables and evals it (that is how
command_args carries quoted words), so a value it splices in — command,
user, group, working_directory, capabilities, log_file,
respawn_delay, and under Tier B bwrap_path — is read by a shell a second
time. Those must be one word of [A-Za-z0-9_./:@%+,=^!-] and are refused at
eval otherwise (! is there for a capability dropped from the bounding set,
^cap_net_bind_service,!cap_sys_admin), and command must be absolute, so it
can't read as a supervise-daemon option. command_args is shell words by
design: OpenRC's eval splits it and reads its quotes, as a shell would a command
line, so quote a value inside it yourself. OpenRC also expands it unquoted before
that eval, so a run of blanks collapses to one, even inside quotes, and a word
holding *, ? or [ globs against the working directory.
Environment injection
OpenRC has no EnvironmentFile= parser, and the init script runs as root, so the
script never sources env_file: root reading it as shell would run whatever the
file says, and exporting its names into root's shell would hand them to root's own
commands (PATH, LD_PRELOAD, OpenRC's command). The file is read as data, by
one reader the script carries (_zap_env_read, built from case and parameter
expansion: nothing in the file is run or expanded), twice:
start_pre() runs it as the service user (su -s /bin/sh … <user>), under
timeout (ENV_CHECK_TIMEOUT_SECS), checking every line and assigning nothing,
so a line it can't read refuses the start, naming the line number, and so does a
file the service user can't read (a root-owned 600 file), naming the user, or
one it doesn't finish reading in time (a FIFO, a huge file). Root runs the check
and never parses the file's bytes. The check's output lands in a file in a
directory mktemp -d made for root under /run, so the service user can't fill
the directory's filesystem beforehand to stop the start (as it could /tmp), and
nothing it leaves running can hold the start open the way it could hold a pipe.
Root bounds only what it reads (head -c), not what a lingering writer, still
holding the file, can grow it to;- the daemon's own process, after
supervise-daemon dropped it to
command_user, reads the file again and exports each assignment before it
execs the command, which runs under /bin/sh -c as Tier B's wrap does
(command and command_args follow the wrap's script as its arguments). So
the file must be readable by the service user, as a deploy's
shared/.env.production owned by it is.
The check needs su and timeout on the target (BusyBox gives both on Alpine);
root's su -s runs the shell for an account whose login shell is nologin.
The format is a subset of what systemd's EnvironmentFile= reads, chosen so a file
the subset accepts gives the daemon the same environment under both:
- a blank line, or one whose first non-blank character is
# or ;, is skipped; - every other line is
NAME=VALUE, NAME matching [A-Za-z_][A-Za-z0-9_]*
with nothing before it, and not beginning _zap_ (the reader's own names); - a value starting with
' is single-quoted: it ends the line with its closing
', holds no other ', and every byte between is literal (\ and $
included), as systemd reads it; - any other value is literal as written (
$, a later quote, a #, interior
blanks), as systemd reads an unquoted value without a backslash; it may not
start or end with a blank, start with ", or hold a \, which systemd
strips or reads as quoting and escapes; - an empty value (
NAME=) sets the empty string; a later line for a name wins.
Every other line is refused, where systemd differs: it skips a line without =
or with an invalid name, strips blanks around the name and the value, reads
double-quoted values and backslash escapes, continues a line ending in \, and
lets a quoted value span lines. A carriage return anywhere refuses the line (a
CRLF file). A file a fuz_app_env_base / fuz_app_trusted_proxies template
renders is in the subset.
Recipes are imported at config eval time inside zap's sandboxed config eval
(fgro eval), which has no network, no writes, and no environment, and where the
clock and randomness throw.
This module is therefore a pure function: it only assembles a string from
its arguments. The output is deterministic for given inputs (stable ordering,
trailing newline), so it stays a reliable content-addressed plan artifact.
The caller wraps the result in a file resource at /etc/init.d/<name> with
mode 755, e.g.
zap.file({id: 'myapp-init', path: '/etc/init.d/myapp', mode: '755', content: {type: 'inline', content: hardened_openrc_service({...})}}).