mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-11-12 02:17:46 +00:00
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>
303 lines
12 KiB
Bash
303 lines
12 KiB
Bash
#!/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
|
|
}
|