#!/usr/bin/env bash set -Eeuo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "${SCRIPT_DIR}/libsupabase.sh" setup_traps usage() { cat >&2 <<'EOF' Usage: bash install.sh [options] Core options: --ctid Force CT ID (optional). If omitted, a customer-safe CTID is generated: (unix_time - 1000000000) --cores (default: 2) --memory (default: 4096) --swap (default: 512) --disk (default: 50) --bridge (default: vmbr0) --storage (default: local-zfs) --ip (default: dhcp) --vlan VLAN tag for net0 (default: 90) --domain Base domain for FQDN (default: userman.de) --privileged Create privileged CT (default: unprivileged) --help Show help Examples: bash install.sh bash install.sh --vlan 90 --ip dhcp bash install.sh --ip 192.168.45.53/24 --vlan 90 EOF } # Defaults CTID="" CORES="2" MEMORY="4096" SWAP="512" DISK="50" BRIDGE="vmbr0" STORAGE="local-zfs" IPCFG="dhcp" VLAN="90" DOMAIN="userman.de" UNPRIV="1" # --------------------------- # Arg parsing # --------------------------- while [[ $# -gt 0 ]]; do case "$1" in --ctid) CTID="${2:-}"; shift 2 ;; --cores) CORES="${2:-}"; shift 2 ;; --memory) MEMORY="${2:-}"; shift 2 ;; --swap) SWAP="${2:-}"; shift 2 ;; --disk) DISK="${2:-}"; shift 2 ;; --bridge) BRIDGE="${2:-}"; shift 2 ;; --storage) STORAGE="${2:-}"; shift 2 ;; --ip) IPCFG="${2:-}"; shift 2 ;; --vlan) VLAN="${2:-}"; shift 2 ;; --domain) DOMAIN="${2:-}"; shift 2 ;; --privileged) UNPRIV="0"; shift 1 ;; --help|-h) usage; exit 0 ;; *) die "Unknown option: $1 (use --help)" ;; esac done # Basic validation is_int "$CORES" || die "--cores must be integer" is_int "$MEMORY" || die "--memory must be integer" is_int "$SWAP" || die "--swap must be integer" is_int "$DISK" || die "--disk must be integer" is_int "$VLAN" || die "--vlan must be integer" [[ "$UNPRIV" == "0" || "$UNPRIV" == "1" ]] || die "internal: UNPRIV invalid" if [[ "$IPCFG" != "dhcp" ]]; then [[ "$IPCFG" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}/[0-9]{1,2}$ ]] || die "--ip must be dhcp or CIDR (e.g. 192.168.45.53/24)" fi [[ -n "${DOMAIN}" ]] || die "--domain must not be empty" info "Argument-Parsing OK" # --------------------------- # Preflight Proxmox # --------------------------- need_cmd pct pvesm pveam date pve_storage_exists "$STORAGE" || die "Storage not found: $STORAGE" pve_bridge_exists "$BRIDGE" || die "Bridge not found: $BRIDGE" TEMPLATE="$(pve_template_ensure_debian12 "$STORAGE")" info "Template OK: ${TEMPLATE}" # Hostname based on unix time (as agreed) UNIXTS="$(date +%s)" CT_HOSTNAME="sb-${UNIXTS}" FQDN="${CT_HOSTNAME}.${DOMAIN}" # CTID selection if [[ -n "$CTID" ]]; then is_int "$CTID" || die "--ctid must be integer" # nur lokal check (cluster-check war dir zu unzuverlässig) if pct status "${CTID}" >/dev/null 2>&1; then die "Forced CTID=${CTID} already exists on this node" fi else CTID="$(pve_select_customer_ctid)" fi info "CTID selected: ${CTID}" info "SCRIPT_DIR=${SCRIPT_DIR}" info "CT_HOSTNAME=${CT_HOSTNAME}" info "FQDN=${FQDN}" info "cores=${CORES} memory=${MEMORY}MB swap=${SWAP}MB disk=${DISK}GB" info "bridge=${BRIDGE} storage=${STORAGE} ip=${IPCFG} vlan=${VLAN} unprivileged=${UNPRIV}" # --------------------------- # Step 5: Create CT # --------------------------- NET0="$(pve_build_net0 "$BRIDGE" "$IPCFG" "$VLAN")" ROOTFS="${STORAGE}:${DISK}" FEATURES="nesting=1,keyctl=1,fuse=1" info "Step 5: Create CT" info "Creating CT ${CTID} (${CT_HOSTNAME}) from ${TEMPLATE}" pct create "${CTID}" "${TEMPLATE}" \ --hostname "${CT_HOSTNAME}" \ --cores "${CORES}" \ --memory "${MEMORY}" \ --swap "${SWAP}" \ --net0 "${NET0}" \ --rootfs "${ROOTFS}" \ --unprivileged "${UNPRIV}" \ --features "${FEATURES}" \ --start 0 info "CT created (not started). Next step: start CT + wait for IP" info "Starting CT ${CTID}" pct start "${CTID}" CT_IP="$(pct_wait_for_ip "${CTID}" || true)" [[ -n "${CT_IP}" ]] || die "Could not determine CT IP after start" info "Step 5 OK: LXC erstellt + IP ermittelt" info "CT_HOSTNAME=${CT_HOSTNAME}" info "CT_IP=${CT_IP}" # --------------------------- # Step 6: Provision inside CT (Docker + Locales + Base) # --------------------------- info "Step 6: Provisioning im CT (Docker + Locales + Base)" # Base packages (include locales early, damit perl nicht meckert) pct_exec "${CTID}" "export DEBIAN_FRONTEND=noninteractive; apt-get update -y" pct_exec "${CTID}" "export DEBIAN_FRONTEND=noninteractive; apt-get install -y ca-certificates curl gnupg lsb-release locales" # locales setzen (de_DE.UTF-8) pct_exec "${CTID}" "sed -i 's/^# *de_DE.UTF-8 UTF-8/de_DE.UTF-8 UTF-8/' /etc/locale.gen || true" pct_exec "${CTID}" "locale-gen >/dev/null" pct_exec "${CTID}" "update-locale LANG=de_DE.UTF-8 LC_ALL=de_DE.UTF-8" pct_exec "${CTID}" "printf 'LANG=de_DE.UTF-8\nLC_ALL=de_DE.UTF-8\n' > /etc/default/locale" # Docker official repo (Debian 12 / bookworm) pct_exec "${CTID}" "install -m 0755 -d /etc/apt/keyrings" pct_exec "${CTID}" "curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg" pct_exec "${CTID}" "chmod a+r /etc/apt/keyrings/docker.gpg" pct_exec "${CTID}" "echo \"deb [arch=\$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian \$(. /etc/os-release && echo \$VERSION_CODENAME) stable\" > /etc/apt/sources.list.d/docker.list" pct_exec "${CTID}" "export DEBIAN_FRONTEND=noninteractive; apt-get update -y" pct_exec "${CTID}" "export DEBIAN_FRONTEND=noninteractive; apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin" # Create stack directories pct_exec "${CTID}" "mkdir -p /opt/customer-stack/sql /opt/customer-stack/volumes/postgres/data /opt/customer-stack/volumes/n8n-data" info "Step 6 OK: Docker + Compose Plugin installiert, Locales gesetzt, Basis-Verzeichnisse erstellt" info "Next: Schritt 7 (finales docker-compose + Secrets + n8n/supabase up + Healthchecks)" # --------------------------- # Step 7: Stack finalisieren + Secrets + Up + Checks # --------------------------- info "Step 7: Stack finalisieren + Secrets + Up + Checks" # secrets PG_DB="customer" PG_USER="customer" PG_PASSWORD="$(gen_hex 16)" # 32 hex chars N8N_ENCRYPTION_KEY="$(gen_hex 32)" # 64 hex chars # n8n URL config: # intern bleibt http://CT_IP:5678 # extern geplant via OPNsense reverse proxy: https://FQDN N8N_PORT="5678" N8N_PROTOCOL="http" N8N_HOST="${CT_IP}" N8N_EDITOR_BASE_URL="https://${FQDN}/" WEBHOOK_URL="https://${FQDN}/" N8N_SECURE_COOKIE="false" # Telemetrie / Background Calls aus N8N_DIAGNOSTICS_ENABLED="false" N8N_VERSION_NOTIFICATIONS_ENABLED="false" N8N_TEMPLATES_ENABLED="false" # write .env (in CT) pct_exec "${CTID}" "cat > /opt/customer-stack/.env <<'ENV' PG_DB=${PG_DB} PG_USER=${PG_USER} PG_PASSWORD=${PG_PASSWORD} N8N_PORT=${N8N_PORT} N8N_PROTOCOL=${N8N_PROTOCOL} N8N_HOST=${N8N_HOST} N8N_EDITOR_BASE_URL=${N8N_EDITOR_BASE_URL} WEBHOOK_URL=${WEBHOOK_URL} N8N_SECURE_COOKIE=${N8N_SECURE_COOKIE} N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY} N8N_DIAGNOSTICS_ENABLED=${N8N_DIAGNOSTICS_ENABLED} N8N_VERSION_NOTIFICATIONS_ENABLED=${N8N_VERSION_NOTIFICATIONS_ENABLED} N8N_TEMPLATES_ENABLED=${N8N_TEMPLATES_ENABLED} ENV" # init sql pct_exec "${CTID}" "cat > /opt/customer-stack/sql/init_pgvector.sql <<'SQL' CREATE EXTENSION IF NOT EXISTS vector; CREATE EXTENSION IF NOT EXISTS pgcrypto; SQL" # docker-compose.yml (in CT) pct_exec "${CTID}" "cat > /opt/customer-stack/docker-compose.yml <<'YML' services: postgres: image: pgvector/pgvector:pg16 container_name: customer-postgres restart: unless-stopped environment: POSTGRES_DB: \${PG_DB} POSTGRES_USER: \${PG_USER} POSTGRES_PASSWORD: \${PG_PASSWORD} volumes: - ./volumes/postgres/data:/var/lib/postgresql/data - ./sql:/docker-entrypoint-initdb.d:ro healthcheck: test: ['CMD-SHELL', 'pg_isready -U \${PG_USER} -d \${PG_DB} || exit 1'] interval: 10s timeout: 5s retries: 20 networks: - customer-net n8n: image: n8nio/n8n:latest container_name: n8n restart: unless-stopped depends_on: postgres: condition: service_healthy ports: - '\${N8N_PORT}:5678' environment: N8N_PORT: 5678 N8N_PROTOCOL: \${N8N_PROTOCOL} N8N_HOST: \${N8N_HOST} N8N_EDITOR_BASE_URL: \${N8N_EDITOR_BASE_URL} WEBHOOK_URL: \${WEBHOOK_URL} N8N_SECURE_COOKIE: \${N8N_SECURE_COOKIE} DB_TYPE: postgresdb DB_POSTGRESDB_HOST: postgres DB_POSTGRESDB_PORT: 5432 DB_POSTGRESDB_DATABASE: \${PG_DB} DB_POSTGRESDB_USER: \${PG_USER} DB_POSTGRESDB_PASSWORD: \${PG_PASSWORD} GENERIC_TIMEZONE: Europe/Berlin TZ: Europe/Berlin N8N_ENCRYPTION_KEY: \${N8N_ENCRYPTION_KEY} # Telemetrie/Background Calls aus N8N_DIAGNOSTICS_ENABLED: \${N8N_DIAGNOSTICS_ENABLED} N8N_VERSION_NOTIFICATIONS_ENABLED: \${N8N_VERSION_NOTIFICATIONS_ENABLED} N8N_TEMPLATES_ENABLED: \${N8N_TEMPLATES_ENABLED} volumes: - ./volumes/n8n-data:/home/node/.n8n networks: - customer-net networks: customer-net: driver: bridge YML" # Deploy pct_exec "${CTID}" "cd /opt/customer-stack && docker compose pull" pct_exec "${CTID}" "cd /opt/customer-stack && docker compose up -d" pct_exec "${CTID}" "cd /opt/customer-stack && docker compose ps" info "Step 7 OK: Stack deployed" info "n8n intern: http://${CT_IP}:5678/" info "n8n extern (geplant via OPNsense): https://${FQDN}" info "Hinweis: Telemetrie/Template/Versionschecks sind deaktiviert (n8n docs)." # --------------------------- # JSON Output (NUR stdout) # --------------------------- # Damit kann n8n/SSH node direkt JSON parsen. # Alles andere ist in stderr geloggt. echo -n "{" print_json_kv "ctid" "${CTID}"; echo -n "," print_json_kv "hostname" "${CT_HOSTNAME}"; echo -n "," print_json_kv "fqdn" "${FQDN}"; echo -n "," print_json_kv "ip" "${CT_IP}"; echo -n "," print_json_kv "n8n_internal_url" "http://${CT_IP}:5678/"; echo -n "," print_json_kv "n8n_external_url" "https://${FQDN}/"; echo -n "," print_json_kv "pg_db" "${PG_DB}"; echo -n "," print_json_kv "pg_user" "${PG_USER}"; echo -n "," print_json_kv "pg_password" "${PG_PASSWORD}"; echo -n "," print_json_kv "n8n_encryption_key" "${N8N_ENCRYPTION_KEY}" echo "}"