rig/commands/runner-install.sh
cluade-reviewer-andresmgsl 7f2501d0fe fix: refuse a PATH without /usr/sbin, before the token prompt
Reported from a real ci-box: `rig forgejo-runner install` read a registration
token off the operator's terminal and then died with

  …/forgejo-runner-install.sh: line 250: useradd: command not found

rig checked `id -u` and concluded it could administer the machine. Being root
and being able to FIND the admin binaries are different facts, and only the
first was asserted. `su` without `-`, sudo with a sanitised secure_path, and
several container images all produce a root shell with no /usr/sbin on PATH,
which is where useradd lives.

Three call sites had it: both runner installers and users apply. The last is
the worst — it runs mid-convergence, so a PATH-shorn root could fail partway
through a user sweep rather than before it starts.

require_admin_bins refuses rather than repairing PATH itself: a command that
quietly prepends /usr/sbin teaches the operator nothing and leaves a
misconfigured host misconfigured. The message names the remedy and,
deliberately, not this script — echoing an internal path back at someone who
typed `rig forgejo-runner install` is the unhelpful half of the original error.

It sits beside each root check, so identity and capability are asserted
together and before anything is spent. A secret typed for a run that could
never succeed is the avoidable half of this bug, and there is a test for
exactly that ordering.

Closes #139

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 18:39:03 +00:00

208 lines
8.9 KiB
Bash
Executable file

