From 834abcec40a85cd88aced1c5471d57c57009854f Mon Sep 17 00:00:00 2001 From: nexxo Date: Sat, 25 Jul 2026 23:13:11 +0200 Subject: [PATCH] feat(deploy): installer and updater for a fresh server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit install.sh sets up a bare Debian/Ubuntu server end to end: Docker, git, WireGuard, clone, generated secrets, stack, migrations, hub keypair, the Owner account and a closed firewall. Re-runnable: it keeps an existing .env and never regenerates the hub key, which would disconnect every onboarded host. update.sh pulls and applies. Migrations run before the new containers take traffic, the image is rebuilt only when its definition changed, and the queue workers are restarted — they are long-running processes that otherwise keep the old classes in memory, which cost us an hour during development. clupilot:create-admin creates or promotes an Owner, so re-running the installer fixes a lost role instead of failing on a taken address. Co-Authored-By: Claude Opus 4.8 --- app/Console/Commands/CreateAdmin.php | 79 +++++++++ deploy/install.sh | 241 +++++++++++++++++++++++++++ deploy/update.sh | 53 ++++++ docs/deployment.md | 57 +++++++ 4 files changed, 430 insertions(+) create mode 100644 app/Console/Commands/CreateAdmin.php create mode 100755 deploy/install.sh create mode 100755 deploy/update.sh create mode 100644 docs/deployment.md diff --git a/app/Console/Commands/CreateAdmin.php b/app/Console/Commands/CreateAdmin.php new file mode 100644 index 0000000..7ace7d1 --- /dev/null +++ b/app/Console/Commands/CreateAdmin.php @@ -0,0 +1,79 @@ +option('email') ?: $this->ask('Email'); + $name = $this->option('name') ?: $this->ask('Name', 'Administrator'); + $password = $this->option('password') ?: $this->secret('Password'); + + $validator = Validator::make( + ['email' => $email, 'name' => $name, 'password' => $password], + [ + 'email' => 'required|email|max:255', + 'name' => 'required|string|max:255', + 'password' => ['required', Password::min(12)], + ], + ); + + if ($validator->fails()) { + foreach ($validator->errors()->all() as $error) { + $this->error($error); + } + + return self::FAILURE; + } + + // An operator address that also belongs to a customer would block that + // customer from ever getting a portal login. + if (Customer::query()->where('email', $email)->exists()) { + $this->error('That address already belongs to a customer.'); + + return self::FAILURE; + } + + $user = User::query()->firstOrNew(['email' => $email]); + $existed = $user->exists; + + $user->fill([ + 'name' => $name, + 'password' => Hash::make($password), + 'is_admin' => true, + 'email_verified_at' => $user->email_verified_at ?? now(), + ])->save(); + + $user->syncRoles(['Owner']); + app(PermissionRegistrar::class)->forgetCachedPermissions(); + + $this->info($existed + ? "Existing account {$email} promoted to Owner and password reset." + : "Owner account {$email} created."); + + return self::SUCCESS; + } +} diff --git a/deploy/install.sh b/deploy/install.sh new file mode 100755 index 0000000..341b02a --- /dev/null +++ b/deploy/install.sh @@ -0,0 +1,241 @@ +#!/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}" + +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 </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 '')" +cat <