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 <