#!/usr/bin/env bash # ┌─────────────────────────────────────────────────────────────────────────┐ # │ HNF Manage — Proxmox Stack Installer │ # │ Provisions: postgres · auth · portal · npm · management · noticeboard │ # │ │ # │ Run on the Proxmox host shell: │ # │ bash install-stack.sh │ # │ │ # │ Or from Forgejo once repos are pushed: │ # │ bash <(curl -fsSL https://git.pterois.co.uk/jtr/proxmox-helpers-hotel-manage-stack/raw/branch/main/install-stack.sh) # └─────────────────────────────────────────────────────────────────────────┘ set -euo pipefail # ── Colour helpers ──────────────────────────────────────────────────────────── YW="\033[33m"; BL="\033[36m"; RD="\033[01;31m" GN="\033[1;92m"; DGN="\033[32m"; CL="\033[m" BFR="\\r\\033[K"; CM="${GN}✓${CL}"; CROSS="${RD}✗${CL}" msg_info() { printf " ◌ ${YW}%-55s${CL}" "$*"; } msg_ok() { printf "${BFR} ${CM} ${DGN}%s${CL}\n" "$*"; } msg_error() { printf "${BFR} ${CROSS} ${RD}%s${CL}\n" "$*"; exit 1; } msg_warn() { printf "\n ${CROSS} ${YW}%s${CL}\n" "$*"; } msg_step() { printf "\n${BL}── %s ─────────────────────────────${CL}\n" "$*"; } header_info() { clear printf "${BL}" cat <<'BANNER' ╔══════════════════════════════════════════════════════════════╗ ║ HNF MANAGE — PROXMOX STACK INSTALLER ║ ║ postgres · auth · portal · npm · mgmt · noticeboard ║ ╚══════════════════════════════════════════════════════════════╝ BANNER printf "${CL}\n" } # ── Pre-flight ──────────────────────────────────────────────────────────────── [[ $EUID -ne 0 ]] && msg_error "Must run as root on the Proxmox VE host" command -v pct &>/dev/null || msg_error "pct not found — run this on a Proxmox VE host" command -v pvesm &>/dev/null || msg_error "pvesm not found — run this on a Proxmox VE host" command -v whiptail &>/dev/null || { apt-get install -y -qq whiptail &>/dev/null; } command -v openssl &>/dev/null || { apt-get install -y -qq openssl &>/dev/null; } # Defensive — when run via `bash <(curl ...)` BASH_SOURCE is a pipe, not a file SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd || echo /tmp)" REPO_ROOT="$(cd "${SCRIPT_DIR}/.." 2>/dev/null && pwd || echo /tmp)" header_info # ── Detect storage pool ─────────────────────────────────────────────────────── detect_storage() { if pvesm status 2>/dev/null | awk '{print $1}' | grep -q "^local-lvm$"; then echo "local-lvm" elif pvesm status 2>/dev/null | awk '{print $1}' | grep -q "^local-zfs$"; then echo "local-zfs" else echo "local" fi } STORAGE=$(detect_storage) # ── Check vmbr1 internal bridge ─────────────────────────────────────────────── check_vmbr1() { if ! ip link show vmbr1 &>/dev/null; then whiptail --title "vmbr1 Missing" --msgbox \ "The internal container bridge vmbr1 does not exist yet. Add the following to /etc/network/interfaces on this host, then run: ifreload -a auto vmbr1 iface vmbr1 inet static address 10.10.10.1/24 bridge-ports none bridge-stp off bridge-fd 0 Then re-run this installer." 18 62 exit 1 fi } # ── Ensure Ubuntu 22.04 template ───────────────────────────────────────────── ensure_template() { local tmpl tmpl=$(pveam list local 2>/dev/null | awk '/ubuntu-22\.04/{print $1; exit}') if [[ -z "$tmpl" ]]; then msg_info "Downloading Ubuntu 22.04 LXC template" pveam update &>/dev/null pveam download local ubuntu-22.04-standard_22.04-1_amd64.tar.zst &>/dev/null msg_ok "Template downloaded" tmpl="ubuntu-22.04-standard_22.04-1_amd64.tar.zst" fi # Return full path for pct create echo "local:vztmpl/${tmpl##*/}" } # ── Collect site config ─────────────────────────────────────────────────────── collect_config() { SITE_NAME=$(whiptail --title "HNF Stack — Site Config" \ --inputbox "Site name:" 8 52 "Hotel Number Four" 3>&1 1>&2 2>&3) || exit 0 DOMAIN=$(whiptail --title "HNF Stack — Site Config" \ --inputbox "Public domain (e.g. manage.hotelnumberfour.com):" 8 64 "manage.hotelnumberfour.com" \ 3>&1 1>&2 2>&3) || exit 0 NPM_LAN_IP=$(whiptail --title "HNF Stack — Site Config" \ --inputbox "NPM LXC static LAN IP (from your hotel LAN pool):" 8 64 "10.4.0.50" \ 3>&1 1>&2 2>&3) || exit 0 LAN_GW=$(whiptail --title "HNF Stack — Site Config" \ --inputbox "LAN gateway IP:" 8 52 "10.4.0.1" 3>&1 1>&2 2>&3) || exit 0 OFFICE_IP=$(whiptail --title "HNF Stack — Site Config" \ --inputbox \ "Office IP / CIDR / DDNS hostname for offsite restriction. Examples: 203.0.113.5 10.4.0.0/22 hotel.dyndns.org Type 'disabled' to allow access from anywhere:" \ 11 64 "disabled" 3>&1 1>&2 2>&3) || exit 0 ADMIN_EMAIL=$(whiptail --title "HNF Stack — Admin Account" \ --inputbox "Admin user email:" 8 52 "" 3>&1 1>&2 2>&3) || exit 0 ADMIN_PASS=$(whiptail --title "HNF Stack — Admin Account" \ --passwordbox "Admin user password:" 8 52 3>&1 1>&2 2>&3) || exit 0 # Deploy source — per-service Forgejo repos (default) or a local copy on this host FORGEJO_BASE=$(whiptail --title "HNF Stack — Forgejo" \ --inputbox \ "Forgejo org/base URL hosting the per-service repos. Each service is cloned from /.git → auth portal management noticeboard Example: https://git.pterois.co.uk/jtr" \ 13 66 "https://git.pterois.co.uk/jtr" 3>&1 1>&2 2>&3) || exit 0 FORGEJO_BASE="${FORGEJO_BASE%/}" FORGEJO_TOKEN=$(whiptail --title "HNF Stack — Forgejo Token" \ --passwordbox \ "The repos are PUBLIC — leave this blank. (Only needed if you make them private again: an access token from Forgejo → Settings → Applications, scope read:repository, which gets embedded in each LXC's git remote for the updater.)" \ 12 66 3>&1 1>&2 2>&3) || exit 0 if whiptail --title "HNF Stack — Deploy Source" --yesno \ "Deploy services from Forgejo? (recommended)\n\nNo = copy from a local repo at ${REPO_ROOT}\n(only works if you already copied the repo to this host)" \ 11 62; then USE_FORGEJO=true else USE_FORGEJO=false fi BACKUP_REMOTE=$(whiptail --title "HNF Stack — Backup" \ --inputbox \ "Backup rsync target (leave blank to skip backup config). Example: backup@192.168.1.10:/backups/hnf" \ 10 64 "" 3>&1 1>&2 2>&3) || exit 0 # Confirm LXC allocation whiptail --title "HNF Stack — Confirm" --yesno \ "LXCs to be created (storage: ${STORAGE}): ID Hostname IP ────────────────────────────────────────── 100 hnf-postgres 10.10.10.100 101 hnf-auth 10.10.10.101 102 hnf-portal 10.10.10.102 103 hnf-npm 10.10.10.103 / ${NPM_LAN_IP} (dual-homed) 105 hnf-management 10.10.10.105 112 hnf-noticeboard 10.10.10.112 Domain: ${DOMAIN} Admin: ${ADMIN_EMAIL} Proceed?" 24 58 || exit 0 } # ── Generate secrets ────────────────────────────────────────────────────────── gen_secrets() { msg_info "Generating secrets" PG_SUPERPASS=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24) AUTH_DB_PASS=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24) NOTICES_DB_PASS=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24) CENTRAL_AUTH_SECRET=$(openssl rand -hex 32) WEBHOOK_SECRET=$(openssl rand -hex 24) NPM_ADMIN_PASS=$(openssl rand -base64 12 | tr -dc 'a-zA-Z0-9' | head -c 12) cat > /root/hnf-credentials.txt </dev/null msg_ok "SSH keypair generated → /root/.ssh/hnf_management" else msg_ok "Using existing SSH keypair at /root/.ssh/hnf_management" fi MGMT_PUBKEY=$(cat /root/.ssh/hnf_management.pub) echo "${MGMT_PUBKEY}" >> /root/hnf-credentials.txt } # ── LXC lifecycle helpers ───────────────────────────────────────────────────── TEMPLATE_PATH="" get_template() { [[ -n "$TEMPLATE_PATH" ]] && { echo "$TEMPLATE_PATH"; return; } TEMPLATE_PATH=$(ensure_template) echo "$TEMPLATE_PATH" } lxc_exists() { pct status "$1" &>/dev/null; } lxc_running() { pct status "$1" 2>/dev/null | grep -q "running" } create_lxc() { local id=$1 ip=$2 name=$3 mem=${4:-512} cores=${5:-1} if lxc_exists "$id"; then msg_warn "LXC $id (hnf-${name}) already exists — skipping creation" lxc_running "$id" || pct start "$id" return fi local tmpl; tmpl=$(get_template) pct create "$id" "$tmpl" \ --hostname "hnf-${name}" \ --memory "$mem" \ --cores "$cores" \ --rootfs "${STORAGE}:8" \ --net0 "name=eth0,bridge=vmbr1,ip=${ip}/24,gw=10.10.10.1" \ --features nesting=1 \ --unprivileged 0 \ --onboot 1 \ --start 1 &>/dev/null sleep 5 # let systemd start } create_npm_lxc() { local id=103 if lxc_exists "$id"; then msg_warn "LXC $id (hnf-npm) already exists — skipping creation" lxc_running "$id" || pct start "$id" return fi local tmpl; tmpl=$(get_template) pct create "$id" "$tmpl" \ --hostname "hnf-npm" \ --memory 512 \ --cores 1 \ --rootfs "${STORAGE}:8" \ --net0 "name=eth0,bridge=vmbr0,ip=${NPM_LAN_IP}/22,gw=${LAN_GW}" \ --net1 "name=eth1,bridge=vmbr1,ip=10.10.10.3/24" \ --features nesting=1 \ --unprivileged 0 \ --onboot 1 \ --start 1 &>/dev/null sleep 5 } install_docker() { local id=$1 pct exec "$id" -- bash -s &>/dev/null <<'DOCKER_INSTALL' export DEBIAN_FRONTEND=noninteractive apt-get update -qq 2>/dev/null apt-get install -y -qq ca-certificates curl gnupg git openssh-server 2>/dev/null install -m 0755 -d /etc/apt/keyrings curl -fsSL https://download.docker.com/linux/ubuntu/gpg \ | gpg --dearmor -o /etc/apt/keyrings/docker.gpg 2>/dev/null echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.gpg] \ https://download.docker.com/linux/ubuntu jammy stable" \ > /etc/apt/sources.list.d/docker.list apt-get update -qq 2>/dev/null apt-get install -y -qq docker-ce docker-ce-cli containerd.io docker-compose-plugin 2>/dev/null systemctl enable --now docker 2>/dev/null systemctl enable --now ssh 2>/dev/null DOCKER_INSTALL } install_mgmt_key() { local id=$1 pct exec "$id" -- bash -c " mkdir -p /root/.ssh chmod 700 /root/.ssh grep -qF '${MGMT_PUBKEY}' /root/.ssh/authorized_keys 2>/dev/null || \ echo '${MGMT_PUBKEY}' >> /root/.ssh/authorized_keys chmod 600 /root/.ssh/authorized_keys " &>/dev/null } push_file() { # Write content to a temp file, push into LXC, remove temp local id=$1 dest=$2; shift 2 local tmp; tmp=$(mktemp /tmp/hnf-push-XXXX) cat > "$tmp" # reads stdin pct push "$id" "$tmp" "$dest" 2>/dev/null rm -f "$tmp" } push_dir() { # tar local dir → push tarball → extract in LXC at parent of dest local id=$1 src=$2 dest=$3 local tmp; tmp=$(mktemp /tmp/hnf-dir-XXXX.tar.gz) tar czf "$tmp" -C "$(dirname "$src")" "$(basename "$src")" 2>/dev/null pct push "$id" "$tmp" /tmp/hnf-deploy.tar.gz 2>/dev/null pct exec "$id" -- bash -c " mkdir -p '$(dirname "$dest")' tar xzf /tmp/hnf-deploy.tar.gz -C '$(dirname "$dest")' mv '$(dirname "$dest")/$(basename "$src")' '${dest}' 2>/dev/null || true rm -f /tmp/hnf-deploy.tar.gz " &>/dev/null rm -f "$tmp" } build_clone_url() { # Inject the Forgejo token into the clone URL so private repos work and the # updater can pull later without extra credentials. local repo=$1 local url="${FORGEJO_BASE}/${repo}.git" if [[ -n "${FORGEJO_TOKEN:-}" ]]; then url="${url/https:\/\//https://oauth2:${FORGEJO_TOKEN}@}" url="${url/http:\/\//http://oauth2:${FORGEJO_TOKEN}@}" fi echo "$url" } deploy_service() { # Either clone from Forgejo or push from a local repo copy on this host local id=$1 repo_name=$2 local_src=$3 dest=$4 if [[ "$USE_FORGEJO" == "true" ]]; then local url; url=$(build_clone_url "$repo_name") pct exec "$id" -- bash -c " if [ -d '${dest}/.git' ]; then cd '${dest}' && git pull -q; \ else git clone -q '${url}' '${dest}'; fi " &>/dev/null else push_dir "$id" "$local_src" "$dest" fi } wait_healthy() { local id=$1 url=$2 max=${3:-40} local i=0 while ! pct exec "$id" -- curl -sf --max-time 2 "$url" &>/dev/null; do sleep 3; ((i++)) [[ $i -ge $max ]] && return 1 done return 0 } wait_pg() { local max=30 i=0 while ! pct exec 100 -- bash -c \ "docker exec hnf-postgres pg_isready -U postgres" &>/dev/null; do sleep 3; ((i++)) [[ $i -ge $max ]] && { msg_warn "Postgres not ready after 90s"; return 1; } done } # ════════════════════════════════════════════════════════════════════════════ # PHASE 1 — POSTGRES LXC 100 # ════════════════════════════════════════════════════════════════════════════ deploy_postgres() { msg_step "1/6 Postgres (LXC 100 · 10.10.10.100)" msg_info "Creating LXC 100" create_lxc 100 "10.10.10.100" "postgres" 512 1 msg_ok "LXC 100 created" msg_info "Installing Docker" install_docker 100 msg_ok "Docker installed" msg_info "Deploying postgres" pct exec 100 -- mkdir -p /opt/postgres/init # docker-compose.yml push_file 100 /opt/postgres/docker-compose.yml <<'EOF' services: postgres: container_name: hnf-postgres image: postgres:16-alpine environment: - POSTGRES_USER=postgres - POSTGRES_PASSWORD=${PG_SUPERPASS} volumes: - pg_data:/var/lib/postgresql/data - ./init:/docker-entrypoint-initdb.d:ro ports: - "5432:5432" healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 5s retries: 15 restart: unless-stopped volumes: pg_data: EOF # .env push_file 100 /opt/postgres/.env </dev/null msg_info "Waiting for postgres to be ready" wait_pg && msg_ok "Postgres running at 10.10.10.100:5432" || msg_warn "Postgres may need extra time — check LXC 100" install_mgmt_key 100 } # ════════════════════════════════════════════════════════════════════════════ # PHASE 2 — AUTH SERVICE LXC 101 # ════════════════════════════════════════════════════════════════════════════ deploy_auth() { msg_step "2/6 Auth service (LXC 101 · 10.10.10.101)" msg_info "Creating LXC 101" create_lxc 101 "10.10.10.101" "auth" 512 1 msg_ok "LXC 101 created" msg_info "Installing Docker" install_docker 101 install_mgmt_key 101 msg_ok "Docker + SSH ready" msg_info "Deploying auth service" deploy_service 101 "auth" "${REPO_ROOT}/auth" /opt/auth push_file 101 /opt/auth/.env </dev/null msg_info "Waiting for auth service" wait_healthy 101 "http://localhost:3001/health" \ && msg_ok "Auth service running at 10.10.10.101:3001" \ || msg_warn "Auth service may need extra time — check LXC 101" } # ════════════════════════════════════════════════════════════════════════════ # PHASE 3 — PORTAL LXC 102 # ════════════════════════════════════════════════════════════════════════════ deploy_portal() { msg_step "3/6 Portal (LXC 102 · 10.10.10.102)" msg_info "Creating LXC 102" create_lxc 102 "10.10.10.102" "portal" 1024 2 msg_ok "LXC 102 created" msg_info "Installing Docker" install_docker 102 install_mgmt_key 102 msg_ok "Docker + SSH ready" msg_info "Deploying portal" deploy_service 102 "portal" "${REPO_ROOT}/portal" /opt/portal push_file 102 /opt/portal/.env </dev/null msg_info "Waiting for portal" wait_healthy 102 "http://localhost:3000/health" \ && msg_ok "Portal running at 10.10.10.102:3000" \ || msg_warn "Portal may need extra time — check LXC 102" } # ════════════════════════════════════════════════════════════════════════════ # PHASE 4 — NPM LXC 103 (dual-homed) # ════════════════════════════════════════════════════════════════════════════ deploy_npm() { msg_step "4/6 Nginx Proxy Manager (LXC 103 · ${NPM_LAN_IP} / 10.10.10.3)" msg_info "Creating NPM LXC 103 (dual-homed)" create_npm_lxc msg_ok "LXC 103 created" msg_info "Installing Docker" install_docker 103 msg_ok "Docker installed" msg_info "Deploying NPM" pct exec 103 -- mkdir -p /opt/npm push_file 103 /opt/npm/docker-compose.yml <<'EOF' services: npm: container_name: hnf-npm image: jc21/nginx-proxy-manager:latest ports: - "80:80" - "443:443" - "81:81" volumes: - npm_data:/data - npm_letsencrypt:/etc/letsencrypt restart: unless-stopped volumes: npm_data: npm_letsencrypt: EOF pct exec 103 -- bash -c "cd /opt/npm && docker compose up -d" &>/dev/null msg_info "Waiting for NPM admin UI" # NPM admin API on port 81 — wait up to 60s local i=0 while ! pct exec 103 -- curl -sf --max-time 3 "http://localhost:81/api/" &>/dev/null; do sleep 3; ((i++)); [[ $i -ge 20 ]] && break done msg_ok "NPM running — admin UI at http://${NPM_LAN_IP}:81" msg_warn "NPM default login: admin@example.com / changeme (change immediately!)" } # ════════════════════════════════════════════════════════════════════════════ # PHASE 5 — MANAGEMENT LXC 105 # ════════════════════════════════════════════════════════════════════════════ deploy_management() { msg_step "5/6 Management (LXC 105 · 10.10.10.105)" msg_info "Creating LXC 105" create_lxc 105 "10.10.10.105" "management" 512 1 msg_ok "LXC 105 created" msg_info "Installing Docker" install_docker 105 msg_ok "Docker installed" # Copy the management SSH private key into management container pct exec 105 -- mkdir -p /root/.ssh pct push 105 /root/.ssh/hnf_management /root/.ssh/hnf_management &>/dev/null pct exec 105 -- chmod 600 /root/.ssh/hnf_management msg_info "Deploying management stack" deploy_service 105 "management" "${REPO_ROOT}/management" /opt/management push_file 105 /opt/management/.env </dev/null msg_info "Waiting for Uptime Kuma" wait_healthy 105 "http://localhost:3002" \ && msg_ok "Management running — Kuma at 10.10.10.105:3002" \ || msg_warn "Management may need extra time — check LXC 105" } # ════════════════════════════════════════════════════════════════════════════ # PHASE 6 — NOTICEBOARD LXC 112 # ════════════════════════════════════════════════════════════════════════════ deploy_noticeboard() { msg_step "6/6 Noticeboard (LXC 112 · 10.10.10.112)" msg_info "Creating LXC 112" create_lxc 112 "10.10.10.112" "noticeboard" 512 1 msg_ok "LXC 112 created" msg_info "Installing Docker" install_docker 112 install_mgmt_key 112 msg_ok "Docker + SSH ready" msg_info "Deploying noticeboard" deploy_service 112 "noticeboard" "${REPO_ROOT}/noticeboard" /opt/noticeboard push_file 112 /opt/noticeboard/.env </dev/null msg_info "Waiting for noticeboard" wait_healthy 112 "http://localhost:3080/notices/health" \ && msg_ok "Noticeboard running at 10.10.10.112:3080" \ || msg_warn "Noticeboard may need extra time — check LXC 112" } # ════════════════════════════════════════════════════════════════════════════ # NPM PROXY HOSTS (via API) # ════════════════════════════════════════════════════════════════════════════ configure_npm_proxy_hosts() { msg_step "Configuring NPM proxy hosts" msg_info "Waiting for NPM API to be ready" local i=0 while ! curl -sf --max-time 3 "http://${NPM_LAN_IP}:81/api/" &>/dev/null; do sleep 3; ((i++)) [[ $i -ge 30 ]] && { msg_warn "NPM API not responding — configure proxy hosts manually"; return; } done # Get token with default credentials local token token=$(curl -sf -X POST "http://${NPM_LAN_IP}:81/api/tokens" \ -H "Content-Type: application/json" \ -d '{"identity":"admin@example.com","secret":"changeme"}' \ 2>/dev/null | grep -o '"token":"[^"]*"' | cut -d'"' -f4) || true if [[ -z "$token" ]]; then msg_warn "Could not get NPM token — proxy hosts must be created manually (see summary)" return fi msg_ok "NPM API authenticated" create_proxy_host() { local name=$1 forward_host=$2 forward_port=$3 locations_json=${4:-'[]'} curl -sf -X POST "http://${NPM_LAN_IP}:81/api/proxy-hosts" \ -H "Authorization: Bearer ${token}" \ -H "Content-Type: application/json" \ -d "{ \"domain_names\": [\"${DOMAIN}\"], \"forward_scheme\": \"http\", \"forward_host\": \"${forward_host}\", \"forward_port\": ${forward_port}, \"ssl_forced\": false, \"locations\": ${locations_json}, \"block_exploits\": true, \"allow_websocket_upgrade\": true, \"http2_support\": false, \"advanced_config\": \"proxy_set_header X-Real-IP \$remote_addr;\nproxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;\" }" &>/dev/null && echo "created" || echo "failed" } # Single proxy host for the domain routing everything through portal, # with custom locations per app path. # NPM "custom locations" feature handles path-based routing. local locations locations=$(cat <