etc: harden litestream.service systemd unit#1364
Open
Self-Perfection wants to merge 2 commits into
Open
Conversation
The packaged unit ran the daemon as root with no sandboxing
(`systemd-analyze security` exposure 9.6 "UNSAFE"). This adds the subset
of hardening directives that require no per-deployment configuration and
do not change how the daemon accesses its databases, so it is safe to
ship to every existing installation on upgrade:
- NoNewPrivileges, ProtectSystem=full
- Protect{Clock,KernelTunables,KernelModules,KernelLogs,ControlGroups,Hostname}
- ProtectProc=invisible / ProcSubset=pid, PrivateDevices
- RestrictNamespaces, LockPersonality, RestrictRealtime, RestrictSUIDSGID
- RemoveIPC, SystemCallArchitectures=native
Directives that depend on the database path or on capabilities (User=,
ProtectSystem=strict, ReadWritePaths=, CapabilityBoundingSet=, PrivateTmp,
system-call filtering) are deferred to a follow-up so this baseline stays
trivially reviewable and cannot break existing setups.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Builds on the conservative baseline with the directives that carry a per-deployment caveat, each documented inline so operators can tune them: - CapabilityBoundingSet=CAP_DAC_OVERRIDE CAP_DAC_READ_SEARCH CAP_CHOWN CAP_FOWNER (+ empty AmbientCapabilities). An *empty* bounding set breaks the common "root replicates a database owned by another user" topology: without DAC override, root is denied by normal permission checks and replication fails with "stat ...: permission denied". Retaining just the file-access caps keeps that working; setting User= to the DB owner lets you drop them entirely. - PrivateTmp=true, with a note that a control socket configured under /tmp (as shown in some docs) becomes unreachable to external `litestream` CLI clients; the default socket lives under /run and is unaffected. - MemoryDenyWriteExecute=true — safe because the shipped binary is pure Go (CGO_ENABLED=0, modernc.org/sqlite); no W+X mappings. - RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 (all replica backends plus the local IPC socket and name resolution). - SystemCallFilter=@System-service minus @privileged/@resources, SystemCallErrorNumber=EPERM, UMask=0077. - Commented, path-dependent knobs: User=/Group=, ProtectSystem=strict + ReadWritePaths=, ProtectHome=. Exposure on the live unit (still running as root): 9.6 UNSAFE -> 2.2 OK per `systemd-analyze security`. Verified working in production. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Harden the packaged
etc/litestream.servicesystemd unit. It currently runs thedaemon as root with no sandboxing; this adds standard systemd confinement in two
reviewable commits without changing how Litestream reaches its databases.
Problem
The shipped unit applies zero confinement:
systemd-analyze security litestream.servicerates it 9.6 "UNSAFE" — fullcapability set, all address families, writable
/, kernel tunables/modulesreachable, no syscall filter, etc.
Solution
Two commits, layered by blast radius:
Conservative baseline — directives that need no per-deployment tuning and
cannot change how the daemon accesses its data:
NoNewPrivileges,ProtectSystem=full, theProtect*/Restrict*kernel & namespace set,ProtectProc=invisible/ProcSubset=pid,PrivateDevices,SystemCallArchitectures=native. Safe to ship to every existing install onupgrade.
Deployment-aware directives — the ones with a caveat, each documented
inline:
CapabilityBoundingSet(see below),PrivateTmp,MemoryDenyWriteExecute,RestrictAddressFamilies,SystemCallFilter,UMask, plus commentedUser=/ProtectSystem=strict/ReadWritePaths=/ProtectHome=knobs.Result: 9.6 UNSAFE → 2.2 OK on the loaded unit (still running as root; the
remaining exposure is
User=/PrivateNetwork=/ProtectHome=, which aredeployment choices).
The one real regression we guarded against
An empty
CapabilityBoundingSet=(the obvious "drop everything" choice)breaks the common topology where the app writes its SQLite DB as its own user in
a
0750directory and Litestream runs as root: droppingCAP_DAC_OVERRIDEsubjects root to normal permission checks, so it can't even
statthe DB:Litestream must also create a
.<db>-litestreamsidecar dir plus-wal/-shmin that directory (
db.goNewDB/metaPath). We therefore keepCAP_DAC_OVERRIDE CAP_DAC_READ_SEARCH CAP_CHOWN CAP_FOWNER, and document thatsetting
User=to the DB owner lets operators drop them entirely.Scope
In scope:
etc/litestream.servicehardening only.Not in scope (left as documented, commented knobs for the operator):
User=/DynamicUser=(DB ownership is deployment-specific).ProtectSystem=strict+ReadWritePaths=(needs the DB path).ProtectHome=,PrivateNetwork=(depend on DB/replica location).Test Plan
No Go code is touched, so there is no
go testfor this change; verification isagainst the unit itself. Reproducible commands:
Results (Debian 13, systemd 257, static CGO_ENABLED=0 build)
Measured with
systemd-analyze securityon the loaded unit and a realreplication run (SQLite WAL DB owned by an unprivileged app user in a 0750
directory, file replica):
User=<owner>, empty capsCapabilityBoundingSet=as rootpermission deniedThe last row is the regression the shipped
CapabilityBoundingSetavoids;step 3 above reproduces it in isolation. No
denied/EPERM/panicappears inthe journal for any working configuration, confirming
MemoryDenyWriteExecuteand the syscall filter are compatible with the pure-Go binary.
Note on ordering: if Litestream (as root) opens the database before the app,
it creates root-owned
-wal/-shmand the app can then get "readonlydatabase". That is pre-existing root behavior (the old unit runs as root too),
not introduced here; running as the database owner via
User=avoids itentirely and is the documented recommendation.
Recommended tightened mode (documentation, not the default)
The shipped default runs as root and relies on
CAP_DAC_OVERRIDEto reach anapp-owned database — a blunt grant. Operators who want least privilege can
instead run Litestream under a dedicated unprivileged user and grant access to
each database's group via a drop-in. This is documented, not the default,
because it requires per-database filesystem setup and would break existing
installs if forced.
Filesystem requirements (tested end-to-end on Debian 13 / systemd 257):
0770, ownedappuser:appgroup; Litestream joinsappgroup. Do not set the setgid bit on this directory — Litestreammirrors the directory's mode onto its own
.<db>-litestreammetadata dir bydesign (
internal.MkdirAllreceives the parentdirInfo), and creating asetgid dir is refused (EPERM) by this unit's
RestrictSUIDSGID=yes.chmod 0660): Litestream opens itread-write (it creates
_litestream_seqand checkpoints). SQLite createsfiles from a
0644base, which has no group-write bit, so this cannot beachieved with
umaskalone — the application (or an admin) must set0660.-wal/-shmthen inherit the database file's mode.Result: runs with no root and no capabilities; exposure drops to 1.5 OK,
and the database's owning application keeps read/write access throughout.
If you also enable the control socket (off by default), note that its default
path
/var/run/litestream.socklives directly in root-owned/run, which anunprivileged user cannot write. Give the service its own runtime directory and
point the socket at it (Litestream does not read
$RUNTIME_DIRECTORY, so thepath must be set explicitly, and CLI clients need the matching
-socket):Related
🤖 Generated with Claude Code