recipe_debian_systemd.ts

Recipe for a hardened systemd service unit.

The Alpine/OpenRC sibling is recipe_alpine_openrc.ts's hardened_openrc_service — a different artifact with a different security model (the two share only the broad options shape, not content).

hardened_systemd_unit builds the unit-file text for a long-running network service (a compiled Rust daemon, a Deno process, or any comparable service that talks to a local PostgreSQL). It captures a security-hardening directive set that server configs otherwise copy by hand — NoNewPrivileges, ProtectSystem=strict, ProtectHome=read-only, the kernel/namespace/realtime restrictions, an empty capability bounding set, the @system-service syscall filter, UMask=0077, and SystemCallArchitectures=native — as one reusable string builder so every service converges on the same baseline.

Loosening the baseline

Three defaults are tight enough that a service may need one opened, each through its own option, so the loosening is written where it is reviewed:

  • capabilities — the bounding set is empty unless the service names the capabilities it holds, which it is then also granted as ambient (a CAP_NET_BIND_SERVICE listener on a port below 1024).
  • system_call_filter — @system-service with EPERM for anything outside it, so a refused call fails where the program can report it instead of killing the process. false drops the filter.
  • umask — 0077, so what the service creates is its own. A service whose files another account reads (an nginx X-Accel-Redirect or alias over a store the service writes) passes the mode that account needs, e.g. '0022'.

The unit refuses root as its user or group, and either given as a number: the hardening block assumes an unprivileged account, and root would make most of it moot.

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 — no I/O, no clock, no randomness, no imports beyond types. 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, e.g. zap.file({id: 'myapp-unit', path: '/etc/systemd/system/myapp.service', content: {type: 'inline', content: hardened_systemd_unit({...})}}).

Environment injection: env_file vs --env

Two ways to get env into the service, mutually exclusive in practice:

  • Set env_file to emit an EnvironmentFile= directive — systemd loads the file and the process reads its env (the compiled-binary path, where the daemon reads process env, not a file).
  • Omit env_file and bake --env=<path> (or equivalent) into exec_start — the runtime loads the file itself (the Deno path), so no EnvironmentFile= line is emitted at all.
view source

Declarations
#

2 declarations

hardened_systemd_unit
#

recipe_debian_systemd.ts view source

(options: HardenedSystemdUnitOptions): string import {hardened_systemd_unit} from '@fuzdev/zap/recipe_debian_systemd.js';

Build the hardened systemd unit-file text for options.

Produces a canonical [Unit] / [Service] / [Install] unit with the standard security-hardening block (introduced by a # Security hardening comment), from a service's inputs, including its per-host hardening_comment.

options

returns

string

the complete unit-file text, terminated by a trailing newline.

HardenedSystemdUnitOptions
#

recipe_debian_systemd.ts view source

HardenedSystemdUnitOptions import type {HardenedSystemdUnitOptions} from '@fuzdev/zap/recipe_debian_systemd.js';

Options for hardened_systemd_unit.

Required fields are the real per-service variations across deploys (description, exec_start, user, working_directory, read_write_paths); everything else defaults to the shared baseline.

description

Description= — human-readable unit description (e.g. the domain).

type string

exec_start

ExecStart= — the full command line, already absolute and quoted as needed. It begins with the absolute path of the program, optionally after systemd's @ (the next word is argv[0]) or : (no $VAR expansion) prefix. Every other prefix is refused: +, ! and !! run the command outside User= or the sandbox, and - reads a failing exit as success, so Restart=on-failure never restarts it.

type string

user

User= — the service account, by name. Also the default for group. Not root: the unit is for an unprivileged account.

type string

group?

Group= — the service group, by name, not root. Defaults to user.

type string

working_directory

WorkingDirectory= — the process working directory (typically the current release symlink).

type string

read_write_paths

ReadWritePaths= — the read-write holes punched through ProtectSystem=strict + ProtectHome=read-only. Space-joined into a single directive; every path must already exist or systemd namespace setup fails with status 226.

type string[]

env_file?

EnvironmentFile= — emitted only when set. Leave unset when exec_start injects env itself (e.g. Deno's --env=); see the module doc.

type string

after?

After= units. Defaults to ['network.target']; add 'postgresql.service' for an app on a local database.

type string[]

wants?

Wants= units, the line emitted only when non-empty. Defaults to none.

type string[]

restart?

Restart= policy. Defaults to 'on-failure'.

type string

restart_sec?

RestartSec= seconds. Defaults to 5.

type number

restrict_address_families?

RestrictAddressFamilies= — space-joined. Defaults to ['AF_UNIX', 'AF_INET', 'AF_INET6'], which covers a TCP listener, the Postgres socket, and loopback callbacks.

type string[]

wanted_by?

WantedBy= install target. Defaults to 'multi-user.target'.

type string

capabilities?

The capabilities the service holds, as systemd names them (CAP_NET_BIND_SERVICE), written both as the CapabilityBoundingSet= and as AmbientCapabilities=. Defaults to none: an empty CapabilityBoundingSet= and no AmbientCapabilities= line.

type string[]

system_call_filter?

SystemCallFilter= — the syscall sets or names the service may call (an allow-list), written with SystemCallErrorNumber=EPERM. Defaults to ['@system-service']; widen it for a workload that needs a set outside it, such as @privileged, @mount, @debug or @pkey (`systemd-analyze syscall-filter

type string[] | false

umask?

UMask= — four octal digits. Defaults to '0077'; pass '0022' (or '0027' for a group) when another account reads what the service writes.

type string

hardening_comment?

Host-specific sandboxing-rationale comment, inserted verbatim after PrivateTmp=true and before PrivateDevices=true. Pass the full comment lines (each already prefixed with # , newline-separated, no trailing newline); omitted means no comment. A line that doesn't begin with # would be a directive, and one ending in \ would continue onto the next directive, so both are refused. This is where a deploy documents why its address-family / syscall-filter choices are safe for its workload — genuinely per-host prose (a git-backend service explains its subprocesses; a pure-API service notes it has none), so it can't be a shared constant.

type string