#!/usr/bin/env bash
# rig runner install — GitHub Actions self-hosted runner as a systemd service
# under an unprivileged user. Outbound-only (long-poll to GitHub), no Docker.
# Convergent toward --repo: re-running against the repo the box is already on
# leaves it alone; a box registered to a DIFFERENT repo is refused, never
# silently restarted on the old one (that is `repoint`'s job).
set -euo pipefail
HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
# shellcheck source=SCRIPTDIR/lib/runner-config.sh
. "$HERE/lib/runner-config.sh"
# shellcheck source=SCRIPTDIR/lib/admin-path.sh
. "$HERE/lib/admin-path.sh"
log() { printf 'rig-runner: %s\n' "$*"; }
warn() { printf 'rig-runner: WARNING: %s\n' "$*" >&2; }
die() { printf 'rig-runner: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; }
usage() {
cat <<'EOF'
usage: rig runner install --repo <owner/repo> [options]
--repo <owner/repo> GitHub repository the runner registers to (required)
--version <pin> actions/runner release to install, e.g. 2.335.1
(default: the latest release, resolved at install
time — safe here because the runner self-updates
regardless; pin it when you need a deterministic,
auditable install)
--name <name> runner name (default: this host's hostname)
--labels <csv> runner labels; replaces the default (default: ci-runner)
--user <name> unprivileged service user (default: github-runner;
created if absent; never root)
Installs GitHub's official actions/runner as a systemd service under an
unprivileged user. The runner is an agent, not a server: it long-polls
GitHub outbound and needs ZERO inbound ports. No Docker is installed and
the runner user gets no supplementary groups.
Provide the short-lived registration token via the RUNNER_TOKEN env var or
the interactive prompt (get one from the repo's Settings > Actions >
Runners > "New self-hosted runner", or:
gh api -X POST repos/<owner/repo>/actions/runners/registration-token).
It is consumed at registration and never written to disk by rig.
Convergent toward --repo: re-running against the repo this box is already on
re-uses the binary, skips registration, and never asks for a token. A box
registered to a DIFFERENT repo is refused — moving a runner is
`rig runner repoint --repo <owner/repo>`.
EOF
}
# --- args (validated before the root check, so errors are testable) ---------
REPO=""
VERSION=""
RUNNER_NAME="$(hostname)"
LABELS="ci-runner"
RUNNER_USER="github-runner"
while [ $# -gt 0 ]; do
case "$1" in
--repo)
[ $# -ge 2 ] || die "--repo needs a value" 2
REPO="$2"; shift 2 ;;
--version)
[ $# -ge 2 ] || die "--version needs a value" 2
VERSION="$2"; shift 2 ;;
--name)
[ $# -ge 2 ] || die "--name needs a value" 2
RUNNER_NAME="$2"; shift 2 ;;
--labels)
[ $# -ge 2 ] || die "--labels needs a value" 2
LABELS="$2"; shift 2 ;;
--user)
[ $# -ge 2 ] || die "--user needs a value" 2
RUNNER_USER="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) die "unknown flag: $1" 2 ;;
esac
done
# --- validation ----------------------------------------------------------
[ -n "$REPO" ] || die "--repo <owner/repo> is required" 2
if ! printf '%s' "$REPO" | grep -qE '^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$'; then
die "--repo must be owner/repo" 2
fi
VERSION="${VERSION#v}"
[ "$RUNNER_USER" != "root" ] || die "runner user must not be root" 2
# --- guards ----------------------------------------------------------------
[ "$(id -u)" -eq 0 ] || die "must run as root"
# Root is not enough: the admin binaries must also be reachable (#139). This
# sits beside the root check so identity and capability are asserted together,
# and BEFORE any prompt or download — a token typed for a doomed run is waste.
require_admin_bins useradd
if [ -r /etc/os-release ]; then
# Sourced in a subshell: os-release defines VERSION (e.g. "13 (trixie)"),
# which would clobber this script's $VERSION.
# shellcheck source=/dev/null
OS_FAMILY="$(. /etc/os-release && printf '%s %s' "${ID:-}" "${ID_LIKE:-}")"
case "$OS_FAMILY" in
*debian*) ;;
*) warn "not a Debian-family system (${OS_FAMILY:-unknown}); proceeding anyway" ;;
esac
else
warn "cannot read /etc/os-release; proceeding anyway"
fi
command -v curl >/dev/null || die "curl is required (run rig bootstrap first)"
# --- is this box already registered somewhere else? --------------------------
# Before anything is prompted for, downloaded, or started: --repo must agree
# with what is already on the box. Everything below this point treats an
# existing .runner as "nothing to do" — which is right for the repo the box is
# already on, and silently wrong for any other. See assert_runner_repo.
#
# Registration is pending unless the runner user already exists AND
# $RUNNER_DIR/.runner exists (user absent => nothing can be registered).
REG_PENDING=1
if id -u "$RUNNER_USER" >/dev/null 2>&1; then
USER_HOME="$(getent passwd "$RUNNER_USER" | cut -d: -f6)"
RUNNER_DIR="$USER_HOME/actions-runner"
assert_runner_repo "$RUNNER_DIR" "$REPO" || exit 1
if [ -e "$RUNNER_DIR/.runner" ]; then
REG_PENDING=0
fi
fi
# --- registration token — only when registration is actually pending -------
if [ "$REG_PENDING" -eq 1 ]; then
RUNNER_TOKEN="${RUNNER_TOKEN:-}"
# Prompt only on a tty: headless, a bare `read` dies under set -e with no
# message at all (drill-measured). Refuse loudly, naming the variable.
if [ -z "$RUNNER_TOKEN" ]; then
[ -t 0 ] || die "RUNNER_TOKEN is unset and stdin is not a tty — set RUNNER_TOKEN to run unattended"
read -rsp "runner registration token (short-lived): " RUNNER_TOKEN || { echo; die "no registration token read (EOF) — set RUNNER_TOKEN to run unattended"; }
echo
fi
[ -n "$RUNNER_TOKEN" ] || die "empty registration token"
fi
# --- user --------------------------------------------------------------------
if ! id -u "$RUNNER_USER" >/dev/null 2>&1; then
useradd --create-home --shell /bin/bash "$RUNNER_USER"
log "created user ${RUNNER_USER}"
else
log "user exists"
fi
USER_HOME="$(getent passwd "$RUNNER_USER" | cut -d: -f6)"
RUNNER_DIR="$USER_HOME/actions-runner"
# --- download + unpack ------------------------------------------------------
if [ -e "$RUNNER_DIR/bin/Runner.Listener" ]; then
log "runner binary already present; skipping download (self-update owns upgrades)"
else
case "$(uname -m)" in
x86_64) ARCH="x64" ;;
aarch64) ARCH="arm64" ;;
*) die "unsupported arch: $(uname -m)" ;;
esac
if [ -z "$VERSION" ]; then
# No pin given: resolve the latest release by following the redirect on
# the /releases/latest page — no API call, no rate limit, no JSON to
# parse on a dependency-free box.
LATEST_URL="$(curl -fsSLI -o /dev/null -w '%{url_effective}' \
https://github.com/actions/runner/releases/latest)" \
|| die "could not resolve the latest actions/runner release"
VERSION="${LATEST_URL##*/}"
VERSION="${VERSION#v}"
case "$VERSION" in
""|*[!0-9.]*) die "could not parse a version from ${LATEST_URL}" ;;
esac
log "resolved latest actions/runner: ${VERSION}"
fi
URL="https://github.com/actions/runner/releases/download/v${VERSION}/actions-runner-linux-${ARCH}-${VERSION}.tar.gz"
WORKDIR="$(mktemp -d)"
cleanup() { rm -rf "$WORKDIR"; }
trap cleanup EXIT
log "downloading actions/runner ${VERSION} (${ARCH})"
curl -fsSL "$URL" -o "$WORKDIR/runner.tar.gz"
mkdir -p "$RUNNER_DIR"
tar xzf "$WORKDIR/runner.tar.gz" -C "$RUNNER_DIR"
chown -R "$RUNNER_USER:$RUNNER_USER" "$RUNNER_DIR"
log "installing runner native dependencies"
"$RUNNER_DIR"/bin/installdependencies.sh
fi
# --- configure ---------------------------------------------------------------
if [ -e "$RUNNER_DIR/.runner" ]; then
log "already registered; skipping configure"
else
log "registering runner ${RUNNER_NAME} against ${REPO}"
(cd "$RUNNER_DIR" && runuser -u "$RUNNER_USER" -- env HOME="$USER_HOME" \
./config.sh --url "https://github.com/${REPO}" --token "$RUNNER_TOKEN" \
--name "$RUNNER_NAME" --labels "$LABELS" --unattended --replace)
# GitHub owns the labels and the runner does not persist them locally, so
# `runner status` and `runner repoint` would have nothing to read. Record
# what we registered with — box-local metadata, never a credential.
printf '%s\n' "$LABELS" > "$RUNNER_DIR/.rig-labels"
chown "$RUNNER_USER:$RUNNER_USER" "$RUNNER_DIR/.rig-labels"
fi
# --- service -------------------------------------------------------------
if [ ! -e "$RUNNER_DIR/.service" ]; then
(cd "$RUNNER_DIR" && ./svc.sh install "$RUNNER_USER")
fi
(cd "$RUNNER_DIR" && ./svc.sh start)
log "runner ${RUNNER_NAME} (labels: ${LABELS}) installed and running"
log "verify it shows Idle under the repo's Settings > Actions > Runners"
log "the deny-all provider firewall stays the operator's job outside rig — this box needs no inbound ports for the runner"