recipe_alpine_openrc.ts

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({...})}}).

view source

Declarations
#

3 declarations

hardened_openrc_service
#

recipe_alpine_openrc.ts view source

(options: HardenedOpenrcServiceOptions): string import {hardened_openrc_service} from '@fuzdev/zap/recipe_alpine_openrc.js';

Build the hardened OpenRC init-script text for options.

Produces an openrc-run script supervised by supervise-daemon, with the Tier-A privilege hardening (drop to command_user, the capability bounding set kept to what capabilities names, no_new_privs, umask, rc_ulimit, the service user's check of env_file, and the log-directory guard) and respawn-on-failure. Given a service's inputs it reproduces that service's init script deterministically.

options

returns

string

the complete init-script text, terminated by a trailing newline.

HardenedOpenrcIsolation
#

recipe_alpine_openrc.ts view source

HardenedOpenrcIsolation import type {HardenedOpenrcIsolation} from '@fuzdev/zap/recipe_alpine_openrc.js';

Tier-B bubblewrap filesystem-isolation options for hardened_openrc_service.

read_write_paths

Absolute paths bind-mounted read-write inside the otherwise read-only root — everything the service writes at runtime (its app/data directory, a database socket directory, …). The working_directory should fall under one of these. /tmp is always a private tmpfs and need not be listed.

Each entry must be an absolute path of [A-Za-z0-9_./-] only — no whitespace or shell metacharacters — validated at eval. They're embedded unquoted into the bwrap command line, so an unsafe path would word-split and silently break the sandbox.

type string[]

bwrap_path?

The bwrap binary path. Defaults to /usr/bin/bwrap.

type string

HardenedOpenrcServiceOptions
#

recipe_alpine_openrc.ts view source

HardenedOpenrcServiceOptions import type {HardenedOpenrcServiceOptions} from '@fuzdev/zap/recipe_alpine_openrc.js';

Options for hardened_openrc_service.

The required fields are the per-service variations (name, description, command, user); everything else defaults to the shared hardened baseline. description / command / user / env_file are the subset shared in spirit with HardenedSystemdUnitOptions, so a deploy can present one options shape and pick the artifact by import.

name

The init-script name (/etc/init.d/<name>); also the default log basename.

type string

description

Human-readable description= (e.g. the domain).

type string

command

Absolute path to the executable (command=).

type string

command_args?

Arguments string passed as command_args=: shell words, which OpenRC splits and whose quotes it reads (see the module doc). Omitted when unset.

type string

user

The service account (command_user=<user>), by name: a numeric uid and root are refused. Also the default for group.

type string

group?

The service group (command_user=<user>:<group>), by name, not root. Defaults to user.

type string

working_directory?

directory= — the process working directory (typically the current release symlink). Omitted when unset.

type string

env_file?

Read as data into the daemon's environment — OpenRC's stand-in for systemd EnvironmentFile=. start_pre() has the service user check it, and the daemon's process, as command_user, exports it before it execs the command, so it must be readable by the service user. It must be an absolute path outside the log directory (which is chowned to the service user), or it is refused at eval. Omitted means no environment file. The format, and where it differs from systemd's, is in the module doc.

type string

capabilities?

The capabilities= value, in libcap's IAB text, which supervise-daemon applies: ^cap_x raises cap_x as an ambient capability, !cap_x drops it from the bounding set. Defaults to '^', no ambient capability (a daemon listening on a high port behind nginx needs none); pass '^cap_net_bind_service' for one that binds below 1024. Whatever it names, the recipe appends !cap_… for every other capability (CAPABILITY_NAMES by name, then every higher number up to 63, so one a newer kernel adds is dropped too; the names need a libcap that knows cap_checkpoint_restore), so the bounding set holds only the capabilities the value names without !: a '^' alone leaves it empty. libcap's IAB text has no word for "all", so each is named. Under isolation the command holds none.

type string

umask?

umask=, which supervise-daemon sets for the daemon (--umask): four octal digits. Defaults to '0077', as the systemd unit does, so what the service creates is its own; pass '0022' (or '0027' for a group) when another account reads what it writes, such as nginx serving a store through X-Accel-Redirect.

type string

ulimit_nofile?

rc_ulimit="-n <n>" open-file cap. Omitted when unset.

type number

respawn_delay?

respawn_delay seconds between supervise-daemon restarts. Defaults to 5.

type number

needs?

depend() need entries. Defaults to ['net'].

type string[]

uses?

depend() use entries (soft deps). Defaults to none; add 'postgresql' for an app on a local database.

type string[]

log_file?

Combined stdout+stderr log path (output_log/error_log). Defaults to /var/log/<name>/<name>.log. supervise-daemon opens this as command_user (after the privilege drop), so for a non-root service the path's directory must be writable by that user. The recipe handles this: it creates and chowns log_file's parent directory to the service user in a start_pre() checkpath hook (which runs as root, before the privilege drop), so the default works for a non-root service out of the box with no external directory resource. Because the parent is chowned to the service user, it must be the service's own: at or under a directory that sits directly under /var/log, /var/lib, /srv, /opt or /run and is named name or user, alone or followed by one of _, -, . (/var/log/<name>/server.log, /srv/<user>/logs/server.log), and env_file must not sit inside it, nor may command or, under isolation, the bwrap binary, which the service user could otherwise replace. Any other path is refused at eval.

At start, the hook also refuses (eerror, start_pre fails) a log directory that already exists unless it is a directory, not a symlink, owned by the service user: an absent one is created, and anything else (root's, another daemon's — a name nginx meeting /var/log/nginx) is someone else's, which the hook never re-owns. A root-owned directory is refused too, since the hook didn't make it; chown it to the service user, or remove it, to adopt it. The residual: only the log directory itself is checked, and checkpath follows a symlink in the path's intermediate components, creating through it and chowning where it points; a symlink as the last component it refuses, failing the start. So below an owner-named directory the service user already owns, a symlink it plants at an intermediate component (/srv/<user>/a/logs, replacing a) could redirect the root-run create and chown, while one swapped in for the log directory itself between the check and checkpath is refused. Where that matters, make the log directory the owner-named directory itself (/var/log/<user>).

type string

isolation?

Tier-B filesystem isolation via bubblewrap. When set, the service command runs inside a bwrap sandbox — a read-only root, a private /tmp (tmpfs), and the declared read_write_paths bound read-write — the OpenRC stand-in for systemd's ProtectSystem=strict + PrivateTmp + ReadWritePaths. It layers over the Tier-A privilege hardening (the sandboxed process still drops to command_user, holds no capabilities, whatever capabilities names (bwrap empties the bounding set), and sets no_new_privs). Requires bubblewrap on the target (declare it as a zap.pkg) and a kernel permitting unprivileged user namespaces (Alpine's default). Omit for Tier-A only.

type HardenedOpenrcIsolation

Depends on
#