#!/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 /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=" 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=" 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 </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 # ── 9. Done ────────────────────────────────────────────────────────────────── PUBLIC_IP="$(curl -fsS4 https://ifconfig.co 2>/dev/null || echo '')" cat <