fix(docker): run the Go services as an unprivileged user (#2125)

Runs `corescope-server` and `corescope-ingestor` unprivileged, opt-in
with `RUN_AS_UID`. A code-execution bug in either one then no longer
lands as root next to the mounted data and `config.json`. `supervisord`
stays root, for mosquitto and caddy.

| `RUN_AS_UID` | Go services run as |
|---|---|
| unset (default) | root, as before. Upgrades change nothing |
| set | `RUN_AS_UID` / `RUN_AS_GID` |

With `RUN_AS_UID` set:
- Each start hands the data directory to that uid (`chown -h`), except
`.env` and anything mounted into it on its own. Failures are logged.
- `.env` is read only if it is root-owned and only root can write it.
`RUN_AS_UID` and `RUN_AS_GID` are never taken from it.
- `/app` stays root's. **Depends on #2137.**
- A uid with no account in the image gets one at start; `caddy` and
`mosquitto` are reused. A uid that can't be provided fails startup
instead of falling back to root.

The logic is in `docker/select-service-user.sh`, tested by
`docker/test-select-service-user.sh`, which needs no Docker or root and
runs in CI. See also "Running the Go services unprivileged" in
`docs/deployment.md`.

Verified in the built image (master + this + #2137) across 14 cases.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Mythos 5.1 <noreply@anthropic.com>
Co-authored-by: nullrouten <201262769+nullrouten0@users.noreply.github.com>
This commit is contained in:
nullrouten0
2026-10-15 09:49:29 +02:00
committed by GitHub
co-authored by Claude Mythos 5.1 nullrouten
parent 1ae4c74913
commit a164a44e7f
10 changed files with 915 additions and 6 deletions
+3
View File
@@ -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
+19
View File
@@ -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
+11 -6
View File
@@ -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"
+302
View File
@@ -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 <path> <account>
#
# 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 <path>
#
# 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 <path>
#
# 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 <account>
#
# 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
}
+2
View File
@@ -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
@@ -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
+2
View File
@@ -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
+2
View File
@@ -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
+479
View File
@@ -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-<unset>} RUN_AS_GID=${RUN_AS_GID-<unset>}" > "$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=<unset> RUN_AS_GID=<unset>" "$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=<unset>" "$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
+93
View File
@@ -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 <container> 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