469 lines
22 KiB
Bash
Executable File
469 lines
22 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}"
|
|
# 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"
|
|
# Stripe posts server-to-server and never sees the portal, so the webhook has
|
|
# its own name. Naming the portal here was wrong in the printed instructions.
|
|
ask API_DOMAIN "Domain Stripe posts its webhooks to" "api.clupilot.com"
|
|
ask STATUS_DOMAIN "Public status page (blank to keep it on every host)" "status.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=<tag>"
|
|
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=<name>"
|
|
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"
|
|
# And that hostname answers only the console — off until the DNS for it is
|
|
# actually in place, because switching it on before then makes the console
|
|
# unreachable under any other name.
|
|
set_env ADMIN_HOST_EXCLUSIVE false
|
|
set_env TRUSTED_RANGES "10.66.0.0/24,127.0.0.1"
|
|
optional_env STATUS_HOST "$STATUS_DOMAIN"
|
|
|
|
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 <<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="$(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
|
|
|
|
# ── 8b. Update agent ─────────────────────────────────────────────────────────
|
|
# The panel's update button cannot run the update itself: it is www-data inside
|
|
# a container, and the update restarts that container. It writes a request into
|
|
# the checkout instead, and a host-side timer picks it up. Installed by its own
|
|
# script, because the installed base upgrades through deploy/update.sh and would
|
|
# otherwise never get it.
|
|
log "Installing the update agent"
|
|
APP_USER="$APP_USER" bash "$INSTALL_DIR/deploy/install-agent.sh"
|
|
|
|
# ── 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_SECRET and STRIPE_WEBHOOK_SECRET in $INSTALL_DIR/.env —
|
|
Stripe dashboard → Developers → Webhooks →
|
|
https://${API_DOMAIN}/webhooks/stripe, subscribing all six events:
|
|
checkout.session.completed checkout.session.async_payment_succeeded
|
|
invoice.paid invoice.payment_failed
|
|
customer.subscription.updated customer.subscription.deleted
|
|
3. Mirror the plan catalogue into Stripe — nothing can be sold until it knows
|
|
what it is billing for. Look first; a Stripe Price cannot be deleted:
|
|
docker compose exec app php artisan stripe:sync-catalogue --dry-run
|
|
docker compose exec app php artisan stripe:sync-catalogue
|
|
4. Sign in and switch the website to hidden under Settings until you are
|
|
ready to be found (Settings → Website visibility).
|
|
|
|
Updating later — as ${APP_USER}, not as root:
|
|
|
|
sudo -u ${APP_USER} bash -c 'cd $INSTALL_DIR && bash deploy/update.sh'
|
|
|
|
SUMMARY
|