feat(deploy): run under a dedicated account instead of root

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 <noreply@anthropic.com>
feat/portal-design
nexxo 2026-07-25 23:25:59 +02:00
parent 0219e3c987
commit 4681b135db
3 changed files with 75 additions and 25 deletions

View File

@ -20,6 +20,9 @@ set -euo pipefail
REPO_URL="${REPO_URL:-https://git.bave.dev/boban/CluPilotCloud.git}" REPO_URL="${REPO_URL:-https://git.bave.dev/boban/CluPilotCloud.git}"
INSTALL_DIR="${INSTALL_DIR:-/opt/clupilot}" INSTALL_DIR="${INSTALL_DIR:-/opt/clupilot}"
BRANCH="${BRANCH:-main}" 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' "$*"; } log() { printf '\n\033[1;34m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m !\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." modprobe wireguard 2>/dev/null || warn "Could not load the wireguard module — check the kernel."
echo wireguard > /etc/modules-load.d/wireguard.conf 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 ──────────────────────────────────────────────────────────────── # ── 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 if [[ -d "$INSTALL_DIR/.git" ]]; then
log "Updating existing checkout in $INSTALL_DIR" log "Updating existing checkout in $INSTALL_DIR"
git -C "$INSTALL_DIR" fetch --quiet origin "$BRANCH" chown -R "$APP_USER:$APP_USER" "$INSTALL_DIR"
git -C "$INSTALL_DIR" checkout --quiet "$BRANCH" as_app git -C "$INSTALL_DIR" fetch --quiet origin "$BRANCH"
git -C "$INSTALL_DIR" pull --quiet --ff-only origin "$BRANCH" as_app git -C "$INSTALL_DIR" checkout --quiet "$BRANCH"
as_app git -C "$INSTALL_DIR" pull --quiet --ff-only origin "$BRANCH"
else else
log "Cloning into $INSTALL_DIR" log "Cloning into $INSTALL_DIR"
# The token is passed for this one command and never written to disk or to install -d -o "$APP_USER" -g "$APP_USER" -m 0750 "$INSTALL_DIR"
# the git remote — a later `git pull` will ask, or use a deploy key. # 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}@}" auth_url="${REPO_URL/https:\/\//https://oauth2:${GIT_TOKEN}@}"
git clone --quiet --branch "$BRANCH" "$auth_url" "$INSTALL_DIR" as_app git clone --quiet --branch "$BRANCH" "$auth_url" "$INSTALL_DIR"
git -C "$INSTALL_DIR" remote set-url origin "$REPO_URL" as_app git -C "$INSTALL_DIR" remote set-url origin "$REPO_URL"
fi fi
cd "$INSTALL_DIR" 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 ───────────────────────────────────────────────────────── # ── 4. Configuration ─────────────────────────────────────────────────────────
if [[ -f .env ]]; then if [[ -f .env ]]; then
log "Keeping the existing .env (delete it to start over)" 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 CLUPILOT_WG_ENDPOINT "$(curl -fsS4 https://ifconfig.co 2>/dev/null || echo 'SET-ME'):51820"
set_env HOST_UID 1000 # The container user is mapped to the service account, so files written
set_env HOST_GID 1000 # 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 set_env VITE_AUTOSTART false
chmod 600 .env chmod 600 .env
chown "$APP_USER:$APP_USER" .env
fi fi
# ── 5. Stack ───────────────────────────────────────────────────────────────── # ── 5. Stack ─────────────────────────────────────────────────────────────────
log "Building and starting the containers (this takes a few minutes)" log "Building and starting the containers (this takes a few minutes)"
docker compose build --quiet app compose build --quiet app
docker compose up -d compose up -d
log "Waiting for the application container" log "Waiting for the application container"
for _ in $(seq 1 60); do 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 sleep 5
done done
log "Running migrations" log "Running migrations"
docker compose exec -T app php artisan migrate --force compose exec -T app php artisan migrate --force
# ── 6. WireGuard hub ───────────────────────────────────────────────────────── # ── 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" log "WireGuard hub already configured — leaving it alone"
else else
log "Setting up the WireGuard hub" 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 set -e; umask 077; mkdir -p /etc/wireguard
wg genkey > /etc/wireguard/hub.key wg genkey > /etc/wireguard/hub.key
cat > /etc/wireguard/wg0.conf <<EOF cat > /etc/wireguard/wg0.conf <<EOF
@ -193,19 +227,20 @@ EOF
wg-quick up wg0 >/dev/null 2>&1 || true' wg-quick up wg0 >/dev/null 2>&1 || true'
fi 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 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 ────────────────────────────────────────────────────────── # ── 7. Your account ──────────────────────────────────────────────────────────
log "Creating your Owner 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" --email="$ADMIN_EMAIL" --name="$ADMIN_NAME" --password="$ADMIN_PASSWORD"
# Record what has been deployed: update.sh compares against this to tell a # Record what has been deployed: update.sh compares against this to tell a
# finished deployment from one that died halfway. # finished deployment from one that died halfway.
mkdir -p storage/app as_app mkdir -p storage/app
git rev-parse HEAD > storage/app/deployed-commit as_app git rev-parse HEAD > storage/app/deployed-commit
chown -R "$APP_USER:$APP_USER" "$INSTALL_DIR"
# ── 8. Firewall ────────────────────────────────────────────────────────────── # ── 8. Firewall ──────────────────────────────────────────────────────────────
# Only what has to be reachable: SSH, the web ports, and the tunnel. The console # 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 3. Sign in and switch the website to hidden under Settings until you are
ready to be found (Settings → Website visibility). 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 SUMMARY

View File

@ -15,6 +15,14 @@ set -euo pipefail
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
BRANCH="${BRANCH:-main}" 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" STATE_FILE="storage/app/deployed-commit"
log() { printf '\n\033[1;34m==>\033[0m %s\n' "$*"; } log() { printf '\n\033[1;34m==>\033[0m %s\n' "$*"; }

View File

@ -3,13 +3,20 @@
Developed locally, deployed by pulling. Two scripts do the work: Developed locally, deployed by pulling. Two scripts do the work:
```bash ```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 bash deploy/install.sh
# afterwards, to bring it up to date # afterwards, as the service account — never as root
cd /opt/clupilot && bash deploy/update.sh 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 ## What the installer does
Docker, git and `wireguard-tools`; clone into `/opt/clupilot`; generate every Docker, git and `wireguard-tools`; clone into `/opt/clupilot`; generate every