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
parent
0219e3c987
commit
4681b135db
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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' "$*"; }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue