CluPilotCloud/deploy/install.sh

242 lines
11 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}"
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'; }
# ── 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
# ── 3. Source ────────────────────────────────────────────────────────────────
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"
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.
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"
fi
cd "$INSTALL_DIR"
# ── 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)"
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"
set_env HOST_UID 1000
set_env HOST_GID 1000
set_env VITE_AUTOSTART false
chmod 600 .env
fi
# ── 5. Stack ─────────────────────────────────────────────────────────────────
log "Building and starting the containers (this takes a few minutes)"
docker compose build --quiet app
docker 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
sleep 5
done
log "Running migrations"
docker 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
log "WireGuard hub already configured — leaving it alone"
else
log "Setting up the WireGuard hub"
docker 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="$(docker 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
# ── 7. Your account ──────────────────────────────────────────────────────────
log "Creating your Owner account"
docker compose exec -T app php artisan clupilot:create-admin \
--email="$ADMIN_EMAIL" --name="$ADMIN_NAME" --password="$ADMIN_PASSWORD"
# ── 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 '<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_WEBHOOK_SECRET in $INSTALL_DIR/.env — Stripe dashboard →
Developers → Webhooks → https://${APP_DOMAIN}/webhooks/stripe, events
checkout.session.completed and checkout.session.async_payment_succeeded.
3. Sign in and switch the website to hidden under Settings until you are
ready to be found (Settings → Website visibility).
Updating later:
cd $INSTALL_DIR && bash deploy/update.sh
SUMMARY