diff --git a/install.sh b/install.sh index 2342312..1ddb05f 100755 --- a/install.sh +++ b/install.sh @@ -11,7 +11,7 @@ Usage: bash install.sh [options] Core options: - --ctid Force CT ID (optional). If omitted, customer-safe CTID is generated. + --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) @@ -19,11 +19,15 @@ Core options: --bridge (default: vmbr0) --storage (default: local-zfs) --ip (default: dhcp) - --vlan VLAN tag on net0 (default: 90). Use 0/empty to disable tagging. + --vlan VLAN tag for net0 (default: 90) + --domain Base domain for FQDN (default: userman.de) --privileged Create privileged CT (default: unprivileged) - --base-domain e.g. userman.de (default: userman.de) --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 } @@ -37,9 +41,12 @@ BRIDGE="vmbr0" STORAGE="local-zfs" IPCFG="dhcp" VLAN="90" +DOMAIN="userman.de" UNPRIV="1" -BASE_DOMAIN="userman.de" +# --------------------------- +# Arg parsing +# --------------------------- while [[ $# -gt 0 ]]; do case "$1" in --ctid) CTID="${2:-}"; shift 2 ;; @@ -51,67 +58,73 @@ while [[ $# -gt 0 ]]; do --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 ;; - --base-domain) BASE_DOMAIN="${2:-}"; shift 2 ;; --help|-h) usage; exit 0 ;; *) die "Unknown option: $1 (use --help)" ;; esac done -# Validate -[[ "$CORES" =~ ^[0-9]+$ ]] || die "--cores must be integer" -[[ "$MEMORY" =~ ^[0-9]+$ ]] || die "--memory must be integer" -[[ "$SWAP" =~ ^[0-9]+$ ]] || die "--swap must be integer" -[[ "$DISK" =~ ^[0-9]+$ ]] || die "--disk must be integer" +# 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" -[[ -z "$VLAN" || "$VLAN" =~ ^[0-9]+$ ]] || die "--vlan must be integer (or empty)" -[[ -n "$BASE_DOMAIN" ]] || die "--base-domain must not be empty" 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.171/24)" + [[ "$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" -need_cmd pct pvesm pveam date awk grep sed +# --------------------------- +# Preflight Proxmox +# --------------------------- +need_cmd pct pvesm pveam date -# Preflight pve_storage_exists "$STORAGE" || die "Storage not found: $STORAGE" -pve_bridge_exists "$BRIDGE" || die "Bridge not found: $BRIDGE" +pve_bridge_exists "$BRIDGE" || die "Bridge not found: $BRIDGE" TEMPLATE="$(pve_template_ensure_debian12 "$STORAGE")" info "Template OK: ${TEMPLATE}" -# Hostname (wie gehabt) -CT_HOSTNAME="sb-$(date +%s)" +# Hostname based on unix time (as agreed) +UNIXTS="$(date +%s)" +CT_HOSTNAME="sb-${UNIXTS}" +FQDN="${CT_HOSTNAME}.${DOMAIN}" # CTID selection if [[ -n "$CTID" ]]; then - [[ "$CTID" =~ ^[0-9]+$ ]] || die "--ctid must be integer" - if pct status "$CTID" >/dev/null 2>&1; then - die "Forced CTID=${CTID} already exists locally" + 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 -# FQDN ableiten (ohne extra Option; du weißt es erst nach Erstellung -> passt so) -FQDN="${CT_HOSTNAME}.${BASE_DOMAIN}" - 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:-0} unprivileged=${UNPRIV}" +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}" \ @@ -134,13 +147,22 @@ info "Step 5 OK: LXC erstellt + IP ermittelt" info "CT_HOSTNAME=${CT_HOSTNAME}" info "CT_IP=${CT_IP}" -# Step 6: Docker + locales + base dirs +# --------------------------- +# 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" +pct_exec "${CTID}" "export DEBIAN_FRONTEND=noninteractive; apt-get install -y ca-certificates curl gnupg lsb-release locales" -# Docker repo (Debian 12/bookworm) +# 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" @@ -148,34 +170,39 @@ pct_exec "${CTID}" "echo \"deb [arch=\$(dpkg --print-architecture) signed-by=/et 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" -# locales fix (de_DE.UTF-8) -pct_fix_locales "${CTID}" "de_DE.UTF-8" - -pct_exec "${CTID}" "mkdir -p /opt/customer-stack/volumes/postgres/data /opt/customer-stack/volumes/n8n-data /opt/customer-stack/sql" +# 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 deploy +# --------------------------- +# Step 7: Stack finalisieren + Secrets + Up + Checks +# --------------------------- info "Step 7: Stack finalisieren + Secrets + Up + Checks" -# Secrets (ohne tr broken pipe) +# secrets PG_DB="customer" PG_USER="customer" -PG_PASSWORD="$(gen_urlsafe 24)" -N8N_ENCRYPTION_KEY="$(gen_hex 32)" # 64 hex chars +PG_PASSWORD="$(gen_hex 16)" # 32 hex chars +N8N_ENCRYPTION_KEY="$(gen_hex 32)" # 64 hex chars -# URL / Cookies -# Wenn du später via OPNsense/NGINX TLS machst, setze PROTOCOL=https und SECURE_COOKIE=true. -# Hier leiten wir's anhand FQDN ab (du nutzt HTTPS extern). -N8N_PROTOCOL="http" -N8N_SECURE_COOKIE="false" +# 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" -# In /opt/customer-stack schreiben +# 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} @@ -190,19 +217,18 @@ N8N_SECURE_COOKIE=${N8N_SECURE_COOKIE} N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY} -# Telemetrie/Background Calls aus (n8n docs) -N8N_DIAGNOSTICS_ENABLED=false -N8N_VERSION_NOTIFICATIONS_ENABLED=false -N8N_TEMPLATES_ENABLED=false +N8N_DIAGNOSTICS_ENABLED=${N8N_DIAGNOSTICS_ENABLED} +N8N_VERSION_NOTIFICATIONS_ENABLED=${N8N_VERSION_NOTIFICATIONS_ENABLED} +N8N_TEMPLATES_ENABLED=${N8N_TEMPLATES_ENABLED} ENV" -# pgvector init +# 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 +# docker-compose.yml (in CT) pct_exec "${CTID}" "cat > /opt/customer-stack/docker-compose.yml <<'YML' services: postgres: @@ -217,7 +243,7 @@ services: - ./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\"] + test: ['CMD-SHELL', 'pg_isready -U \${PG_USER} -d \${PG_DB} || exit 1'] interval: 10s timeout: 5s retries: 20 @@ -232,7 +258,7 @@ services: postgres: condition: service_healthy ports: - - \"\${N8N_PORT}:5678\" + - '\${N8N_PORT}:5678' environment: N8N_PORT: 5678 N8N_PROTOCOL: \${N8N_PROTOCOL} @@ -250,9 +276,10 @@ services: GENERIC_TIMEZONE: Europe/Berlin TZ: Europe/Berlin + N8N_ENCRYPTION_KEY: \${N8N_ENCRYPTION_KEY} - # Telemetrie/Background Calls aus (n8n docs) + # Telemetrie/Background Calls aus N8N_DIAGNOSTICS_ENABLED: \${N8N_DIAGNOSTICS_ENABLED} N8N_VERSION_NOTIFICATIONS_ENABLED: \${N8N_VERSION_NOTIFICATIONS_ENABLED} N8N_TEMPLATES_ENABLED: \${N8N_TEMPLATES_ENABLED} @@ -267,7 +294,7 @@ networks: driver: bridge YML" -# Pull + Up + Status/Checks +# 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" @@ -276,3 +303,22 @@ 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 "}" diff --git a/libsupabase.sh b/libsupabase.sh index 74c64d6..d89592c 100755 --- a/libsupabase.sh +++ b/libsupabase.sh @@ -1,26 +1,22 @@ #!/usr/bin/env bash +# libsupabase.sh set -Eeuo pipefail -# --------------------------- -# Logging / Errors / Traps -# --------------------------- +# ---------- logging (ALLES nach STDERR) ---------- _ts() { date '+%F %T'; } -# log to stderr (wichtig: damit Funktions-Rückgaben via stdout sauber bleiben) -log() { echo "[$(_ts)] $*" >&2; } -info() { log "INFO: $*"; } -warn() { log "WARN: $*"; } -die() { log "ERROR: $*"; exit 1; } - -on_error() { - local lineno="$1" - local cmd="$2" - local code="$3" - die "Failed at line ${lineno}: ${cmd} (exit=${code})" +_log() { + local level="$1"; shift + # stderr, damit stdout sauber bleibt (für JSON am Ende) + echo "[$(_ts)] ${level}: $*" >&2 } +info() { _log "INFO" "$*"; } +warn() { _log "WARN" "$*"; } +err() { _log "ERROR" "$*"; } -setup_traps() { - trap 'on_error "$LINENO" "$BASH_COMMAND" "$?"' ERR +die() { + err "$*" + exit 1 } need_cmd() { @@ -30,38 +26,51 @@ need_cmd() { done } -# --------------------------- -# Safe secret generators (NO tr|head pipelines) -# --------------------------- -# Hex secret, length = bytes*2 chars -gen_hex() { - local bytes="${1:-32}" - if command -v openssl >/dev/null 2>&1; then - openssl rand -hex "$bytes" - return 0 - fi - # fallback python - python3 - </dev/null || true)" + if [[ -n "${ip}" ]]; then + echo "${ip}" + return 0 + fi + sleep 1 + done + return 1 +} + +# Proxmox checks pve_storage_exists() { local st="$1" - pvesm status --storage "$st" >/dev/null 2>&1 + need_cmd pvesm + pvesm status --storage "${st}" >/dev/null 2>&1 } pve_bridge_exists() { @@ -69,100 +78,98 @@ pve_bridge_exists() { [[ -d "/sys/class/net/${br}/bridge" ]] } +# Build net0 string (supports optional VLAN tag) +pve_build_net0() { + local bridge="$1" + local ipcfg="$2" # dhcp OR CIDR + local vlan="${3:-}" # optional integer + + local net="name=eth0,bridge=${bridge}" + if [[ -n "${vlan}" ]]; then + is_int "${vlan}" || die "--vlan must be integer" + net="${net},tag=${vlan}" + fi + + if [[ "${ipcfg}" == "dhcp" ]]; then + net="${net},ip=dhcp" + else + net="${net},ip=${ipcfg}" + fi + echo "${net}" +} + +# Template ensure (stdout darf NUR den Template-Pfad liefern!) pve_template_ensure_debian12() { - # Gibt NUR den template-string auf stdout zurück - # Logs gehen auf stderr (info/warn), damit TEMPLATE nicht "vermüllt". - local desired="debian-12-standard_12.12-1_amd64.tar.zst" - local store="$1" + local preferred_store="$1" + local tpl="debian-12-standard_12.12-1_amd64.tar.zst" need_cmd pveam awk grep - # prüfen, ob storage für templates geeignet ist - if ! pveam list "$store" >/dev/null 2>&1; then + local store="${preferred_store}" + + # pveam kann oft nur "local" + if ! pveam list "${store}" >/dev/null 2>&1; then warn "pveam storage '${store}' not available for templates; falling back to 'local'" store="local" fi + # update list pveam update >/dev/null 2>&1 || true - # vorhanden? - if ! pveam list "$store" | awk '{print $2}' | grep -qx "$desired"; then - # check available list - if pveam available | awk '{print $2}' | grep -qx "$desired"; then - info "Downloading CT template to ${store}: ${desired}" - pveam download "$store" "$desired" >/dev/null - else - die "Template not available via pveam: ${desired}" - fi - else - # optional: trotzdem loggen - : + # download if missing + if ! pveam list "${store}" 2>/dev/null | awk '{print $2}' | grep -qx "${tpl}"; then + info "Downloading CT template to ${store}: ${tpl}" + pveam download "${store}" "${tpl}" >/dev/null fi - echo "${store}:vztmpl/${desired}" + echo "${store}:vztmpl/${tpl}" } -pve_build_net0() { - local bridge="$1" - local ipcfg="$2" - local vlan="${3:-}" - - local base="name=eth0,bridge=${bridge},ip=${ipcfg}" - if [[ -n "${vlan}" && "${vlan}" != "0" ]]; then - base="${base},tag=${vlan}" - fi - echo "$base" -} - -# CTID nach "Unixzeit - 1e9" (Kundensicher bis 2038) + lokale Kollisionsprüfung +# ---- CTID selection (customer-safe): (unix_time - 1_000_000_000) ---- +# Keine Cluster-Abfrage mehr nötig. Nur lokale Node-Check, + increment falls belegt. pve_select_customer_ctid() { - need_cmd date pct awk sort tail + need_cmd pct date local base base="$(($(date +%s) - 1000000000))" - # Falls lokal belegt, hochzählen bis frei - local ctid="$base" - while pct status "$ctid" >/dev/null 2>&1; do + # falls negative (sollte nicht passieren), fallback + if (( base < 100 )); then + base=100000 + fi + + local ctid="${base}" + + # nur lokal prüfen (pct list ist node-lokal) + while pct status "${ctid}" >/dev/null 2>&1; do ctid="$((ctid + 1))" done - echo "$ctid" + + echo "${ctid}" } -pct_wait_for_ip() { - local ctid="$1" - local tries="${2:-60}" - local i=0 - - need_cmd pct awk grep - - while (( i < tries )); do - # pct exec ip -4 addr show dev eth0 → robust bei DHCP - local ip="" - ip="$(pct exec "$ctid" -- bash -lc "ip -4 -o addr show dev eth0 | awk '{print \$4}' | cut -d/ -f1 | head -n1" 2>/dev/null || true)" - if [[ -n "$ip" ]]; then - echo "$ip" - return 0 - fi - sleep 1 - ((i++)) - done - return 1 +# Secrets ohne tr/head Pipes (kein Broken pipe) +gen_hex() { + local nbytes="$1" + is_int "${nbytes}" || die "gen_hex expects integer bytes" + need_cmd openssl + openssl rand -hex "${nbytes}" } -pct_exec() { - local ctid="$1"; shift - pct exec "$ctid" -- bash -lc "$*" +# JSON escaper (minimal, reicht für unsere Werte) +json_escape() { + local s="${1:-}" + s="${s//\\/\\\\}" + s="${s//\"/\\\"}" + s="${s//$'\n'/\\n}" + s="${s//$'\r'/\\r}" + s="${s//$'\t'/\\t}" + echo -n "${s}" } -# Locale fix (Debian 12) -pct_fix_locales() { - local ctid="$1" - local locale="${2:-de_DE.UTF-8}" - pct_exec "$ctid" "export DEBIAN_FRONTEND=noninteractive; apt-get update -y" - pct_exec "$ctid" "export DEBIAN_FRONTEND=noninteractive; apt-get install -y locales" - pct_exec "$ctid" "sed -i 's/^# *${locale} UTF-8/${locale} UTF-8/' /etc/locale.gen || true" - pct_exec "$ctid" "locale-gen ${locale} >/dev/null" - pct_exec "$ctid" "update-locale LANG=${locale} LC_ALL=${locale}" +# Print a JSON object (stdout). Use at the VERY end. +print_json_kv() { + # args: key value + local k="$1" v="$2" + echo -n "\"$(json_escape "$k")\":\"$(json_escape "$v")\"" } -