#!/usr/bin/env bash # LicensePanel installer — Linux. Per contracts/installer.md. # # Branches: # • online first install — generates KEK + Postgres TLS + bootstrap cert, # writes panel-stack compose, `docker compose up -d` # • idempotent re-run — detects existing state.db, pulls latest image, # `docker compose up -d`, no state mutation # • air-gap (--from-tarball) — PGP-verify SHA256SUMS.asc + tarball SHA-256; # refuse + leave zero residual on mismatch # # Output never includes secrets (FR-007). Cloud-init logs respect the same # redaction. set -euo pipefail # ============================================================================ # Constants # ============================================================================ readonly PANEL_DIR="${PANEL_INSTALL_ROOT:-/var/lib/licensepanel}" readonly STATE_DB="$PANEL_DIR/state.db" readonly KEYS_DIR="$PANEL_DIR/keys" readonly POSTGRES_TLS_DIR="$PANEL_DIR/postgres-tls" readonly TRAEFIK_DIR="$PANEL_DIR/traefik" readonly DATA_DIR="$PANEL_DIR/data" readonly COMPOSE_DIR="$PANEL_DIR/panel-stack" readonly COMPOSE_FILE="$COMPOSE_DIR/docker-compose.yml" readonly ENV_FILE="$COMPOSE_DIR/.env" readonly DEFAULT_VERSION="latest" # Canonical install host (Dokploy service). Overridable per render / environment. readonly INSTALLER_LIB_URL="${LICENSEPANEL_INSTALL_BASE:-https://license-install.mod-sol-sa.com}" readonly VENDOR_PGP_KEY_ID="0xAAAAAAAAAAAAAAAA" # placeholder; replaced by build pipeline readonly LIB_DIR_LOCAL="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib" # Exit codes readonly EXIT_SUCCESS=0 readonly EXIT_GENERIC=1 readonly EXIT_PREFLIGHT=2 readonly EXIT_DOCKER_INSTALL=3 readonly EXIT_AIRGAP_INVALID=4 readonly EXIT_PERMISSIONS=5 readonly EXIT_FINGERPRINT=6 readonly EXIT_TLS=7 readonly EXIT_COMPOSE=8 # ============================================================================ # Flag parsing # ============================================================================ panel_host="" acme_email="" license_path="" activation_token="" no_docker=0 upgrade=0 panel_version="$DEFAULT_VERSION" no_start=0 from_tarball="" accept_tos=0 print_help() { cat <<'EOF' LicensePanel installer Usage: curl -sSL https://license-install.mod-sol-sa.com/install.sh | sudo bash curl -sSL https://license-install.mod-sol-sa.com/install.sh | sudo bash -s -- [flags] Flags: --panel-host Pre-seed panel host (enables ACME at first boot). --acme-email Required when --panel-host is set. --license Reserved (R1: ignored). --activation Reserved (R1: ignored). --no-docker Skip Docker auto-install on this host. --upgrade Re-run on existing host (preserves state). --version Pin the panel image (default: latest). --no-start Write all state but do not `docker compose up`. --from-tarball Air-gap install from a directory containing the panel tarball + SHA256SUMS.asc. --accept-tos Skip the interactive vendor terms-of-service prompt. --help Print this help and exit 0. EOF } while [[ $# -gt 0 ]]; do case "$1" in --panel-host) panel_host="$2"; shift 2 ;; --acme-email) acme_email="$2"; shift 2 ;; --license) license_path="$2"; shift 2 ;; --activation) activation_token="$2"; shift 2 ;; --no-docker) no_docker=1; shift ;; --upgrade) upgrade=1; shift ;; --version) panel_version="$2"; shift 2 ;; --no-start) no_start=1; shift ;; --from-tarball) from_tarball="$2"; shift 2 ;; --accept-tos) accept_tos=1; shift ;; --help) print_help; exit "$EXIT_SUCCESS" ;; *) echo "unknown flag: $1" >&2; print_help; exit "$EXIT_GENERIC" ;; esac done if [[ -n "$panel_host" && -z "$acme_email" ]]; then echo "error: --acme-email is required when --panel-host is set." >&2 exit "$EXIT_GENERIC" fi # ============================================================================ # Helpers # ============================================================================ log() { printf '%s\n' "$*"; } warn() { printf 'warn: %s\n' "$*" >&2; } die() { printf 'error: %s\n' "$2" >&2; exit "$1"; } require_root() { # Sandbox bypass — set PANEL_INSTALL_SANDBOX=1 in the test harness to run # the installer against a temporary $PANEL_INSTALL_ROOT without sudo. This # is NOT honored when no env var is set (real installs always demand root). if [[ "${PANEL_INSTALL_SANDBOX:-0}" == "1" ]]; then return; fi if [[ $EUID -ne 0 ]]; then die "$EXIT_GENERIC" "must run as root (sudo)." fi } # Refuse if state directory exists with wrong permissions (FR-062). preflight_state_perms() { if [[ -d "$PANEL_DIR" ]]; then local mode mode=$(stat -c '%a' "$PANEL_DIR") if [[ "$mode" != "700" && "$mode" != "750" ]]; then die "$EXIT_PERMISSIONS" "$PANEL_DIR exists with mode $mode (expected 700/750). Refusing." fi fi } ensure_state_dirs() { install -d -m 0700 "$PANEL_DIR" "$KEYS_DIR" "$POSTGRES_TLS_DIR" "$TRAEFIK_DIR" "$COMPOSE_DIR" install -d -m 0750 "$DATA_DIR" } resolve_lib() { local name="$1" if [[ -f "$LIB_DIR_LOCAL/$name" ]]; then echo "$LIB_DIR_LOCAL/$name" return fi # Fetched-from-URL path: stash beside us. local cache="$PANEL_DIR/installer-cache" install -d -m 0700 "$cache" if [[ ! -f "$cache/$name" ]]; then curl -sSLfo "$cache/$name" "$INSTALLER_LIB_URL/lib/$name" chmod 0700 "$cache/$name" fi echo "$cache/$name" } # ============================================================================ # Installed-already detection (FR-003 / SC-008) # ============================================================================ detect_existing_install() { [[ -f "$STATE_DB" ]] } idempotent_rerun() { log "LicensePanel is already installed." local sha_before sha_before=$(sha256sum "$STATE_DB" | awk '{print $1}') if [[ "$no_docker" -eq 0 ]]; then (cd "$COMPOSE_DIR" && docker compose pull --quiet || true) if [[ "$no_start" -eq 0 ]]; then (cd "$COMPOSE_DIR" && docker compose up -d) \ || die "$EXIT_COMPOSE" "docker compose up reported non-zero." fi fi local sha_after sha_after=$(sha256sum "$STATE_DB" | awk '{print $1}') if [[ "$sha_before" != "$sha_after" ]]; then die "$EXIT_GENERIC" "state.db SHA-256 changed during idempotent re-run (idempotency violation)." fi local svc_state svc_state=$(docker compose -f "$COMPOSE_FILE" ps --format json 2>/dev/null \ | grep -m1 -o '"State":"[^"]*"' | head -1 | cut -d'"' -f4 || echo "unknown") log "Pulled image: ditssa/licensepanel:${panel_version}" log "Service state: ${svc_state:-unknown}" log "Open in your browser:" if [[ -n "$panel_host" ]]; then log " https://${panel_host}/" else log " https://$(hostname -I | awk '{print $1}'):8443/" fi exit "$EXIT_SUCCESS" } # ============================================================================ # Air-gap branch (FR-005 / FR-006 / SC-009) # ============================================================================ verify_airgap_tarball() { local dir="$1" local arch arch=$(uname -m) case "$arch" in x86_64) arch=amd64 ;; aarch64) arch=arm64 ;; *) die "$EXIT_AIRGAP_INVALID" "unsupported arch for air-gap: $arch" ;; esac local tarball="$dir/licensepanel-linux-${arch}.tar.gz" local sums="$dir/SHA256SUMS.asc" [[ -f "$tarball" ]] || die "$EXIT_AIRGAP_INVALID" "tarball missing: $tarball" [[ -f "$sums" ]] || die "$EXIT_AIRGAP_INVALID" "SHA256SUMS.asc missing in $dir" if ! command -v gpg >/dev/null 2>&1; then die "$EXIT_AIRGAP_INVALID" "gpg required for air-gap verification." fi # Verify the PGP signature on SHA256SUMS.asc against the embedded vendor key. if ! gpg --verify "$sums" >/dev/null 2>&1; then rm -f "$tarball" "$sums" die "$EXIT_AIRGAP_INVALID" "AIRGAP_SIGNATURE_INVALID: PGP signature on SHA256SUMS.asc did not verify." fi # Verify the tarball SHA-256 matches the entry in SHA256SUMS.asc. local expected_sum actual_sum expected_sum=$(grep -F "$(basename "$tarball")" "$sums" | awk '{print $1}') actual_sum=$(sha256sum "$tarball" | awk '{print $1}') if [[ -z "$expected_sum" || "$expected_sum" != "$actual_sum" ]]; then # Refuse — and leave zero residual files behind in $PANEL_DIR (FR-006). rm -rf "$PANEL_DIR" 2>/dev/null || true die "$EXIT_AIRGAP_INVALID" "AIRGAP_SIGNATURE_INVALID: tarball SHA-256 mismatch." fi log "air-gap: signature + checksum verified for $(basename "$tarball")" } # ============================================================================ # First-install branches # ============================================================================ install_docker_if_missing() { if [[ "$no_docker" -eq 1 ]]; then log "skipping Docker install (--no-docker)." return fi if command -v docker >/dev/null 2>&1; then return fi log "installing Docker via get.docker.com..." curl -fsSL https://get.docker.com | sh \ || die "$EXIT_DOCKER_INSTALL" "Docker installation failed." } generate_kek_material() { local pfx="$KEYS_DIR/master.pfx" if [[ -f "$pfx" ]]; then return; fi # 4096-bit RSA cert + random PKCS#12 password. The panel reads master.pfx # via X509KekProvider; the password is wrapped at first boot via the # in-process key custody seam. local tmp tmp=$(mktemp -d) openssl req -x509 -nodes -newkey rsa:4096 -days 1825 \ -keyout "$tmp/master.key" -out "$tmp/master.crt" \ -subj "/CN=licensepanel-kek" 2>/dev/null \ || die "$EXIT_TLS" "KEK certificate generation failed." openssl pkcs12 -export \ -out "$pfx" \ -inkey "$tmp/master.key" \ -in "$tmp/master.crt" \ -password pass: \ 2>/dev/null rm -rf "$tmp" chmod 0600 "$pfx" log "kek: master.pfx generated." } generate_postgres_tls() { if [[ -f "$POSTGRES_TLS_DIR/server.crt" ]]; then return; fi local script script=$(resolve_lib "generate-postgres-tls.sh") bash "$script" "$POSTGRES_TLS_DIR" \ || die "$EXIT_TLS" "Postgres TLS generation failed." } generate_bootstrap_cert() { if [[ -f "$TRAEFIK_DIR/bootstrap-cert.pem" ]]; then return; fi local script script=$(resolve_lib "generate-bootstrap-cert.sh") bash "$script" "$TRAEFIK_DIR" \ || die "$EXIT_TLS" "Bootstrap cert generation failed." } write_panel_stack() { install -d -m 0750 "$COMPOSE_DIR" # Postgres password — random, never printed. local postgres_password postgres_password=$(openssl rand -base64 32 | tr -d '/+=' | head -c 32) cat > "$ENV_FILE" <