From f17cf7fdd80a117e2cb8f207b27b78170f329c50 Mon Sep 17 00:00:00 2001 From: Michal Tuszowski Date: Wed, 8 Jul 2026 17:14:18 +0200 Subject: [PATCH] radio: run liquidsoap as dedicated non-root user; quiet pulse warnings - Liquidsoap refuses to start as root (init: security exit). Instead of the settings.init.allow_root override, the image now has a dedicated 'radio' user (audio + pulse-access groups) and the entrypoint drops privileges via setpriv after doing its root-only work (volume seeding, icecast config render/start, pulse start, chown of the data volume). The icecast secret is made group-readable (640 root:radio) because the script parses it directly. - The opam root moved from /root/.opam to /opt/opam so the liquidsoap binary AND its stdlib .liq files are readable by the radio user. NOTE: this invalidates the cached opam build layer - next build recompiles liquidsoap (~15-20 min). - pulseaudio (PULSE_MODE=internal) now starts with --disallow-module-loading: system.pa startup modules still load, only later client-requested loads are blocked, and the system-mode warning goes away. The 'forcibly disabling SHM mode' notice is inherent to system mode and harmless (documented). Keeps the user's libcurl4-gnutls-dev build-dep fix. Co-Authored-By: Claude Opus 4.8 --- docker/Dockerfile.radio | 17 +++++++++++------ docker/entrypoint.radio.sh | 19 +++++++++++++++++-- docs/deployment/DOCKER_PROXMOX.md | 14 +++++++++++++- 3 files changed, 41 insertions(+), 9 deletions(-) diff --git a/docker/Dockerfile.radio b/docker/Dockerfile.radio index 311fad5..70d71da 100644 --- a/docker/Dockerfile.radio +++ b/docker/Dockerfile.radio @@ -47,10 +47,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ icecast2 jq \ && rm -rf /var/lib/apt/lists/* -# Group membership for PulseAudio/ALSA access. On the Pi this was done by -# hand for the system-wide pulse socket; in the image it is baked in. The -# 'pulse-access' group comes with the pulseaudio package. -RUN usermod -aG audio,pulse-access root +# Liquidsoap refuses to run as root (security exit), so it gets a dedicated +# user. audio/pulse-access mirror what was done by hand on the Pi for the +# system-wide pulse socket ('pulse-access' comes with the pulseaudio pkg). +RUN useradd --system --create-home --home-dir /var/lib/radio \ + --shell /usr/sbin/nologin radio \ + && usermod -aG audio,pulse-access radio # opam as a static binary so OPAM_VERSION is a real choice (apt would pin us # to whatever bookworm ships). @@ -60,13 +62,16 @@ RUN ARCH=$(uname -m) \ && chmod +x /usr/local/bin/opam # OCaml switch + liquidsoap and its optional feature libraries in one solve, -# so liquidsoap is compiled WITH those features enabled. +# so liquidsoap is compiled WITH those features enabled. OPAMROOT lives in +# /opt/opam (not /root/.opam) so the unprivileged 'radio' user can read the +# liquidsoap binary AND its stdlib .liq files at runtime. +ENV OPAMROOT=/opt/opam RUN opam init -y --bare --disable-sandboxing \ && opam switch create default "${OCAML_VERSION}" \ && opam install -y "liquidsoap.${LIQUIDSOAP_VERSION}" ${LIQ_OPAM_PACKAGES} \ && opam clean -a -c -s --logs -ENV PATH="/root/.opam/default/bin:${PATH}" +ENV PATH="/opt/opam/default/bin:${PATH}" # Build-time smoke test: the binary runs and reports the requested version. RUN liquidsoap --version diff --git a/docker/entrypoint.radio.sh b/docker/entrypoint.radio.sh index b4b8de9..150db21 100755 --- a/docker/entrypoint.radio.sh +++ b/docker/entrypoint.radio.sh @@ -73,7 +73,12 @@ fi # none - you edited the script to drop pulse in/out. case "${PULSE_MODE:-internal}" in internal) - pulseaudio --system --daemonize=yes --disallow-exit --exit-idle-time=-1 \ + # --disallow-module-loading: modules from system.pa still load at + # startup; this only blocks later client-requested loads (and + # silences the system-mode warning). The "forcibly disabling SHM" + # notice is inherent to system mode and harmless. + pulseaudio --system --daemonize=yes --disallow-exit \ + --disallow-module-loading --exit-idle-time=-1 \ || echo "WARNING: internal pulseaudio failed to start" >&2 export PULSE_SERVER="${PULSE_SERVER:-unix:/var/run/pulse/native}" ;; @@ -84,5 +89,15 @@ case "${PULSE_MODE:-internal}" in ;; esac +# Liquidsoap refuses to run as root (init: security exit), so hand the data +# volume to the dedicated 'radio' user and drop privileges for the main +# process. The script parses the icecast secret directly, so make that one +# file group-readable for 'radio' (kept 640, root-owned). +chown -R radio:radio "$DATA" +chgrp radio "$CREDS" 2>/dev/null && chmod 640 "$CREDS" || true +if ! setpriv --reuid radio --regid radio --init-groups -- test -r "$MUSIC"; then + echo "WARNING: music dir $MUSIC is not readable by the 'radio' user" >&2 +fi + cd "$DATA" -exec "$@" +exec setpriv --reuid radio --regid radio --init-groups -- "$@" diff --git a/docs/deployment/DOCKER_PROXMOX.md b/docs/deployment/DOCKER_PROXMOX.md index e87b771..c4aa653 100644 --- a/docs/deployment/DOCKER_PROXMOX.md +++ b/docs/deployment/DOCKER_PROXMOX.md @@ -272,12 +272,24 @@ Wire-up: listeners tune to `http://RADIO_VM_IP:8000/mp3-stream`; the bot points at `CONJURER_RADIO_HARBOR=http://RADIO_VM_IP:54321` (harbor `/skip` lives in the script itself). +**Privileges:** Liquidsoap refuses to run as root (`init: security exit`), +so the main process runs as the dedicated **`radio`** user (member of +`audio`/`pulse-access`) — no `settings.init.allow_root` override. The +entrypoint (root) seeds volumes, renders the icecast config, starts +icecast/pulse, chowns `/srv/betoniarka/data` to `radio` and drops +privileges via `setpriv`. This is also why the opam switch lives in +`/opt/opam` instead of `/root/.opam` (the binary and the liquidsoap stdlib +must be readable by `radio`). + **PulseAudio** (`PULSE_MODE` env): - `internal` (default) — a system-wide pulse daemon runs inside the container with a **null sink** (`docker/pulse-system.pa`): no sound hardware needed, `output.pulseaudio()` plays into the void and the `input.pulseaudio()` mic path reads silence (which `blank.strip` already gates out). Right choice - for a headless Proxmox VM. + for a headless Proxmox VM. Started with `--disallow-module-loading` + (startup modules from `system.pa` still load; only later client-requested + loads are blocked). The `forcibly disabling SHM mode` notice is inherent + to system mode and harmless. - `host` — mount the host's pulse socket and set `PULSE_SERVER`, for a VM with real audio hardware (the Pi's Lexicon Lambda setup). - `none` — you removed the pulse in/out from the script.