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 <dir>` 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) <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-06-07 17:03:30 +02:00
co-authored by Claude Opus 4.8
parent f8f783428a
commit 0fd9ad7dad
2 changed files with 71 additions and 1 deletions
+27 -1
View File
@@ -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"]
+44
View File
@@ -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"))