CluPilotCloud/deploy/install.sh

460 lines
21 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# CluPilot — first-run installer for a fresh Debian/Ubuntu server (netcup VPS).
#
# Installs Docker, git and WireGuard, clones the repository, generates every
# secret the application needs, brings the stack up, runs the migrations,
# creates the WireGuard hub and your Owner account.
#
# Usage (interactive):
# curl -fsSL <raw-url>/deploy/install.sh -o install.sh && bash install.sh
#
# Usage (unattended — every prompt can be answered by an environment variable):
# GIT_TOKEN=… ADMIN_EMAIL=… ADMIN_PASSWORD=… APP_DOMAIN=app.clupilot.com \
# ADMIN_DOMAIN=admin.clupilot.com bash install.sh
#
# Safe to re-run: it never overwrites an existing .env and never regenerates
# the WireGuard hub key (that would silently disconnect every host).
set -euo pipefail
REPO_URL="${REPO_URL:-https://git.bave.dev/boban/CluPilotCloud.git}"
INSTALL_DIR="${INSTALL_DIR:-/opt/clupilot}"
BRANCH="${BRANCH:-main}"
# RELEASE=v1.0.0 pins the server to that tag instead of following a branch.
# What a production install should use: a tag is immutable, so the machine moves
# only when someone decides it moves. Without it, main is followed — the edge,
# where CI has not necessarily finished, or has failed.
RELEASE="${RELEASE:-}"
# 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}"
# --env-file PATH — answers every prompt, so the whole run is unattended.
while [[ $# -gt 0 ]]; do
case "$1" in
--env-file)
ENV_FILE="${2:-}"; shift 2 ;;
--env-file=*)
ENV_FILE="${1#*=}"; shift ;;
-h|--help)
sed -n '2,20p' "$0"; exit 0 ;;
*)
echo "Unknown option: $1" >&2; exit 1 ;;
esac
done
log() { printf '\n\033[1;34m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m !\033[0m %s\n' "$*"; }
die() { printf '\033[1;31m ✗\033[0m %s\n' "$*" >&2; exit 1; }
ask() { # ask VAR "Prompt" [default] — skipped when VAR is already set
local var="$1" prompt="$2" default="${3:-}" answer
if [[ -n "${!var:-}" ]]; then return; fi
if [[ ! -t 0 ]]; then die "$var is not set and there is no terminal to ask on."; fi
read -r -p "$prompt${default:+ [$default]}: " answer
printf -v "$var" '%s' "${answer:-$default}"
}
ask_secret() {
local var="$1" prompt="$2" answer
if [[ -n "${!var:-}" ]]; then return; fi
if [[ ! -t 0 ]]; then die "$var is not set and there is no terminal to ask on."; fi
read -r -s -p "$prompt: " answer; echo
printf -v "$var" '%s' "$answer"
}
rand_b64() { head -c "${1:-32}" /dev/urandom | base64 -w0; }
rand_hex() { head -c "${1:-24}" /dev/urandom | od -An -tx1 | tr -d ' \n'; }
if [[ -n "${ENV_FILE:-}" ]]; then
[[ -f "$ENV_FILE" ]] || { echo "No such file: $ENV_FILE" >&2; exit 1; }
# It holds every secret this installation will ever need. A world-readable
# copy on a shared box is worth saying something about.
perms="$(stat -c '%a' "$ENV_FILE")"
[[ "$perms" == "600" || "$perms" == "400" ]] || \
printf '\033[1;33m !\033[0m %s\n' "$ENV_FILE is mode $perms — chmod 600 it."
# The file is SOURCED, so an unquoted value containing a space is read as a
# command: `ADMIN_NAME=Boban Blaskovic` dies with "Blaskovic: command not
# found", which names neither the file nor the variable, and `set -e` ends
# the install right there. A display name with a space is entirely ordinary,
# so say so properly instead.
while IFS= read -r line || [[ -n "$line" ]]; do
case "$line" in ''|'#'*) continue ;; esac
if [[ "$line" =~ ^[A-Za-z_][A-Za-z0-9_]*=[^\"\'][^=]*[[:space:]] ]]; then
die "$ENV_FILE, this line has an unquoted value with a space in it:
${line%%=*}=…
Quote it: ${line%%=*}=\"${line#*=}\""
fi
done < "$ENV_FILE"
# shellcheck disable=SC1090 # the path is the whole point of the flag
set -a; . "$ENV_FILE"; set +a
fi
# ── 0. Preflight ─────────────────────────────────────────────────────────────
[[ $EUID -eq 0 ]] || die "Please run as root (sudo bash install.sh)."
[[ -f /etc/os-release ]] || die "Unsupported system: no /etc/os-release."
. /etc/os-release
case "${ID}${ID_LIKE:-}" in
*debian*|*ubuntu*) ;;
*) die "Only Debian and Ubuntu are supported, found: ${PRETTY_NAME}." ;;
esac
[[ "$(uname -m)" == "x86_64" ]] || die "Only x86_64 is supported, found $(uname -m)."
log "CluPilot installer on ${PRETTY_NAME}"
# ── 1. Answers we need before doing anything ─────────────────────────────────
ask APP_DOMAIN "Public domain for the customer portal" "app.clupilot.com"
ask WWW_DOMAIN "Domain for the marketing site" "www.clupilot.com"
ask ADMIN_DOMAIN "Private domain for the operator console" "admin.clupilot.com"
ask WS_DOMAIN "Domain for the websocket server" "ws.clupilot.com"
ask ADMIN_EMAIL "Your login address"
ask ADMIN_NAME "Your display name" "Administrator"
ask_secret ADMIN_PASSWORD "Your password (min. 12 characters)"
[[ ${#ADMIN_PASSWORD} -ge 12 ]] || die "The password must be at least 12 characters."
# The repository is private; a Gitea token with read access is enough.
if [[ ! -d "$INSTALL_DIR/.git" ]]; then
ask_secret GIT_TOKEN "Gitea access token (read access to the repository)"
fi
# ── 2. Packages ──────────────────────────────────────────────────────────────
log "Installing packages"
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq ca-certificates curl git gnupg wireguard-tools ufw >/dev/null
if ! command -v docker >/dev/null; then
log "Installing Docker"
install -m 0755 -d /etc/apt/keyrings
curl -fsSL "https://download.docker.com/linux/${ID}/gpg" -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/${ID} ${VERSION_CODENAME} stable" \
> /etc/apt/sources.list.d/docker.list
apt-get update -qq
apt-get install -y -qq docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin >/dev/null
systemctl enable --now docker >/dev/null
fi
docker compose version >/dev/null || die "docker compose plugin is missing."
# WireGuard needs the module loadable before a container can create wg0.
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
# An existing checkout is not this script's business beyond bringing it to
# the requested ref — deploy/update.sh is the tool for moving a running
# installation, and it knows about maintenance mode and migrations.
log "Updating existing checkout in $INSTALL_DIR"
chown -R "$APP_USER:$APP_USER" "$INSTALL_DIR"
if [[ -n "$RELEASE" ]]; then
as_app git -C "$INSTALL_DIR" fetch --quiet --tags --force origin
as_app git -C "$INSTALL_DIR" checkout --quiet --detach "refs/tags/${RELEASE}"
else
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"
fi
else
log "Cloning into $INSTALL_DIR"
install -d -o "$APP_USER" -g "$APP_USER" -m 0750 "$INSTALL_DIR"
# The token goes through an askpass helper rather than into the URL: a URL
# with credentials shows up in the process list for every local user while
# the clone runs, and would end up in the remote afterwards.
askpass="$(mktemp)"
printf '#!/bin/sh\nprintf %%s "%s"\n' "$GIT_TOKEN" > "$askpass"
chmod 500 "$askpass"
chown "$APP_USER" "$askpass"
trap 'rm -f "$askpass"' EXIT
# Ask what is actually there before cloning. Without this, a ref that does
# not exist fails inside git with "Remote branch not found", which reads
# like a broken token or an unreachable server — and the ref is the one
# thing here that is routinely wrong.
auth_url="${REPO_URL/https:\/\//https://oauth2@}"
if [[ -n "$RELEASE" ]]; then
wanted="$RELEASE"
# `|| true` on the grep: a repository that is reachable but has no
# releases yet makes grep exit 1, and under pipefail that would be
# reported as "could not reach the server" — sending someone to check a
# token that is perfectly fine. An empty list is an answer.
available="$(GIT_ASKPASS="$askpass" GIT_TERMINAL_PROMPT=0 \
as_app git ls-remote --tags "$auth_url" 2>/dev/null \
| sed 's|.*refs/tags/||; s|\^{}$||' | { grep -E '^v' || true; } | sort -u)" \
|| die "Could not reach ${REPO_URL}. Check the server and the token."
kind="release tag"
hint="RELEASE=<tag>"
else
wanted="$BRANCH"
available="$(GIT_ASKPASS="$askpass" GIT_TERMINAL_PROMPT=0 \
as_app git ls-remote --heads "$auth_url" 2>/dev/null \
| sed 's|.*refs/heads/||')" \
|| die "Could not reach ${REPO_URL}. Check the server and the token."
kind="branch"
hint="BRANCH=<name>"
fi
if ! grep -qxF "$wanted" <<<"$available"; then
die "The repository has no ${kind} '${wanted}'. It has:
$(sed 's/^/ /' <<<"${available:-(none)}")
Pass the one you want: ${hint} bash install.sh"
fi
# --branch takes a tag too, and leaves the checkout detached on it, which is
# exactly what a pinned server should be.
GIT_ASKPASS="$askpass" GIT_TERMINAL_PROMPT=0 \
as_app git clone --quiet --branch "$wanted" "$auth_url" "$INSTALL_DIR"
rm -f "$askpass"
trap - EXIT
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)"
else
log "Writing .env with freshly generated secrets"
cp .env.example .env
set_env() { # set_env KEY VALUE — replaces the line, appends if absent
local key="$1" value="$2"
if grep -qE "^${key}=" .env; then
# `|` as the delimiter: values contain / and + from base64.
sed -i "s|^${key}=.*|${key}=${value}|" .env
else
printf '%s=%s\n' "$key" "$value" >> .env
fi
}
set_env APP_ENV production
set_env APP_DEBUG false
set_env APP_URL "https://${APP_DOMAIN}"
set_env APP_KEY "base64:$(rand_b64 32)"
set_env DB_PASSWORD "$(rand_hex 24)"
# Otherwise every installation shares the value from .env.example, reachable
# from any container on the compose network.
set_env DB_ROOT_PASSWORD "$(rand_hex 24)"
set_env VPN_CONFIG_KEY "$(rand_b64 32)"
set_env REVERB_APP_ID "clupilot"
set_env REVERB_APP_KEY "$(rand_hex 16)"
set_env REVERB_APP_SECRET "$(rand_hex 24)"
set_env VITE_REVERB_APP_KEY "$(grep '^REVERB_APP_KEY=' .env | cut -d= -f2-)"
set_env VITE_REVERB_HOST "$WS_DOMAIN"
set_env VITE_REVERB_PORT 443
set_env VITE_REVERB_SCHEME https
# The console answers only on its own hostname; everything else 404s.
set_env ADMIN_HOSTS "${ADMIN_DOMAIN},127.0.0.1,localhost"
set_env TRUSTED_RANGES "10.66.0.0/24,127.0.0.1"
set_env CLUPILOT_WG_ENDPOINT "$(curl -fsS4 https://ifconfig.co 2>/dev/null || echo 'SET-ME'):51820"
# The container user is mapped to the service account, so files written
# inside the container belong to it and not to root.
# Supplied through --env-file; skipped silently when absent, so a first
# install can happen before the Stripe account exists.
# An `if`, not `[[ … ]] && …`: the && form makes the function return 1 for
# every value that is absent, and under `set -e` that ends the install.
# Absent is the NORMAL case here — these are the optional ones — so the
# script killed itself on the first installation that did not happen to
# supply a Hetzner token.
optional_env() {
if [[ -n "${2:-}" ]]; then
set_env "$1" "$2"
fi
}
optional_env HETZNER_DNS_TOKEN "${HETZNER_DNS_TOKEN:-}"
optional_env CLUPILOT_DNS_ZONE "${CLUPILOT_DNS_ZONE:-}"
optional_env STRIPE_KEY "${STRIPE_KEY:-}"
optional_env STRIPE_SECRET "${STRIPE_SECRET:-}"
optional_env STRIPE_WEBHOOK_SECRET "${STRIPE_WEBHOOK_SECRET:-}"
optional_env MAIL_MAILER "${MAIL_MAILER:-}"
optional_env MAIL_HOST "${MAIL_HOST:-}"
optional_env MAIL_PORT "${MAIL_PORT:-}"
optional_env MAIL_USERNAME "${MAIL_USERNAME:-}"
optional_env MAIL_PASSWORD "${MAIL_PASSWORD:-}"
optional_env MAIL_FROM_ADDRESS "${MAIL_FROM_ADDRESS:-}"
optional_env CLUPILOT_SSH_KEY_PATH "${CLUPILOT_SSH_KEY_PATH:-}"
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)"
compose build --quiet app
compose up -d
# Not `php -v`: PHP answers within seconds, while the entrypoint is still
# running `composer install` on a fresh checkout — which takes minutes. The old
# condition let the migration below run against a checkout with no vendor/ at
# all, and artisan died on a missing autoload.php. Wait for what is actually
# needed, exactly as the dependent containers already do.
log "Waiting for the application container to install its dependencies"
deps_ready=0
for _ in $(seq 1 180); do
if compose exec -T app test -f vendor/autoload.php 2>/dev/null; then
deps_ready=1
break
fi
sleep 5
done
[[ $deps_ready -eq 1 ]] || die "vendor/ never appeared. Look at: docker compose logs app"
log "Running migrations"
compose exec -T app php artisan migrate --force
# ── 6. WireGuard hub ─────────────────────────────────────────────────────────
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"
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
# CluPilot management hub. Peers are managed by the application
# (wg set + wg-quick save), so this file is rewritten — keep edits minimal.
[Interface]
Address = 10.66.0.1/24
ListenPort = 51820
PrivateKey = $(cat /etc/wireguard/hub.key)
EOF
chmod 600 /etc/wireguard/wg0.conf /etc/wireguard/hub.key
wg-quick up wg0 >/dev/null 2>&1 || true'
fi
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
compose exec -T app php artisan config:clear >/dev/null
# The worker booted before that key existed and holds the old value for the life
# of its process — host onboarding would write an empty PublicKey into every
# host's wg0.conf until someone restarted it.
compose restart queue-provisioning >/dev/null
# ── 7. Your account ──────────────────────────────────────────────────────────
log "Creating your Owner account"
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, and the console reports the
# manifest rather than live git — git says what the files are, not whether the
# deployment came up.
# shellcheck source=deploy/lib/release.sh
. "$INSTALL_DIR/deploy/lib/release.sh"
as_app mkdir -p storage/app
as_app git rev-parse HEAD > storage/app/deployed-commit
if [[ -n "$RELEASE" ]]; then
DEPLOY_MODE="release"; DEPLOY_SOURCE="refs/tags/${RELEASE}"
else
DEPLOY_MODE="branch"; DEPLOY_SOURCE="$BRANCH"
fi
release_remember "$DEPLOY_MODE" "$DEPLOY_SOURCE"
release_write_manifest "$(as_app git rev-parse HEAD)" "$DEPLOY_SOURCE" "$DEPLOY_MODE"
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
# is additionally restricted to ADMIN_HOSTS inside the application.
log "Configuring the firewall"
ufw --force reset >/dev/null
ufw default deny incoming >/dev/null
ufw default allow outgoing >/dev/null
ufw allow 22/tcp >/dev/null
ufw allow 80/tcp >/dev/null
ufw allow 443/tcp >/dev/null
ufw allow 51820/udp >/dev/null
ufw --force enable >/dev/null
# ── 8b. Update agent ─────────────────────────────────────────────────────────
# The panel's update button cannot run the update itself: it is www-data inside
# a container, and the update restarts that container. It writes a request into
# the checkout instead, and a host-side timer picks it up. Installed by its own
# script, because the installed base upgrades through deploy/update.sh and would
# otherwise never get it.
log "Installing the update agent"
APP_USER="$APP_USER" bash "$INSTALL_DIR/deploy/install-agent.sh"
# ── 9. Done ──────────────────────────────────────────────────────────────────
PUBLIC_IP="$(curl -fsS4 https://ifconfig.co 2>/dev/null || echo '<server ip>')"
cat <<SUMMARY
$(printf '\033[1;32m✓ CluPilot is installed.\033[0m')
Directory $INSTALL_DIR
Console https://${ADMIN_DOMAIN}/admin (private — VPN only)
Portal https://${APP_DOMAIN}
WireGuard hub ${PUBLIC_IP}:51820
Hub public key ${HUB_PUBKEY}
Still to do, in this order:
1. Point the DNS records at ${PUBLIC_IP} and put TLS in front (Zoraxy,
Caddy or nginx). ${ADMIN_DOMAIN} must NOT be publicly resolvable.
2. Enter STRIPE_SECRET and STRIPE_WEBHOOK_SECRET in $INSTALL_DIR/.env —
Stripe dashboard → Developers → Webhooks →
https://${APP_DOMAIN}/webhooks/stripe, subscribing all six events:
checkout.session.completed checkout.session.async_payment_succeeded
invoice.paid invoice.payment_failed
customer.subscription.updated customer.subscription.deleted
3. Mirror the plan catalogue into Stripe — nothing can be sold until it knows
what it is billing for. Look first; a Stripe Price cannot be deleted:
docker compose exec app php artisan stripe:sync-catalogue --dry-run
docker compose exec app php artisan stripe:sync-catalogue
4. Sign in and switch the website to hidden under Settings until you are
ready to be found (Settings → Website visibility).
Updating later — as ${APP_USER}, not as root:
sudo -u ${APP_USER} bash -c 'cd $INSTALL_DIR && bash deploy/update.sh'
SUMMARY