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}"
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 <<EOF
@ -193,19 +227,20 @@ EOF
wg-quick up wg0 >/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

View File

@ -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' "$*"; }

View File

@ -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