feat(deploy): installer and updater for a fresh server

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 <noreply@anthropic.com>
feat/portal-design
nexxo 2026-07-25 23:13:11 +02:00
parent e2b4cdbac4
commit 834abcec40
4 changed files with 430 additions and 0 deletions

View File

@ -0,0 +1,79 @@
<?php
namespace App\Console\Commands;
use App\Models\Customer;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules\Password;
use Illuminate\Support\Facades\Validator;
use Spatie\Permission\PermissionRegistrar;
/**
* Creates (or promotes) an operator with the Owner role the account the
* installer sets up so a fresh server is usable immediately.
*
* Promotion is deliberate: running this twice with the same address should fix
* a lost role, not fail with "email already taken".
*/
class CreateAdmin extends Command
{
protected $signature = 'clupilot:create-admin
{--email= : Login address}
{--name= : Display name}
{--password= : Leave empty to be prompted}';
protected $description = 'Create or promote an operator account with the Owner role';
public function handle(): int
{
$email = $this->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;
}
}

241
deploy/install.sh Executable file
View File

@ -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 <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

53
deploy/update.sh Executable file
View File

@ -0,0 +1,53 @@
#!/usr/bin/env bash
#
# CluPilot — pull the latest code and apply it.
#
# Order matters: migrations run BEFORE the new containers take traffic, because
# code that expects a column the database does not have yet answers with 500s.
# The queue workers are restarted at the end — they are long-running processes
# and keep the old classes in memory otherwise.
set -euo pipefail
cd "$(dirname "$0")/.."
BRANCH="${BRANCH:-main}"
log() { printf '\n\033[1;34m==>\033[0m %s\n' "$*"; }
log "Fetching $BRANCH"
git fetch --quiet origin "$BRANCH"
before="$(git rev-parse HEAD)"
git merge --quiet --ff-only "origin/$BRANCH"
after="$(git rev-parse HEAD)"
if [[ "$before" == "$after" ]]; then
log "Already up to date ($(git rev-parse --short HEAD))"
exit 0
fi
log "Updating $(git rev-parse --short "$before")$(git rev-parse --short "$after")"
git --no-pager log --oneline "$before..$after" | sed 's/^/ /'
# Rebuilt only when the image definition actually changed — a rebuild is
# minutes, a no-op check is seconds.
if ! git diff --quiet "$before" "$after" -- docker/ composer.json composer.lock package.json package-lock.json; then
log "Rebuilding the image"
docker compose build --quiet app
fi
log "Applying migrations"
docker compose exec -T app php artisan migrate --force
log "Rebuilding assets"
docker compose exec -T app npm run build
log "Restarting services"
docker compose up -d
# Workers hold their PHP classes for the life of the process; without this they
# keep running the code from before the update.
docker compose restart queue queue-provisioning scheduler reverb
docker compose exec -T app php artisan config:clear >/dev/null
docker compose exec -T app php artisan view:clear >/dev/null
log "Done — now on $(git rev-parse --short HEAD)"

57
docs/deployment.md Normal file
View File

@ -0,0 +1,57 @@
# Deploying CluPilot on a fresh server
Developed locally, deployed by pulling. Two scripts do the work:
```bash
# once, on a fresh Debian/Ubuntu server as root
bash deploy/install.sh
# afterwards, to bring it up to date
cd /opt/clupilot && bash deploy/update.sh
```
## What the installer does
Docker, git and `wireguard-tools`; clone into `/opt/clupilot`; generate every
secret (`APP_KEY`, `VPN_CONFIG_KEY`, database password, Reverb keys); start the
stack; migrate; create the WireGuard hub and print its public key; create your
Owner account; close the firewall down to 22, 80, 443 and 51820/udp.
It is safe to re-run: an existing `.env` is kept and the hub key is never
regenerated — doing so would silently disconnect every onboarded host.
Every prompt has an environment variable, so it can also run unattended:
```bash
GIT_TOKEN=… ADMIN_EMAIL=… ADMIN_PASSWORD=… APP_DOMAIN=app.clupilot.com \
ADMIN_DOMAIN=admin.clupilot.com bash deploy/install.sh
```
## Order of the update
Migrations run **before** the new containers take traffic: code that expects a
column the database does not have yet answers with 500s. The image is rebuilt
only when `docker/`, composer or npm manifests actually changed. Queue workers
are restarted at the end — they are long-running PHP processes and keep the old
classes in memory otherwise. That one bit us during development: a fixed job
kept failing until the worker was restarted.
## What the installer deliberately leaves to you
- **DNS and TLS.** Put a reverse proxy in front (Zoraxy, Caddy, nginx). The
console hostname must not be publicly resolvable: `/admin` answers only on
the hostnames in `ADMIN_HOSTS` and 404s everywhere else, but keeping it out
of public DNS is the layer above that.
- **`STRIPE_WEBHOOK_SECRET`.** Stripe dashboard → Developers → Webhooks →
`https://<app-domain>/webhooks/stripe`, events `checkout.session.completed`
and `checkout.session.async_payment_succeeded`. The secret starts with
`whsec_` and differs between test and live mode.
- **Backups.** At minimum the MariaDB volume and `/etc/wireguard` in the
`wireguard` volume — losing the hub key means re-peering every host.
## Hiding the site while you build
Settings → Website visibility. Outsiders then get a placeholder (503,
`noindex`), while anyone on the management VPN and any signed-in operator keeps
seeing the real thing. The console stays reachable either way, so the switch can
always be flipped back.