#!/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 -u www-data app php artisan stripe:sync-catalogue --dry-run
       docker compose exec -u www-data 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
