diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 5796328a..301abfd7 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -242,6 +242,9 @@ jobs: - name: Staging disk-monitor unit tests (issue #1684) run: bash scripts/staging/test-disk-monitor.sh + - name: Container service-user selection unit tests + run: bash docker/test-select-service-user.sh + - name: QA SQL parameter-binding unit tests (issue #1977) run: bash qa/scripts/test-blacklist-sql.sh diff --git a/Dockerfile b/Dockerfile index 76514d2a..35499abe 100644 --- a/Dockerfile +++ b/Dockerfile @@ -128,6 +128,23 @@ FROM alpine:3.20 RUN apk add --no-cache mosquitto mosquitto-clients supervisor caddy wget +# Account for the Go services when a deployment opts into running them +# unprivileged with RUN_AS_UID=1000, the usual choice. Creating it costs +# nothing when it goes unused, and saves the entrypoint creating one at start. +# +# Nothing in the image is given to it here. -H matters for that: without it +# busybox adduser creates the home directory itself, and /app would be +# corescope-owned mode 2755 — the setgid bit then giving gid 1000 to /app/data +# and to every file written into it, which Docker copies into each new named +# volume. The home field still points at /app; only the directory is left to +# the WORKDIR below, root-owned as in earlier releases. +RUN addgroup -S -g 1000 corescope && \ + adduser -S -u 1000 -G corescope -H -h /app -s /sbin/nologin corescope + +# Default: the services run as root, exactly as in earlier releases. The +# entrypoint overrides this when RUN_AS_UID asks for an unprivileged account. +ENV CORESCOPE_SERVICE_USER=root + WORKDIR /app # Go binaries (statically linked; they do not use this image's libc) @@ -155,6 +172,8 @@ RUN mkdir -p /app/data /var/lib/mosquitto /data/caddy && \ # Entrypoint COPY docker/entrypoint-go.sh /entrypoint.sh +# Sourced by the entrypoint, so deliberately not executable. +COPY docker/select-service-user.sh /select-service-user.sh RUN chmod +x /entrypoint.sh EXPOSE 80 443 1883 diff --git a/docker/entrypoint-go.sh b/docker/entrypoint-go.sh index 9f47efc1..f2f5c1e5 100644 --- a/docker/entrypoint-go.sh +++ b/docker/entrypoint-go.sh @@ -14,12 +14,15 @@ if [ -f /app/data/theme.json ]; then ln -sf /app/data/theme.json /app/theme.json fi -# Source .env from data volume if present (works with any launch method) -if [ -f /app/data/.env ]; then - set -a - . /app/data/.env - set +a -fi +# Which user runs the two Go services, and what they are handed. supervisord +# itself stays root so that mosquitto and caddy can still bind their ports. +# Unprivileged operation is opt-in via RUN_AS_UID, taken from the container's +# environment before .env is read; .env is then read (with the services +# unprivileged, only if root owns it) and the data directory handed over. The +# order matters and lives in one function: see docker/select-service-user.sh, +# and "Running the Go services unprivileged" in docs/deployment.md. +. /select-service-user.sh +prepare_service_user || exit 1 SUPERVISORD_CONF="/etc/supervisor/conf.d/supervisord.conf" if [ "${DISABLE_MOSQUITTO:-false}" = "true" ] && [ "${DISABLE_CADDY:-false}" = "true" ]; then @@ -34,4 +37,6 @@ elif [ "${DISABLE_CADDY:-false}" = "true" ]; then SUPERVISORD_CONF="/etc/supervisor/conf.d/supervisord-no-caddy.conf" fi +echo "[entrypoint] Go services run as $CORESCOPE_SERVICE_USER (uid=$(id -u "$CORESCOPE_SERVICE_USER") gid=$(id -g "$CORESCOPE_SERVICE_USER"))" + exec /usr/bin/supervisord -c "$SUPERVISORD_CONF" diff --git a/docker/select-service-user.sh b/docker/select-service-user.sh new file mode 100644 index 00000000..3a00ba64 --- /dev/null +++ b/docker/select-service-user.sh @@ -0,0 +1,302 @@ +#!/bin/sh +# select-service-user.sh — decides which user the two Go services run as, and +# hands over the paths they have to write. +# +# Sourced by docker/entrypoint-go.sh, which calls prepare_service_user and +# nothing else from here: the order of the steps (resolve the account, read +# .env, hand over the data directory) is part of what keeps .env from choosing +# the account, so it lives in one function and the test drives that function. +# docker/test-select-service-user.sh replaces getent/adduser/addgroup/deluser/ +# delgroup/id/chown/find/stat with stubs: keep every external command here +# stubbable, keep account resolution free of filesystem writes, and send +# human-readable output to stderr so stdout carries only the account name. +# +# Which user the services run as: +# +# 1. RUN_AS_UID / RUN_AS_GID, when set in the container's environment +# 2. root — what every earlier release did +# +# Both are read before /app/data/.env and never from it: the data directory is +# writable by the service account, and a setting there could be removed by the +# very process it restricts. +# +# There is deliberately no third rule inferring the uid from the mounted data +# directory. Such a rule switches user on upgrade for any deployment whose +# data directory is not root-owned — the ordinary `mkdir data` bind mount — +# while meshcore.db and its -wal/-shm, users.db and the backups inside it stay +# root-owned from the releases that ran as root, leaving the services unable to +# write them. Unprivileged operation is opt-in for that reason. + +# The account baked into the image, at uid 1000. +SERVICE_ACCOUNT=corescope +SERVICE_APP_DIR=/app +SERVICE_DATA_DIR=/app/data +SERVICE_ENV_FILE=/app/data/.env +SERVICE_MOUNTINFO=/proc/self/mountinfo + +svc_log() { + echo "[entrypoint] $*" >&2 +} + +# prepare_service_user +# +# The start-up sequence, in the only order that is safe: +# +# 1. resolve the account, from RUN_AS_UID/RUN_AS_GID in the container's +# environment; +# 2. read /app/data/.env (see load_env_file for when it is trusted); +# 3. hand the data directory to the account, when it is not root. +# +# Returns non-zero, with the reason on stderr, when RUN_AS_UID was set and no +# account could be provided. On success exports CORESCOPE_SERVICE_USER. +# +# The resolved account is kept in the positional parameter, not a variable: the +# sourced .env runs in this shell, and a line in it such as `account=root` would +# otherwise change which account is handed the data directory and exported to +# supervisord. +prepare_service_user() { + local _account + _account=$(resolve_service_account) || return 1 + set -- "$_account" + unset _account + + load_env_file "$SERVICE_ENV_FILE" "$1" + if [ "$1" != root ]; then + claim_service_paths "$1" + fi + CORESCOPE_SERVICE_USER=$1 + export CORESCOPE_SERVICE_USER +} + +# resolve_service_account +# +# Echoes the name of the account the Go services should run as: `root`, or an +# account with uid RUN_AS_UID, created when the image has none at that uid. +# Returns non-zero when RUN_AS_UID was set and no such account could be +# provided — a deployment that asked for unprivileged services must not +# quietly get root ones instead. +resolve_service_account() { + local run_as_uid="${RUN_AS_UID:-}" + local run_as_gid="${RUN_AS_GID:-}" + local account group account_gid + + if [ -z "$run_as_uid" ]; then + svc_log "RUN_AS_UID is unset — the Go services run as root, as in earlier releases." + svc_log "To run them unprivileged, see \"Running the Go services unprivileged\" in docs/deployment.md." + echo root + return 0 + fi + + case "$run_as_uid" in + *[!0-9]*) + svc_log "RUN_AS_UID must be a number, got \"$run_as_uid\"" + return 1 + ;; + esac + : "${run_as_gid:=$run_as_uid}" + case "$run_as_gid" in + *[!0-9]*) + svc_log "RUN_AS_GID must be a number, got \"$run_as_gid\"" + return 1 + ;; + esac + + # Asking for uid 0 is asking for the old behaviour; say so rather than + # treating root as an account to hand files over to. + if [ "$run_as_uid" -eq 0 ]; then + svc_log "RUN_AS_UID=0 — the Go services run as root, as in earlier releases." + echo root + return 0 + fi + + account=$(getent passwd "$run_as_uid" 2>/dev/null | cut -d: -f1) + # The baked-in account in another group is rebuilt below rather than used + # as it is, so RUN_AS_UID=1000 RUN_AS_GID=1001 means gid 1001. An account the + # image needs for something else (caddy, mosquitto) keeps its own group. + if [ "$account" = "$SERVICE_ACCOUNT" ] && [ "$(id -g "$account")" != "$run_as_gid" ]; then + account="" + fi + if [ -z "$account" ]; then + # Nothing usable holds that uid. The baked-in account is not what was + # asked for: free its name, but only now that it is known to be in the + # way. + if getent passwd "$SERVICE_ACCOUNT" >/dev/null 2>&1; then + deluser "$SERVICE_ACCOUNT" >/dev/null 2>&1 || true + fi + if getent group "$SERVICE_ACCOUNT" >/dev/null 2>&1; then + delgroup "$SERVICE_ACCOUNT" >/dev/null 2>&1 || true + fi + group=$(getent group "$run_as_gid" 2>/dev/null | cut -d: -f1) + if [ -z "$group" ] && addgroup -S -g "$run_as_gid" "$SERVICE_ACCOUNT" 2>/dev/null; then + group="$SERVICE_ACCOUNT" + fi + # -H as in the Dockerfile: without it busybox adduser gives the existing + # /app to the account, mode 2755. + if [ -n "$group" ] && adduser -S -u "$run_as_uid" -G "$group" -H \ + -h "$SERVICE_APP_DIR" -s /sbin/nologin "$SERVICE_ACCOUNT" 2>/dev/null; then + account="$SERVICE_ACCOUNT" + fi + fi + + if [ -z "$account" ]; then + svc_log "no account available for uid=$run_as_uid gid=$run_as_gid" + svc_log "RUN_AS_UID was set explicitly — refusing to fall back to root" + return 1 + fi + + account_gid=$(id -g "$account") + if [ "$account_gid" != "$run_as_gid" ]; then + svc_log "note: uid $run_as_uid belongs to the existing account \"$account\"," \ + "whose primary group is $account_gid and not $run_as_gid" + fi + + echo "$account" +} + +# load_env_file +# +# Sources the data directory's .env with every variable exported, as the +# entrypoint always has. When the services run unprivileged that directory is +# theirs, so a .env they could have written or put there is not run as root: it +# is read only when it is a regular file, not a symlink, owned by root and +# writable by no one else. Running as root changes nothing — whoever could +# write the file could already run code as root. +# +# Local names are underscored: the file is sourced into this function. +load_env_file() { + local _env_file="$1" _env_account="$2" + local _uid_before="${RUN_AS_UID-}" _gid_before="${RUN_AS_GID-}" + local _uid_was="${RUN_AS_UID+set}" _gid_was="${RUN_AS_GID+set}" + + [ -f "$_env_file" ] || return 0 + if [ "$_env_account" != root ] && ! env_file_trusted "$_env_file"; then + svc_log "not reading $_env_file: the Go services run as $_env_account and can" + svc_log "write $SERVICE_DATA_DIR, and this file is run as root. Make it a regular" + svc_log "file owned by root and writable only by root to have it read." + return 0 + fi + + set -a + . "$_env_file" + set +a + + # The account was resolved before this file was read, so a RUN_AS_UID or + # RUN_AS_GID in it must not reach supervisord and the services either: put + # back what the container had, set or unset. + if [ "${RUN_AS_UID+set}" != "$_uid_was" ] || [ "${RUN_AS_UID-}" != "$_uid_before" ] || + [ "${RUN_AS_GID+set}" != "$_gid_was" ] || [ "${RUN_AS_GID-}" != "$_gid_before" ]; then + svc_log "ignoring RUN_AS_UID/RUN_AS_GID from $_env_file: set them on the container" + svc_log "(docker run -e, or environment: in compose), where the services cannot remove them." + if [ -n "$_uid_was" ]; then RUN_AS_UID=$_uid_before; export RUN_AS_UID; else unset RUN_AS_UID; fi + if [ -n "$_gid_was" ]; then RUN_AS_GID=$_gid_before; export RUN_AS_GID; else unset RUN_AS_GID; fi + fi +} + +# env_file_trusted +# +# True for a regular file, not a symlink, owned by root and writable by no one +# else. stat without -L describes a symlink itself, so that test is belt and +# braces. +env_file_trusted() { + local _st + [ -L "$1" ] && return 1 + _st=$(stat -c '%u %A' "$1" 2>/dev/null) || return 1 + case "$_st" in + 0\ -????w????|0\ -???????w?) return 1 ;; + 0\ -*) return 0 ;; + esac + return 1 +} + +# service_data_mounts +# +# Mount points inside the data directory, not counting the data directory +# itself, one per line, with the kernel's escapes undone (space, tab, backslash; +# \134 last, so that an escaped backslash followed by digits stays literal). A +# config.json bind-mounted on its own shows up here. +# +# Returns non-zero when a mount point contains a newline: it cannot be listed +# one per line, and the caller must then not hand anything over. +service_data_mounts() { + local _paths _tab + _tab=$(printf '\t') + [ -r "$SERVICE_MOUNTINFO" ] || return 0 + _paths=$(awk -v d="$SERVICE_DATA_DIR/" 'index($5, d) == 1 { print $5 }' "$SERVICE_MOUNTINFO") + case "$_paths" in + *'\012'*) return 1 ;; + esac + printf '%s\n' "$_paths" | sed "s/\\\\040/ /g; s/\\\\011/$_tab/g; s/\\\\134/\\\\/g" +} + +# glob_escape +# +# find -path treats [ ] * ? and \ in its argument as pattern characters; a mount +# point named with one of them must match itself and nothing else. +glob_escape() { + printf '%s\n' "$1" | sed 's/[][*?\\]/\\&/g' +} + +# claim_service_paths +# +# Gives the service account the data directory and what an earlier root-run +# container left in it. /app stays root's: nothing the services write lives +# there, and an operator may run its binaries as root with `docker exec`. +# +# Reached only when RUN_AS_UID named an unprivileged account, and that is what +# makes the hand-over consented to rather than a surprise. It runs on every +# start, so a file later created there as root, by `docker exec` say, is handed +# over at the next one. +# +# Left alone, with what is below them: +# - anything mounted into the data directory on its own, such as a +# config.json bind-mounted as a single file: that is the operator's file on +# the host, and its owner is theirs to choose; +# - .env, which the entrypoint reads as root and so must stay root's. +# +# chown -h re-owns a symlink itself rather than what it points at. A path the +# hand-over could not change — on NFS with root_squash, say — is logged: the +# services would otherwise fail to write it with nothing in the log saying why. +claim_service_paths() { + local account="$1" + local uid gid excluded p left count + + uid=$(id -u "$account") + gid=$(id -g "$account") + + if ! excluded=$(service_data_mounts); then + svc_log "WARNING: a mount point in $SERVICE_DATA_DIR has a newline in its name, so which" + svc_log "files are the operator's cannot be told; not handing $SERVICE_DATA_DIR over." + return 0 + fi + if [ -n "$excluded" ]; then + printf '%s\n' "$excluded" | while IFS= read -r p; do + svc_log "left alone: $p is mounted on its own, so it keeps the owner it has on the host" + done + fi + excluded="$SERVICE_ENV_FILE${excluded:+ +$excluded}" + + # find's prune expression, built in the positional parameters so that + # paths with spaces survive. + set -- + set -f + local IFS=' +' + for p in $excluded; do + [ $# -eq 0 ] || set -- "$@" -o + set -- "$@" -path "$(glob_escape "$p")" + done + unset IFS + set +f + + find "$SERVICE_DATA_DIR" \( "$@" \) -prune -o ! -user "$uid" \ + -exec chown -h "$uid:$gid" {} + 2>/dev/null + + left=$(find "$SERVICE_DATA_DIR" \( "$@" \) -prune -o ! -user "$uid" -print 2>/dev/null) + if [ -n "$left" ]; then + count=$(printf '%s\n' "$left" | wc -l) + svc_log "WARNING: $count path(s) in $SERVICE_DATA_DIR could not be given to uid $uid;" + svc_log "the Go services will fail to write them. First ones:" + printf '%s\n' "$left" | head -n 5 | while IFS= read -r p; do svc_log " $p"; done + fi +} diff --git a/docker/supervisord-go-no-caddy.conf b/docker/supervisord-go-no-caddy.conf index 3ca0af00..3b104744 100644 --- a/docker/supervisord-go-no-caddy.conf +++ b/docker/supervisord-go-no-caddy.conf @@ -15,6 +15,7 @@ stderr_logfile=/dev/stderr stderr_logfile_maxbytes=0 [program:corescope-ingestor] +user=%(ENV_CORESCOPE_SERVICE_USER)s command=/app/corescope-ingestor -config /app/config.json directory=/app autostart=true @@ -29,6 +30,7 @@ stderr_logfile=/dev/stderr stderr_logfile_maxbytes=0 [program:corescope-server] +user=%(ENV_CORESCOPE_SERVICE_USER)s command=/app/corescope-server -config-dir /app -db /app/data/meshcore.db -public /app/public -port 3000 directory=/app autostart=true diff --git a/docker/supervisord-go-no-mosquitto-no-caddy.conf b/docker/supervisord-go-no-mosquitto-no-caddy.conf index 661d7719..8c8c9859 100644 --- a/docker/supervisord-go-no-mosquitto-no-caddy.conf +++ b/docker/supervisord-go-no-mosquitto-no-caddy.conf @@ -6,6 +6,7 @@ logfile_maxbytes=0 pidfile=/var/run/supervisord.pid [program:corescope-ingestor] +user=%(ENV_CORESCOPE_SERVICE_USER)s command=/app/corescope-ingestor -config /app/config.json directory=/app autostart=true @@ -20,6 +21,7 @@ stderr_logfile=/dev/stderr stderr_logfile_maxbytes=0 [program:corescope-server] +user=%(ENV_CORESCOPE_SERVICE_USER)s command=/app/corescope-server -config-dir /app -db /app/data/meshcore.db -public /app/public -port 3000 directory=/app autostart=true diff --git a/docker/supervisord-go-no-mosquitto.conf b/docker/supervisord-go-no-mosquitto.conf index fc98edb1..3c673f2e 100644 --- a/docker/supervisord-go-no-mosquitto.conf +++ b/docker/supervisord-go-no-mosquitto.conf @@ -6,6 +6,7 @@ logfile_maxbytes=0 pidfile=/var/run/supervisord.pid [program:corescope-ingestor] +user=%(ENV_CORESCOPE_SERVICE_USER)s command=/app/corescope-ingestor -config /app/config.json directory=/app autostart=true @@ -20,6 +21,7 @@ stderr_logfile=/dev/stderr stderr_logfile_maxbytes=0 [program:corescope-server] +user=%(ENV_CORESCOPE_SERVICE_USER)s command=/app/corescope-server -config-dir /app -db /app/data/meshcore.db -public /app/public -port 3000 directory=/app autostart=true diff --git a/docker/supervisord-go.conf b/docker/supervisord-go.conf index 315b10ab..18a8f4d7 100644 --- a/docker/supervisord-go.conf +++ b/docker/supervisord-go.conf @@ -15,6 +15,7 @@ stderr_logfile=/dev/stderr stderr_logfile_maxbytes=0 [program:corescope-ingestor] +user=%(ENV_CORESCOPE_SERVICE_USER)s command=/app/corescope-ingestor -config /app/config.json directory=/app autostart=true @@ -29,6 +30,7 @@ stderr_logfile=/dev/stderr stderr_logfile_maxbytes=0 [program:corescope-server] +user=%(ENV_CORESCOPE_SERVICE_USER)s command=/app/corescope-server -config-dir /app -db /app/data/meshcore.db -public /app/public -port 3000 directory=/app autostart=true diff --git a/docker/test-select-service-user.sh b/docker/test-select-service-user.sh new file mode 100755 index 00000000..7269bb4a --- /dev/null +++ b/docker/test-select-service-user.sh @@ -0,0 +1,479 @@ +#!/usr/bin/env bash +# test-select-service-user.sh — unit tests for docker/select-service-user.sh, +# the uid selection the container entrypoint performs at start. +# +# Every branch is exercised without docker and without root: getent, adduser, +# addgroup, deluser, delgroup, id, chown, find and stat are replaced by stubs +# on PATH, and the mount table is a fixture file. The account stubs read and +# write a passwd/group pair of files seeded with the accounts the real image has — verified against alpine:3.20 with +# mosquitto, supervisor and caddy installed, plus the Dockerfile's corescope — +# so an account created mid-run is visible to the lookups that follow it. chown +# and find record their arguments rather than touching the filesystem, which is +# what lets the ownership hand-over be asserted exactly. +# +# The driver calls prepare_service_user, the one function docker/entrypoint-go.sh +# calls: resolve the account, read .env, hand over the data directory. A static +# check at the end pins that the entrypoint calls it and nothing else from the +# script, so what gets tested is the sequence the container runs: a non-zero +# return must never be read as root, and .env cannot choose the account. +# +# Run: bash docker/test-select-service-user.sh +# Exits non-zero if any case fails. +set -u + +SCRIPT="$(cd "$(dirname "$0")" && pwd)/select-service-user.sh" +[ -f "$SCRIPT" ] || { echo "FAIL: $SCRIPT missing" >&2; exit 1; } + +# Accounts the image actually has. uid:gid differ for caddy and mosquitto, +# which is what makes the "uid already belongs to an account" branch testable. +IMAGE_PASSWD='root:0:0 +caddy:100:101 +mosquitto:101:102 +corescope:1000:1000' +IMAGE_GROUP='root:0 +caddy:101 +mosquitto:102 +corescope:1000' + +PASS=0 +FAIL=0 +CASE="" + +fail() { + FAIL=$((FAIL + 1)) + echo "FAIL: $CASE — $1" >&2 +} + +assert_eq() { + if [ "$2" = "$3" ]; then PASS=$((PASS + 1)); else + fail "$1 — expected '$2', got '$3'" + fi +} + +assert_has() { + case "$2" in + *"$3"*) PASS=$((PASS + 1)) ;; + *) fail "$1 — expected to find '$3' in: $2" ;; + esac +} + +assert_lacks() { + case "$2" in + *"$3"*) fail "$1 — did not expect '$3' in: $2" ;; + *) PASS=$((PASS + 1)) ;; + esac +} + +# ----- stub environment ------------------------------------------------------ + +make_fixture() { + FIX=$(mktemp -d) + printf '%s\n' "$IMAGE_PASSWD" > "$FIX/passwd" + printf '%s\n' "$IMAGE_GROUP" > "$FIX/group" + : > "$FIX/calls" + : > "$FIX/log" + printf '%s' "${FIX_MOUNTINFO-}" > "$FIX/mountinfo" + if [ -n "${FIX_ENV-}" ]; then + printf '%s\n' "$FIX_ENV" > "$FIX/env.real" + if [ "${FIX_ENV_SYMLINK:-0}" = 1 ]; then + ln -s "$FIX/env.real" "$FIX/env" + [ -L "$FIX/env" ] || { echo "FAIL: fixture could not create the .env symlink" >&2; exit 1; } + else + mv "$FIX/env.real" "$FIX/env" + fi + fi + mkdir -p "$FIX/bin" + + cat > "$FIX/bin/getent" <<'STUB' +#!/bin/sh +case "$1" in + passwd) + while IFS=: read -r name uid gid; do + [ -n "$name" ] || continue + if [ "$2" = "$name" ] || [ "$2" = "$uid" ]; then + echo "$name:x:$uid:$gid::/:/sbin/nologin"; exit 0 + fi + done < "$FIX/passwd" ;; + group) + while IFS=: read -r name gid; do + [ -n "$name" ] || continue + if [ "$2" = "$name" ] || [ "$2" = "$gid" ]; then + echo "$name:x:$gid:"; exit 0 + fi + done < "$FIX/group" ;; +esac +exit 2 +STUB + + cat > "$FIX/bin/adduser" <<'STUB' +#!/bin/sh +[ "${STUB_ADDUSER_FAIL:-0}" = 1 ] && exit 1 +uid=""; grp=""; name=""; flags="" +while [ $# -gt 0 ]; do + case "$1" in + -u) uid="$2"; shift 2 ;; + -G) grp="$2"; shift 2 ;; + -h|-s) shift 2 ;; + -S|-D|-H) flags="$flags $1"; shift ;; + *) name="$1"; shift ;; + esac +done +[ -n "$uid" ] && [ -n "$name" ] && [ -n "$grp" ] || exit 1 +# busybox refuses a uid that is already taken +awk -F: -v u="$uid" '$2==u{found=1} END{exit !found}' "$FIX/passwd" && exit 1 +gid=$(awk -F: -v g="$grp" '$1==g{print $2}' "$FIX/group") +[ -n "$gid" ] || exit 1 +echo "$name:$uid:$gid" >> "$FIX/passwd" +echo "adduser $name uid=$uid gid=$gid flags=$flags" >> "$FIX/calls" +STUB + + cat > "$FIX/bin/addgroup" <<'STUB' +#!/bin/sh +[ "${STUB_ADDGROUP_FAIL:-0}" = 1 ] && exit 1 +gid=""; name="" +while [ $# -gt 0 ]; do + case "$1" in + -g) gid="$2"; shift 2 ;; + -S|-D) shift ;; + *) name="$1"; shift ;; + esac +done +[ -n "$gid" ] && [ -n "$name" ] || exit 1 +awk -F: -v g="$gid" '$2==g{found=1} END{exit !found}' "$FIX/group" && exit 1 +echo "$name:$gid" >> "$FIX/group" +echo "addgroup $name gid=$gid" >> "$FIX/calls" +STUB + + cat > "$FIX/bin/deluser" <<'STUB' +#!/bin/sh +grep -v "^$1:" "$FIX/passwd" > "$FIX/passwd.new" || true +mv "$FIX/passwd.new" "$FIX/passwd" +echo "deluser $1" >> "$FIX/calls" +STUB + + cat > "$FIX/bin/delgroup" <<'STUB' +#!/bin/sh +grep -v "^$1:" "$FIX/group" > "$FIX/group.new" || true +mv "$FIX/group.new" "$FIX/group" +echo "delgroup $1" >> "$FIX/calls" +STUB + + cat > "$FIX/bin/id" <<'STUB' +#!/bin/sh +while IFS=: read -r name uid gid; do + if [ "$2" = "$name" ]; then + case "$1" in + -u) echo "$uid"; exit 0 ;; + -g) echo "$gid"; exit 0 ;; + esac + exit 2 + fi +done < "$FIX/passwd" +exit 1 +STUB + + cat > "$FIX/bin/chown" <<'STUB' +#!/bin/sh +echo "chown $*" >> "$FIX/calls" +STUB + + # The hand-over walk is recorded; the verification walk (-print) answers + # with STUB_LEFTOVER, the paths a failed chown would have left behind. + cat > "$FIX/bin/find" <<'STUB' +#!/bin/sh +echo "find $*" >> "$FIX/calls" +case " $* " in + *" -print "*) [ -n "${STUB_LEFTOVER-}" ] && printf '%s\n' "$STUB_LEFTOVER" ;; +esac +exit 0 +STUB + + # Answers STUB_STAT ("uid mode"), by default a uid 1000 file: the uid + # must never be inferred from that, and the .env check must refuse it. + # Any call is recorded so the test can prove when none happens. + cat > "$FIX/bin/stat" <<'STUB' +#!/bin/sh +echo "stat $*" >> "$FIX/calls" +echo "${STUB_STAT:-1000 -rw-r--r--}" +STUB + + chmod +x "$FIX"/bin/* +} + +# drive [VAR=VAL ...] — run prepare_service_user as docker/entrypoint-go.sh +# does and exit non-zero if it fails. env -i keeps the host environment (a +# RUN_AS_UID of its own, a real PATH) out of the case. FIX_ENV (the .env +# contents), FIX_ENV_SYMLINK and FIX_MOUNTINFO shape the fixture; a .env that +# sets CS_ENV_READ shows up as "env-read" in CALLS. OUT is the account the +# services will run as (CORESCOPE_SERVICE_USER); FINAL_ENV is RUN_AS_UID/GID as +# the services would see them. +drive() { + make_fixture + OUT=$(env -i PATH="$FIX/bin:/usr/bin:/bin" FIX="$FIX" SCRIPT="$SCRIPT" "$@" sh -c ' + . "$SCRIPT" + SERVICE_ENV_FILE="$FIX/env" + SERVICE_MOUNTINFO="$FIX/mountinfo" + prepare_service_user || exit 1 + [ -z "${CS_ENV_READ-}" ] || echo "env-read" >> "$FIX/calls" + echo "RUN_AS_UID=${RUN_AS_UID-} RUN_AS_GID=${RUN_AS_GID-}" > "$FIX/final-env" + echo "$CORESCOPE_SERVICE_USER" + ' 2>"$FIX/log") + RC=$? + LOG=$(cat "$FIX/log") + CALLS=$(cat "$FIX/calls") + FINAL_ENV=$(cat "$FIX/final-env" 2>/dev/null) +} + +# ----- 1. RUN_AS_UID unset: root, exactly as earlier releases ---------------- +CASE="RUN_AS_UID unset" +drive +assert_eq "account" "root" "$OUT" +assert_eq "exit status" 0 "$RC" +assert_eq "nothing was chowned" "" "$CALLS" +assert_has "log explains the default" "$LOG" "RUN_AS_UID is unset" + +# ----- 2. the dropped inferred branch must not come back --------------------- +# The data directory is reported as owned by uid 1000 (`mkdir data` + bind +# mount, the ordinary setup). Earlier revisions of this change ran the services +# as that uid, which broke every upgrade whose data directory held root-owned +# database files. Staying root here is the point of the whole arrangement. +CASE="non-root data dir, RUN_AS_UID unset" +drive +assert_eq "account" "root" "$OUT" +assert_lacks "data dir owner never consulted" "$CALLS" "stat" +assert_eq "nothing was chowned" "" "$CALLS" + +# ----- 3. RUN_AS_UID=1000 reuses the account baked into the image ------------ +CASE="RUN_AS_UID=1000" +drive RUN_AS_UID=1000 RUN_AS_GID=1000 +assert_eq "account" "corescope" "$OUT" +assert_eq "exit status" 0 "$RC" +assert_lacks "/app stays root's" "$CALLS" "chown 1000:1000 /app" +assert_has "data dir handed over, .env left out, symlinks not followed" "$CALLS" \ + "find /app/data ( -path $FIX/env ) -prune -o ! -user 1000 -exec chown -h 1000:1000 {} +" +assert_has "hand-over checked afterwards" "$CALLS" \ + "find /app/data ( -path $FIX/env ) -prune -o ! -user 1000 -print" +assert_lacks "nothing reported left over" "$LOG" "WARNING" +assert_lacks "no account created" "$CALLS" "adduser" +assert_lacks "baked-in account kept" "$CALLS" "deluser" + +# ----- 4. RUN_AS_GID defaults to RUN_AS_UID --------------------------------- +CASE="RUN_AS_UID=1000, RUN_AS_GID unset" +drive RUN_AS_UID=1000 +assert_eq "account" "corescope" "$OUT" +assert_has "gid defaulted to the uid" "$CALLS" "chown -h 1000:1000" +assert_lacks "no group mismatch reported" "$LOG" "note:" + +# ----- 5. a uid nothing holds: the account is recreated at it ---------------- +CASE="RUN_AS_UID=5000" +drive RUN_AS_UID=5000 +assert_eq "account" "corescope" "$OUT" +assert_eq "exit status" 0 "$RC" +assert_has "baked-in account freed" "$CALLS" "deluser corescope" +assert_has "baked-in group freed" "$CALLS" "delgroup corescope" +assert_has "group created at the gid" "$CALLS" "addgroup corescope gid=5000" +assert_has "account created at the uid" "$CALLS" "adduser corescope uid=5000 gid=5000" +assert_has "home directory not created or re-owned" "$CALLS" "flags= -S -H" +assert_has "data dir handed over" "$CALLS" "chown -h 5000:5000" + +# ----- 6. a separate RUN_AS_GID --------------------------------------------- +CASE="RUN_AS_UID=5000, RUN_AS_GID=6000" +drive RUN_AS_UID=5000 RUN_AS_GID=6000 +assert_eq "account" "corescope" "$OUT" +assert_has "group created at the gid" "$CALLS" "addgroup corescope gid=6000" +assert_has "account created with that group" "$CALLS" "adduser corescope uid=5000 gid=6000" +assert_has "data dir handed over" "$CALLS" "chown -h 5000:6000" + +# ----- 7. a uid an image account already holds ------------------------------ +# caddy is uid 100 but gid 101, so the requested gid cannot be honoured; the +# account is used as it is and the mismatch is reported rather than hidden. +CASE="RUN_AS_UID=100 (caddy)" +drive RUN_AS_UID=100 +assert_eq "account" "caddy" "$OUT" +assert_eq "exit status" 0 "$RC" +assert_lacks "existing account untouched" "$CALLS" "adduser" +assert_has "mismatch reported" "$LOG" "primary group is 101 and not 100" +assert_has "account's own gid used" "$CALLS" "chown -h 100:101" + +# ----- 8. RUN_AS_UID=0 is a request for the old behaviour ------------------- +CASE="RUN_AS_UID=0" +drive RUN_AS_UID=0 +assert_eq "account" "root" "$OUT" +assert_eq "exit status" 0 "$RC" +assert_eq "nothing was chowned" "" "$CALLS" + +# ----- 9. non-numeric input is rejected ------------------------------------- +CASE="RUN_AS_UID=nobody" +drive RUN_AS_UID=nobody +assert_eq "exit status" 1 "$RC" +assert_eq "no account on stdout" "" "$OUT" +assert_has "reason given" "$LOG" "RUN_AS_UID must be a number" + +CASE="RUN_AS_GID=wheel" +drive RUN_AS_UID=5000 RUN_AS_GID=wheel +assert_eq "exit status" 1 "$RC" +assert_eq "no account on stdout" "" "$OUT" +assert_has "reason given" "$LOG" "RUN_AS_GID must be a number" + +# ----- 10. an explicit request that cannot be met must not become root ------ +CASE="RUN_AS_UID=5000, adduser fails" +drive RUN_AS_UID=5000 STUB_ADDUSER_FAIL=1 +assert_eq "exit status" 1 "$RC" +assert_eq "no account on stdout" "" "$OUT" +assert_has "refusal is explicit" "$LOG" "refusing to fall back to root" +assert_lacks "nothing handed over" "$CALLS" "chown" + +CASE="RUN_AS_UID=5000, addgroup fails" +drive RUN_AS_UID=5000 STUB_ADDGROUP_FAIL=1 +assert_eq "exit status" 1 "$RC" +assert_eq "no account on stdout" "" "$OUT" +assert_has "refusal is explicit" "$LOG" "refusing to fall back to root" + +# ----- 11. RUN_AS_GID is honoured for the baked-in account ----------------- +# uid 1000 is the image's own corescope account, gid 1000. Asked for gid 1001, +# it is rebuilt in that group rather than used with the wrong one. +CASE="RUN_AS_UID=1000, RUN_AS_GID=1001" +drive RUN_AS_UID=1000 RUN_AS_GID=1001 +assert_eq "account" "corescope" "$OUT" +assert_has "baked-in account rebuilt" "$CALLS" "deluser corescope" +assert_has "group created at the gid" "$CALLS" "addgroup corescope gid=1001" +assert_has "account recreated in it" "$CALLS" "adduser corescope uid=1000 gid=1001" +assert_has "data dir handed to that gid" "$CALLS" "chown -h 1000:1001" +assert_lacks "no group mismatch left to report" "$LOG" "note:" + +# ----- 12. .env as root: read as before, whoever owns it ------------------- +CASE=".env, RUN_AS_UID unset" +FIX_ENV='CS_ENV_READ=1' drive +assert_eq "account" "root" "$OUT" +assert_has ".env read" "$CALLS" "env-read" +assert_lacks "owner not checked as root" "$CALLS" "stat" + +# ----- 13. .env, unprivileged: only a root-owned, root-only-writable file ---- +CASE=".env root-owned 0644, RUN_AS_UID=1000" +FIX_ENV='CS_ENV_READ=1' drive RUN_AS_UID=1000 STUB_STAT='0 -rw-r--r--' +assert_eq "account" "corescope" "$OUT" +assert_has ".env read" "$CALLS" "env-read" + +CASE=".env root-owned 0600, RUN_AS_UID=1000" +FIX_ENV='CS_ENV_READ=1' drive RUN_AS_UID=1000 STUB_STAT='0 -rw-------' +assert_has ".env read" "$CALLS" "env-read" + +CASE=".env owned by the service uid, RUN_AS_UID=1000" +FIX_ENV='CS_ENV_READ=1' drive RUN_AS_UID=1000 STUB_STAT='1000 -rw-r--r--' +assert_eq "account" "corescope" "$OUT" +assert_lacks ".env not read" "$CALLS" "env-read" +assert_has "refusal explained" "$LOG" "not reading $FIX/env" + +CASE=".env root-owned but group-writable, RUN_AS_UID=1000" +FIX_ENV='CS_ENV_READ=1' drive RUN_AS_UID=1000 STUB_STAT='0 -rw-rw-r--' +assert_lacks ".env not read" "$CALLS" "env-read" + +CASE=".env root-owned but world-writable, RUN_AS_UID=1000" +FIX_ENV='CS_ENV_READ=1' drive RUN_AS_UID=1000 STUB_STAT='0 -rw-r--rw-' +assert_lacks ".env not read" "$CALLS" "env-read" + +CASE=".env a symlink, RUN_AS_UID=1000" +FIX_ENV='CS_ENV_READ=1' FIX_ENV_SYMLINK=1 drive RUN_AS_UID=1000 STUB_STAT='0 -rw-r--r--' +assert_lacks ".env not read" "$CALLS" "env-read" +assert_lacks "refused before its owner is asked" "$CALLS" "stat" + +# ----- 14. .env cannot choose the account ----------------------------------- +# A service that can delete .env could otherwise drop its own RUN_AS_UID, and +# one that can write it could set RUN_AS_UID= and be root after a restart. +CASE="RUN_AS_UID in .env, unset on the container" +FIX_ENV='RUN_AS_UID=1000' drive +assert_eq "account" "root" "$OUT" +assert_eq "nothing was chowned" "" "$CALLS" +assert_has "ignored, and said so" "$LOG" "ignoring RUN_AS_UID/RUN_AS_GID" +assert_eq "and not left exported to the services" "RUN_AS_UID= RUN_AS_GID=" "$FINAL_ENV" + +CASE="RUN_AS_UID= in a trusted .env, 1000 on the container" +FIX_ENV='RUN_AS_UID=' drive RUN_AS_UID=1000 STUB_STAT='0 -rw-r--r--' +assert_eq "account" "corescope" "$OUT" +assert_has "ignored, and said so" "$LOG" "ignoring RUN_AS_UID/RUN_AS_GID" +assert_eq "the container's value is put back" "RUN_AS_UID=1000 RUN_AS_GID=" "$FINAL_ENV" + +CASE="RUN_AS_UID=0 and RUN_AS_GID in a trusted .env, 1000 on the container" +FIX_ENV='RUN_AS_UID=0 +RUN_AS_GID=0' drive RUN_AS_UID=1000 RUN_AS_GID=1000 STUB_STAT='0 -rw-r--r--' +assert_eq "account" "corescope" "$OUT" +assert_eq "both put back" "RUN_AS_UID=1000 RUN_AS_GID=1000" "$FINAL_ENV" + +# The account is resolved before .env is read. If it were read first, a +# RUN_AS_UID there would make the resolver create an account at that uid. +CASE="order: .env read after the account is resolved" +FIX_ENV='RUN_AS_UID=5000' drive RUN_AS_UID=1000 STUB_STAT='0 -rw-r--r--' +assert_eq "account" "corescope" "$OUT" +assert_lacks "no account was created at the uid from .env" "$CALLS" "adduser" +assert_lacks "none was freed for it either" "$CALLS" "deluser" +assert_has "the data dir went to the container's uid" "$CALLS" "chown -h 1000:1000" + +# A trusted .env that assigns the names the entrypoint and these functions use +# for the account must not change who the services run as, or who is handed +# the data directory. +CASE=".env assigns the account's variable names" +FIX_ENV='account=root +service_account=root +_account=root +CORESCOPE_SERVICE_USER=root' drive RUN_AS_UID=1000 STUB_STAT='0 -rw-r--r--' +assert_eq "account" "corescope" "$OUT" +assert_has "the data dir was still handed over to it" "$CALLS" "chown -h 1000:1000" + +# ----- 15. a file mounted into the data directory keeps its host owner ----- +# The documented single-file config mount, plus one with a space in its path, +# as the kernel escapes it. The data directory's own mount is not excluded. +CASE="single-file mounts in the data directory" +FIX_MOUNTINFO='600 500 0:50 /srv/data /app/data rw,relatime - ext4 /dev/sda1 rw +601 600 0:50 /srv/config.json /app/data/config.json ro,relatime - ext4 /dev/sda1 rw +602 600 0:50 /srv/my\040keys /app/data/my\040keys rw,relatime - ext4 /dev/sda1 rw +603 500 0:51 / /data/caddy rw,relatime - ext4 /dev/sda1 rw +' drive RUN_AS_UID=1000 +assert_has "mounts pruned along with .env" "$CALLS" \ + "find /app/data ( -path $FIX/env -o -path /app/data/config.json -o -path /app/data/my keys ) -prune -o ! -user 1000 -exec chown -h 1000:1000 {} +" +assert_has "config.json reported as left alone" "$LOG" "left alone: /app/data/config.json" +assert_lacks "data dir itself still handed over" "$CALLS" "-path /app/data )" +assert_lacks "other mounts ignored" "$CALLS" "/data/caddy" + +# Names the kernel escapes (tab) or that find would read as patterns ([ * ?) +# must exclude exactly that path. +CASE="mount points with a tab and glob characters" +FIX_MOUNTINFO='601 600 0:50 /srv/a /app/data/tab\011name rw,relatime - ext4 /dev/sda1 rw +602 600 0:50 /srv/b /app/data/b[1]*? rw,relatime - ext4 /dev/sda1 rw +' drive RUN_AS_UID=1000 +TAB=$(printf '\t') +assert_has "tab decoded" "$CALLS" "-path /app/data/tab${TAB}name" +assert_has "glob characters escaped" "$CALLS" '-path /app/data/b\[1\]\*\?' + +# A newline in a mount point cannot be listed one per line. Failing closed +# means nothing is handed over, not that the file is handed over by mistake. +CASE="mount point with a newline" +FIX_MOUNTINFO='601 600 0:50 /srv/c /app/data/x\012y rw,relatime - ext4 /dev/sda1 rw +' drive RUN_AS_UID=1000 +assert_eq "account" "corescope" "$OUT" +assert_eq "startup continues" 0 "$RC" +assert_lacks "nothing handed over" "$CALLS" "chown" +assert_has "and said why" "$LOG" "has a newline in its name" + +# ----- 16. a hand-over that could not complete is logged ------------------- +CASE="chown refused (root_squash NFS)" +drive RUN_AS_UID=1000 STUB_LEFTOVER='/app/data +/app/data/meshcore.db' +assert_eq "account" "corescope" "$OUT" +assert_eq "startup continues" 0 "$RC" +assert_has "warning names the count" "$LOG" "WARNING: 2 path(s) in /app/data could not be given to uid 1000" +assert_has "and the paths" "$LOG" " /app/data/meshcore.db" + +# ----- 17. the entrypoint runs the sequence through prepare_service_user ---- +# The driver above tests the function; this pins that the entrypoint uses it +# and does not rebuild the sequence from the steps, in an order of its own. +CASE="entrypoint-go.sh" +ENTRYPOINT="$(dirname "$SCRIPT")/entrypoint-go.sh" +EP=$(cat "$ENTRYPOINT") +assert_has "calls prepare_service_user" "$EP" "prepare_service_user || exit 1" +assert_lacks "does not resolve the account itself" "$EP" "resolve_service_account" +assert_lacks "does not read .env itself" "$EP" "load_env_file" +assert_lacks "does not hand over paths itself" "$EP" "claim_service_paths" + +# ----- report --------------------------------------------------------------- +echo "select-service-user: $PASS assertions passed, $FAIL failed" +[ "$FAIL" -eq 0 ] || exit 1 diff --git a/docs/deployment.md b/docs/deployment.md index 4b1142bc..0eb8c217 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -78,6 +78,8 @@ docker run -d --name corescope \ | `-e DISABLE_MOSQUITTO=true` | No | Skip the internal Mosquitto broker (use your own) | | `-e DISABLE_CADDY=true` | No | Skip the built-in Caddy reverse proxy | | `-e MQTT_BROKER=mqtt://host:1883` | No | Override MQTT broker URL | +| `-e RUN_AS_UID=1000` | No | Run the Go services as this uid instead of root — see [Running the Go services unprivileged](#running-the-go-services-unprivileged) | +| `-e RUN_AS_GID=1000` | No | Group for `RUN_AS_UID` (defaults to it) | #### `/app/data/.env` convenience file @@ -92,6 +94,19 @@ MQTT_BROKER=mqtt://my-broker:1883 The entrypoint sources this file before starting services. This works with any launch method (`docker run`, compose, or manage.sh). +Two exceptions, both because the entrypoint runs this file as root from a +directory the Go services can write once they run unprivileged: + +- `RUN_AS_UID` and `RUN_AS_GID` are never taken from `.env`. Set them with + `-e` or in compose's `environment:`. +- With `RUN_AS_UID` set, `.env` is read only if it is owned by root and + writable by no one else (`sudo chown root:root .env && sudo chmod 644 .env`). + Otherwise the log says it was skipped, and why. "Owned by root" means uid 0 + inside the container. With rootless Docker or a user-namespace remap + (`userns-remap`), host root is not that uid: look at the owner the container + reports (`docker exec stat -c '%u' /app/data/.env`) and give the host + file whichever owner id maps to container uid 0. + ### Docker Compose (legacy alternative) Docker Compose files are maintained for backward compatibility but are no longer the recommended approach. @@ -142,6 +157,84 @@ docker run -d --name corescope ... # same flags as before Data is preserved in the volume — updates are non-destructive. +### Running the Go services unprivileged + +`corescope-server` and `corescope-ingestor` can run as a normal user instead of +root, so that a code-execution bug in either one does not land as root in a +container that has your data directory and `config.json` mounted. `supervisord` +itself stays root, because mosquitto and caddy still need to bind their ports. + +It is opt-in, with `RUN_AS_UID`: + +| `RUN_AS_UID` | Services run as | +|--------------|-----------------| +| unset (the default) | root — as in earlier releases | +| set | `RUN_AS_UID` / `RUN_AS_GID` | + +Nothing about an upgrade changes until you set it: the container does not pick a +uid for you, and does not change the owner of anything on your host. That is +deliberate rather than cautious. Every release before this one ran as root, so +the `meshcore.db` in your data directory — with its `-wal` and `-shm`, `users.db` +and the backups beside them — is root-owned. Switching user without also handing +those files over leaves the services unable to write the database, so the switch +has to be something you ask for. + +**To turn it on**, pick the uid you want and restart. Setting `RUN_AS_UID` is +taken as permission to `chown` the data directory, and what an earlier root-run +container left in it, to that uid. This happens at every start, so a file +created there later as root (with `docker exec`, say) is handed over at the +next restart: + +```bash +docker run -d --name corescope \ + -e RUN_AS_UID=1000 -e RUN_AS_GID=1000 \ + -v /your/data:/app/data \ + ... # same flags as before + ghcr.io/kpa-clawbot/corescope:latest +``` + +Check which user it picked: + +```bash +docker logs corescope 2>&1 | grep '\[entrypoint\] Go services run as' +# [entrypoint] Go services run as corescope (uid=1000 gid=1000) +``` + +If you would rather the container never `chown` anything, do the hand-over +yourself first. Then there is nothing left for it to change: + +```bash +docker stop corescope +sudo chown -R 1000:1000 /your/data # uid of your own account is usually 1000 +docker start corescope # with RUN_AS_UID=1000 set as above +``` + +To go back, unset `RUN_AS_UID` and `chown -R root:root /your/data`. + +Notes: + +- `RUN_AS_GID` defaults to `RUN_AS_UID` when omitted. `RUN_AS_UID=0` is the + same as leaving it unset. +- If the uid already belongs to another account inside the image (`caddy` is + uid 100, `mosquitto` 101), that account is used as it is, keeping its own + group, and the log says so. Pick a different uid if you need `RUN_AS_GID` + honoured. +- With `RUN_AS_UID` set to a uid the container cannot use, startup fails rather + than quietly falling back to root. +- What the hand-over leaves alone: anything mounted into the data directory on + its own, such as `-v ./config.json:/app/data/config.json:ro`, which keeps the + owner it has on your host (make sure the uid can read it), and `.env` (see + above). Each mount it skips is named in the log. +- If the hand-over cannot change a file, for example on NFS with + `root_squash`, the log says how many and which. Fix their owner on the host. +- `/app` stays root's. The services write only to the data directory and + `/tmp`. +- A fresh install is root-owned and so starts as root, like any other. Set + `RUN_AS_UID` from the first run to avoid a hand-over later. + +The uid selection lives in `docker/select-service-user.sh`, with its branches +covered by `docker/test-select-service-user.sh`. + --- ## Configuration Reference