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"))