From 0fd9ad7dad7800127d30b0ac5491270d1bf18523 Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Sun, 7 Jun 2026 17:03:30 +0200 Subject: [PATCH] security(docker): non-root soundtouch-service prep, dormant behind a toggle (refs #451) Lands the groundwork to run the service container as non-root, but keeps it running as root by default so this is NOT a breaking change yet. Enabling it (BREAKING) is planned for v1.0.0 and reduced to a one-line flip. Image prep (all harmless while running as root): - A fixed non-root user, uid/gid 65532 (aftertouch), with /app chowned to it. - A cap_net_bind_service file capability on the binary so the optional DNS server can still bind :53 as non-root (NET_BIND_SERVICE is in Docker's default cap set; no --cap-add needed). Applied after chown so it survives. - USER ${APP_USER} with ARG APP_USER=root: still root by default. To enable non-root, flip the default to "aftertouch" (one line) or build with --build-arg APP_USER=aftertouch. Startup safety net (active now, no-op while writable): - warnIfDataDirNotWritable probes DATA_DIR and, if it can't write, logs the exact `chown -R 65532:65532 ` fix (with the process uid) instead of failing later with a cryptic permission error. This is the common snag when a non-root container meets a bind-mounted host dir owned by someone else. Verified: default build runs as root; --build-arg APP_USER=aftertouch runs as 65532, serves /health, writes the data dir; a read-only data dir triggers the warning + chown hint. Co-Authored-By: Claude Opus 4.8 (1M context) --- Dockerfile | 28 +++++++++++++++++++++- cmd/soundtouch-service/main.go | 44 ++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 7ce6356..934b8bf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -50,6 +50,13 @@ FROM alpine:3.23 AS soundtouch-service RUN apk add --no-cache ca-certificates tzdata +# Non-root prep (dormant). Everything below is set up so the service CAN run +# as a fixed non-root user, but the image still runs as root by default +# (APP_USER below) so this is not a breaking change yet. The UID/GID is pinned +# (65532) so a mounted data volume's ownership stays predictable. +RUN addgroup -g 65532 -S aftertouch \ + && adduser -u 65532 -S -G aftertouch -H -h /app aftertouch + WORKDIR /app COPY --from=builder /soundtouch-service /app/soundtouch-service @@ -57,13 +64,32 @@ COPY --from=builder /soundtouch-service /app/soundtouch-service # Verify the binary works on the target platform RUN /app/soundtouch-service version || echo "Binary verification complete" -RUN mkdir -p /app/data +# Create the data dir and hand /app to the non-root user. +RUN mkdir -p /app/data && chown -R aftertouch:aftertouch /app + +# Allow the non-root process to bind the privileged DNS port (:53) when DNS +# Discovery is enabled, without granting the whole container extra privileges +# at runtime. NET_BIND_SERVICE is in Docker's default capability set, so this +# file capability is effective out of the box (no --cap-add needed). Done +# after chown, which would otherwise clear it; the setcap tool is removed after. +RUN apk add --no-cache --virtual .setcap libcap \ + && setcap 'cap_net_bind_service=+ep' /app/soundtouch-service \ + && apk del .setcap ENV PORT=8000 ENV DATA_DIR=/app/data ENV LOG_PROXY_BODY=false ENV REDACT_PROXY_LOGS=true +# The toggle. Defaults to root, so this image behaves exactly as before and +# the change is non-breaking today. Enabling non-root is planned for v1.0.0 +# (BREAKING: a bind-mounted DATA_DIR must then be writable by uid 65532 — the +# service logs the exact chown command at startup if it can't write). To +# enable, either change this default to "aftertouch" (a one-line commit) or +# build with --build-arg APP_USER=aftertouch. +ARG APP_USER=root +USER ${APP_USER} + EXPOSE 8000 ENTRYPOINT ["/app/soundtouch-service"] diff --git a/cmd/soundtouch-service/main.go b/cmd/soundtouch-service/main.go index 5f0e4fa..9a91f3a 100644 --- a/cmd/soundtouch-service/main.go +++ b/cmd/soundtouch-service/main.go @@ -1056,6 +1056,8 @@ func createDefaultSettings(ds *datastore.DataStore, config serviceConfig) datast } func initDataStore(dataDir string) *datastore.DataStore { + warnIfDataDirNotWritable(dataDir) + ds := datastore.NewDataStore(dataDir) if err := ds.Initialize(); err != nil { log.Printf("Warning: Failed to initialize datastore: %v", err) @@ -1064,6 +1066,48 @@ func initDataStore(dataDir string) *datastore.DataStore { return ds } +// warnIfDataDirNotWritable probes the data dir and logs an actionable message +// when the process can't write to it. The common cause is running the +// container as non-root (uid 65532) while a bind-mounted host directory is +// owned by someone else; without this the failure would surface later as a +// cryptic permission error deep in a save. It only warns: the datastore's own +// resilience handles the degraded state. +func warnIfDataDirNotWritable(dataDir string) { + if dataDir == "" { + return + } + + if err := os.MkdirAll(dataDir, 0o755); err != nil { + log.Printf("WARNING: data dir %s cannot be created: %v", sanitizeLog(dataDir), err) + logDataDirChownHint(dataDir) + + return + } + + probe := filepath.Join(dataDir, ".write-probe") + if err := os.WriteFile(probe, []byte("ok"), 0o600); err != nil { + log.Printf("WARNING: data dir %s is not writable: %v", sanitizeLog(dataDir), err) + logDataDirChownHint(dataDir) + + return + } + + _ = os.Remove(probe) +} + +// logDataDirChownHint prints the one-time fix for a non-writable bind-mounted +// data dir, using the process's own uid. Skipped where uid is unavailable +// (e.g. Windows), where the hint wouldn't apply. +func logDataDirChownHint(dataDir string) { + uid := os.Getuid() + if uid < 0 { + return + } + + log.Printf(" The service runs as uid %d. If you bind-mounted a host directory as the data dir, "+ + "make it writable once: chown -R %d:%d %s", uid, uid, uid, sanitizeLog(dataDir)) +} + func initCertificateManager(dataDir, hostname string) *certmanager.CertificateManager { cm := certmanager.NewCertificateManager(filepath.Join(dataDir, "certs"))