From 4681b135db74e08bbb60b6925145b3bf9fbfddca Mon Sep 17 00:00:00 2001 From: nexxo Date: Sat, 25 Jul 2026 23:25:59 +0200 Subject: [PATCH] feat(deploy): run under a dedicated account instead of root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The installer still needs root for apt and the firewall, but it now creates a clupilot service account that owns the checkout and runs Docker, maps the container user to it (HOST_UID/GID), and update.sh refuses to run as root — root-owned files in the checkout are files the application cannot write. The account has no password login: docker group membership is root-equivalent on the host, so it is reachable only via sudo -u or an SSH key. Co-Authored-By: Claude Opus 4.8 --- deploy/install.sh | 79 +++++++++++++++++++++++++++++++++------------- deploy/update.sh | 8 +++++ docs/deployment.md | 13 ++++++-- 3 files changed, 75 insertions(+), 25 deletions(-) diff --git a/deploy/install.sh b/deploy/install.sh index 27cda5d..b4bf3e2 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -20,6 +20,9 @@ set -euo pipefail REPO_URL="${REPO_URL:-https://git.bave.dev/boban/CluPilotCloud.git}" INSTALL_DIR="${INSTALL_DIR:-/opt/clupilot}" BRANCH="${BRANCH:-main}" +# The application never runs as root. This account owns the checkout and is the +# one in the docker group; the installer itself needs root only for apt. +APP_USER="${APP_USER:-clupilot}" log() { printf '\n\033[1;34m==>\033[0m %s\n' "$*"; } warn() { printf '\033[1;33m !\033[0m %s\n' "$*"; } @@ -94,22 +97,50 @@ docker compose version >/dev/null || die "docker compose plugin is missing." modprobe wireguard 2>/dev/null || warn "Could not load the wireguard module — check the kernel." echo wireguard > /etc/modules-load.d/wireguard.conf +# ── 2b. Service account ────────────────────────────────────────────────────── +if id -u "$APP_USER" >/dev/null 2>&1; then + log "Using existing account $APP_USER" +else + log "Creating the service account $APP_USER" + # A login shell, because you will want to su into it to run deploy/update.sh. + useradd --create-home --shell /bin/bash "$APP_USER" + passwd --lock "$APP_USER" >/dev/null # key/su access only, no password login +fi + +# Membership in the docker group is root-equivalent on this host — that is +# inherent to Docker, and the reason this account has no password login. +usermod -aG docker "$APP_USER" + +APP_UID="$(id -u "$APP_USER")" +APP_GID="$(id -g "$APP_USER")" + # ── 3. Source ──────────────────────────────────────────────────────────────── +# as_app CMD… — run a command as the service account, never as root +as_app() { runuser -u "$APP_USER" -- "$@"; } + +install -d -o "$APP_USER" -g "$APP_USER" -m 0755 "$(dirname "$INSTALL_DIR")" + if [[ -d "$INSTALL_DIR/.git" ]]; then log "Updating existing checkout in $INSTALL_DIR" - git -C "$INSTALL_DIR" fetch --quiet origin "$BRANCH" - git -C "$INSTALL_DIR" checkout --quiet "$BRANCH" - git -C "$INSTALL_DIR" pull --quiet --ff-only origin "$BRANCH" + chown -R "$APP_USER:$APP_USER" "$INSTALL_DIR" + as_app git -C "$INSTALL_DIR" fetch --quiet origin "$BRANCH" + as_app git -C "$INSTALL_DIR" checkout --quiet "$BRANCH" + as_app git -C "$INSTALL_DIR" pull --quiet --ff-only origin "$BRANCH" else log "Cloning into $INSTALL_DIR" - # The token is passed for this one command and never written to disk or to - # the git remote — a later `git pull` will ask, or use a deploy key. + install -d -o "$APP_USER" -g "$APP_USER" -m 0750 "$INSTALL_DIR" + # The token is used for this one command and never written to disk or into + # the git remote — later pulls use a deploy key or ask. auth_url="${REPO_URL/https:\/\//https://oauth2:${GIT_TOKEN}@}" - git clone --quiet --branch "$BRANCH" "$auth_url" "$INSTALL_DIR" - git -C "$INSTALL_DIR" remote set-url origin "$REPO_URL" + as_app git clone --quiet --branch "$BRANCH" "$auth_url" "$INSTALL_DIR" + as_app git -C "$INSTALL_DIR" remote set-url origin "$REPO_URL" fi cd "$INSTALL_DIR" +# git and docker run as the service account from here on; only apt and the +# firewall still need root. +compose() { runuser -u "$APP_USER" -- docker compose "$@"; } + # ── 4. Configuration ───────────────────────────────────────────────────────── if [[ -f .env ]]; then log "Keeping the existing .env (delete it to start over)" @@ -152,33 +183,36 @@ else set_env CLUPILOT_WG_ENDPOINT "$(curl -fsS4 https://ifconfig.co 2>/dev/null || echo 'SET-ME'):51820" - set_env HOST_UID 1000 - set_env HOST_GID 1000 + # The container user is mapped to the service account, so files written + # inside the container belong to it and not to root. + set_env HOST_UID "$APP_UID" + set_env HOST_GID "$APP_GID" set_env VITE_AUTOSTART false chmod 600 .env + chown "$APP_USER:$APP_USER" .env fi # ── 5. Stack ───────────────────────────────────────────────────────────────── log "Building and starting the containers (this takes a few minutes)" -docker compose build --quiet app -docker compose up -d +compose build --quiet app +compose up -d log "Waiting for the application container" for _ in $(seq 1 60); do - docker compose exec -T app php -v >/dev/null 2>&1 && break + compose exec -T app php -v >/dev/null 2>&1 && break sleep 5 done log "Running migrations" -docker compose exec -T app php artisan migrate --force +compose exec -T app php artisan migrate --force # ── 6. WireGuard hub ───────────────────────────────────────────────────────── -if docker compose exec -T queue-provisioning test -f /etc/wireguard/wg0.conf 2>/dev/null; then +if compose exec -T queue-provisioning test -f /etc/wireguard/wg0.conf 2>/dev/null; then log "WireGuard hub already configured — leaving it alone" else log "Setting up the WireGuard hub" - docker compose exec -T queue-provisioning sh -c ' + compose exec -T queue-provisioning sh -c ' set -e; umask 077; mkdir -p /etc/wireguard wg genkey > /etc/wireguard/hub.key cat > /etc/wireguard/wg0.conf </dev/null 2>&1 || true' fi -HUB_PUBKEY="$(docker compose exec -T queue-provisioning sh -c 'wg pubkey < /etc/wireguard/hub.key' | tr -d '\r\n')" +HUB_PUBKEY="$(compose exec -T queue-provisioning sh -c 'wg pubkey < /etc/wireguard/hub.key' | tr -d '\r\n')" sed -i "s|^CLUPILOT_WG_HUB_PUBKEY=.*|CLUPILOT_WG_HUB_PUBKEY=${HUB_PUBKEY}|" .env -docker compose exec -T app php artisan config:clear >/dev/null +compose exec -T app php artisan config:clear >/dev/null # ── 7. Your account ────────────────────────────────────────────────────────── log "Creating your Owner account" -docker compose exec -T app php artisan clupilot:create-admin \ +compose exec -T app php artisan clupilot:create-admin \ --email="$ADMIN_EMAIL" --name="$ADMIN_NAME" --password="$ADMIN_PASSWORD" # Record what has been deployed: update.sh compares against this to tell a # finished deployment from one that died halfway. -mkdir -p storage/app -git rev-parse HEAD > storage/app/deployed-commit +as_app mkdir -p storage/app +as_app git rev-parse HEAD > storage/app/deployed-commit +chown -R "$APP_USER:$APP_USER" "$INSTALL_DIR" # ── 8. Firewall ────────────────────────────────────────────────────────────── # Only what has to be reachable: SSH, the web ports, and the tunnel. The console @@ -242,8 +277,8 @@ Still to do, in this order: 3. Sign in and switch the website to hidden under Settings until you are ready to be found (Settings → Website visibility). -Updating later: +Updating later — as ${APP_USER}, not as root: - cd $INSTALL_DIR && bash deploy/update.sh + sudo -u ${APP_USER} bash -c 'cd $INSTALL_DIR && bash deploy/update.sh' SUMMARY diff --git a/deploy/update.sh b/deploy/update.sh index 036272a..ade0025 100755 --- a/deploy/update.sh +++ b/deploy/update.sh @@ -15,6 +15,14 @@ set -euo pipefail cd "$(dirname "$0")/.." BRANCH="${BRANCH:-main}" + +# Never as root: the checkout belongs to the service account, and running this +# as root would leave root-owned files behind that the app cannot write. +if [[ $EUID -eq 0 ]]; then + echo "Run this as the service account, not as root:" >&2 + echo " sudo -u clupilot bash $0" >&2 + exit 1 +fi STATE_FILE="storage/app/deployed-commit" log() { printf '\n\033[1;34m==>\033[0m %s\n' "$*"; } diff --git a/docs/deployment.md b/docs/deployment.md index 4eea1f0..6776b82 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -3,13 +3,20 @@ Developed locally, deployed by pulling. Two scripts do the work: ```bash -# once, on a fresh Debian/Ubuntu server as root +# once, on a fresh Debian/Ubuntu server — as root, because of apt bash deploy/install.sh -# afterwards, to bring it up to date -cd /opt/clupilot && bash deploy/update.sh +# afterwards, as the service account — never as root +sudo -u clupilot bash -c 'cd /opt/clupilot && bash deploy/update.sh' ``` +The installer needs root only to install packages and open the firewall. It +creates a `clupilot` account that owns the checkout and runs Docker; the +application never runs as root, and `update.sh` refuses to start if you try. +That account has no password login — membership in the docker group is +root-equivalent on the host, which is inherent to Docker and the reason it is +reachable only by `sudo -u` or an SSH key. + ## What the installer does Docker, git and `wireguard-tools`; clone into `/opt/clupilot`; generate every