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}"
|
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
|
||||||
|
|
|
||||||
|
|
@ -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' "$*"; }
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue