#!/usr/bin/env bash # ┌─────────────────────────────────────────────────────────────────────────┐ # │ Hotel 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/hotel-manage-stack/stack-init/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' ╔══════════════════════════════════════════════════════════════╗ ║ HOTEL 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) # ── Internal network: bridge + NAT ──────────────────────────────────────────── # The isolated vmbr1 needs outbound NAT so LXCs can apt-install Docker, git # clone, and pull images. Persisted via an if-up.d hook so it survives reboot. ensure_nat() { local wan; wan=$(ip route show default 2>/dev/null | awk '{print $5; exit}'); wan=${wan:-vmbr0} msg_info "Enabling internal NAT (10.10.10.0/24 → ${wan})" sysctl -wq net.ipv4.ip_forward=1 2>/dev/null || echo 1 > /proc/sys/net/ipv4/ip_forward echo 'net.ipv4.ip_forward=1' > /etc/sysctl.d/99-hotel-manage.conf iptables -t nat -C POSTROUTING -s 10.10.10.0/24 ! -d 10.10.10.0/24 -o "$wan" -j MASQUERADE 2>/dev/null \ || iptables -t nat -A POSTROUTING -s 10.10.10.0/24 ! -d 10.10.10.0/24 -o "$wan" -j MASQUERADE cat > /etc/network/if-up.d/hotel-manage-nat <<'HOOK' #!/bin/sh [ "$IFACE" = "vmbr1" ] || exit 0 WAN=$(ip route show default | awk '{print $5; exit}'); WAN=${WAN:-vmbr0} sysctl -w net.ipv4.ip_forward=1 >/dev/null 2>&1 iptables -t nat -C POSTROUTING -s 10.10.10.0/24 ! -d 10.10.10.0/24 -o "$WAN" -j MASQUERADE 2>/dev/null \ || iptables -t nat -A POSTROUTING -s 10.10.10.0/24 ! -d 10.10.10.0/24 -o "$WAN" -j MASQUERADE HOOK chmod +x /etc/network/if-up.d/hotel-manage-nat msg_ok "Internal NAT enabled (via ${wan})" } check_vmbr1() { if ip link show vmbr1 &>/dev/null; then ensure_nat # bridge exists — make sure NAT/forwarding is in place return fi if whiptail --title "Create internal bridge vmbr1?" --yesno \ "The internal container bridge vmbr1 (10.10.10.1/24) doesn't exist yet. Create it now? This adds an ISOLATED bridge with no physical ports, so it cannot affect your LAN or vmbr0. It appends a stanza to /etc/network/interfaces, applies it with ifreload -a, and enables outbound NAT so the containers can reach the internet." 15 68; then if grep -qE '^\s*iface\s+vmbr1' /etc/network/interfaces 2>/dev/null; then msg_warn "vmbr1 already defined in /etc/network/interfaces — applying it" else cat >> /etc/network/interfaces <<'EOF' auto vmbr1 iface vmbr1 inet static address 10.10.10.1/24 bridge-ports none bridge-stp off bridge-fd 0 EOF fi msg_info "Bringing up vmbr1" ifreload -a &>/dev/null || systemctl restart networking &>/dev/null || true sleep 2 if ip link show vmbr1 &>/dev/null; then msg_ok "vmbr1 up (10.10.10.1/24)" else msg_error "vmbr1 still not up — check /etc/network/interfaces and re-run" fi ensure_nat else whiptail --title "vmbr1 Missing" --msgbox \ "Cancelled. Add this to /etc/network/interfaces, run 'ifreload -a', then re-run the installer: auto vmbr1 iface vmbr1 inet static address 10.10.10.1/24 bridge-ports none bridge-stp off bridge-fd 0" 16 66 exit 1 fi } # ── Ensure Ubuntu 22.04 template ───────────────────────────────────────────── ensure_template() { # NOTE: progress goes to stderr — stdout is captured as the template volid. local tmpl tmpl=$(pveam list local 2>/dev/null | awk '/ubuntu-22\.04-standard/{print $1; exit}') if [[ -z "$tmpl" ]]; then msg_info "Downloading Ubuntu 22.04 LXC template" >&2 pveam update &>/dev/null || true # Resolve the exact filename currently offered (pinned names go stale) local avail avail=$(pveam available --section system 2>/dev/null \ | awk '/ubuntu-22\.04-standard/{print $2}' | sort -V | tail -1) [[ -n "$avail" ]] || { msg_error "No ubuntu-22.04 template available via pveam" >&2; exit 1; } pveam download local "$avail" &>/dev/null || { msg_error "Template download failed" >&2; exit 1; } msg_ok "Template downloaded" >&2 tmpl="$avail" fi # Return full path for pct create echo "local:vztmpl/${tmpl##*/}" } # ── Collect site config ─────────────────────────────────────────────────────── collect_config() { SITE_NAME=$(whiptail --title "Hotel Manage — Site Config" \ --inputbox "Site name:" 8 52 "Hotel Number Four" 3>&1 1>&2 2>&3) || exit 0 DOMAIN=$(whiptail --title "Hotel Manage — 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 "Hotel Manage — 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 "Hotel Manage — Site Config" \ --inputbox "LAN gateway IP:" 8 52 "10.4.0.1" 3>&1 1>&2 2>&3) || exit 0 OFFICE_IP=$(whiptail --title "Hotel Manage — Site Config" \ --inputbox \ "Onsite matcher(s) for offsite restriction — comma-separated, any match = onsite. Each can be: an IP, a CIDR, a DDNS hostname, or 'auto' (self-detect the site's public IP — no DDNS service needed). 'disabled' allows access from anywhere. Recommended for a dynamic public IP: 10.4.0.0/22,auto (LAN clients match the CIDR; public-IP/hairpin clients match auto)" \ 13 72 "10.4.0.0/22,auto" 3>&1 1>&2 2>&3) || exit 0 ADMIN_EMAIL=$(whiptail --title "Hotel Manage — Admin Account" \ --inputbox "Admin user email:" 8 52 "" 3>&1 1>&2 2>&3) || exit 0 ADMIN_PASS=$(whiptail --title "Hotel Manage — 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 "Hotel Manage — 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/hotel-manage-stack" \ 13 66 "https://git.pterois.co.uk/hotel-manage-stack" 3>&1 1>&2 2>&3) || exit 0 FORGEJO_BASE="${FORGEJO_BASE%/}" FORGEJO_TOKEN=$(whiptail --title "Hotel Manage — 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 "Hotel Manage — 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 "Hotel Manage — Backup" \ --inputbox \ "Backup rsync target (leave blank to skip backup config). Example: backup@192.168.1.10:/backups/hotel-manage" \ 10 64 "" 3>&1 1>&2 2>&3) || exit 0 # Confirm LXC allocation whiptail --title "Hotel Manage — Confirm" --yesno \ "LXCs to be created (storage: ${STORAGE}): ID Hostname IP ────────────────────────────────────────── 100 hotel-manage-postgres 10.10.10.100 101 hotel-manage-auth 10.10.10.101 102 hotel-manage-portal 10.10.10.102 103 hotel-manage-npm 10.10.10.103 / ${NPM_LAN_IP} (dual-homed) 105 hotel-manage-management 10.10.10.105 112 hotel-manage-noticeboard 10.10.10.112 Domain: ${DOMAIN} Admin: ${ADMIN_EMAIL} Proceed?" 24 58 || exit 0 } # ── Generate secrets ────────────────────────────────────────────────────────── CREDS_FILE=/root/hotel-manage-credentials.txt gen_secrets() { # Resume-safe: if secrets already exist, reuse them so a re-run doesn't # mismatch an already-initialised Postgres / already-deployed services. if [[ -f "$CREDS_FILE" ]] && grep -q '^PG_SUPERPASS=' "$CREDS_FILE"; then msg_info "Reusing existing secrets from $CREDS_FILE" PG_SUPERPASS=$(sed -n 's/^PG_SUPERPASS=//p' "$CREDS_FILE" | head -1) AUTH_DB_PASS=$(sed -n 's/^AUTH_DB_PASS=//p' "$CREDS_FILE" | head -1) NOTICES_DB_PASS=$(sed -n 's/^NOTICES_DB_PASS=//p' "$CREDS_FILE" | head -1) CENTRAL_AUTH_SECRET=$(sed -n 's/^CENTRAL_AUTH_SECRET=//p' "$CREDS_FILE" | head -1) WEBHOOK_SECRET=$(sed -n 's/^WEBHOOK_SECRET=//p' "$CREDS_FILE" | head -1) NPM_ADMIN_PASS=$(sed -n 's/^NPM_ADMIN_PASS=//p' "$CREDS_FILE" | head -1) msg_ok "Reusing existing secrets" return fi 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 > "$CREDS_FILE" </dev/null msg_ok "SSH keypair generated → /root/.ssh/hotel-manage_deploy" else msg_ok "Using existing SSH keypair at /root/.ssh/hotel-manage_deploy" fi MGMT_PUBKEY=$(cat /root/.ssh/hotel-manage_deploy.pub) # record the pubkey once (don't duplicate on re-run) grep -qF "${MGMT_PUBKEY}" "$CREDS_FILE" 2>/dev/null || echo "${MGMT_PUBKEY}" >> "$CREDS_FILE" } # ── 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" } # Group the stack in a Proxmox resource pool + tag for tidy UI organisation. POOL="hotel-manage" POOL_OPT="" ensure_pool() { if ! pvesh get "/pools/${POOL}" &>/dev/null; then msg_info "Creating Proxmox resource pool '${POOL}'" pvesh create /pools --poolid "${POOL}" --comment "Hotel Manage stack" &>/dev/null || true pvesh get "/pools/${POOL}" &>/dev/null && msg_ok "Pool '${POOL}' ready" \ || msg_warn "Could not create pool '${POOL}' — continuing without it" fi # Only pass --pool if it actually exists (else pct create would fail) pvesh get "/pools/${POOL}" &>/dev/null && POOL_OPT="--pool ${POOL}" } # Docker-in-LXC needs the container to run AppArmor-unconfined, otherwise # containers fail with "docker-default profile could not be loaded ... while # confined". Idempotent: adds the line if missing, (re)starts only when needed. apply_docker_lxc_conf() { local id=$1 created=$2 local conf="/etc/pve/lxc/${id}.conf" changed=0 if ! grep -q '^lxc.apparmor.profile: unconfined' "$conf" 2>/dev/null; then echo "lxc.apparmor.profile: unconfined" >> "$conf" changed=1 fi if [[ "$created" == 1 ]]; then pct start "$id" &>/dev/null; sleep 5 elif [[ "$changed" == 1 ]]; then pct stop "$id" &>/dev/null || true; pct start "$id" &>/dev/null; sleep 5 elif ! lxc_running "$id"; then pct start "$id" &>/dev/null; sleep 5 fi } create_lxc() { local id=$1 ip=$2 name=$3 mem=${4:-512} cores=${5:-1} created=0 if lxc_exists "$id"; then msg_warn "LXC $id (hotel-manage-${name}) already exists — skipping creation" else local tmpl; tmpl=$(get_template) pct create "$id" "$tmpl" \ --hostname "hotel-manage-${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 \ --tags "${POOL}" \ ${POOL_OPT} &>/dev/null created=1 fi apply_docker_lxc_conf "$id" "$created" } create_npm_lxc() { local id=103 created=0 if lxc_exists "$id"; then msg_warn "LXC $id (hotel-manage-npm) already exists — skipping creation" else local tmpl; tmpl=$(get_template) pct create "$id" "$tmpl" \ --hostname "hotel-manage-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 \ --tags "${POOL}" \ ${POOL_OPT} &>/dev/null created=1 fi apply_docker_lxc_conf "$id" "$created" } 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/hotel-manage-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/hotel-manage-dir-XXXX.tar.gz) tar czf "$tmp" -C "$(dirname "$src")" "$(basename "$src")" 2>/dev/null pct push "$id" "$tmp" /tmp/hotel-manage-deploy.tar.gz 2>/dev/null pct exec "$id" -- bash -c " mkdir -p '$(dirname "$dest")' tar xzf /tmp/hotel-manage-deploy.tar.gz -C '$(dirname "$dest")' mv '$(dirname "$dest")/$(basename "$src")' '${dest}' 2>/dev/null || true rm -f /tmp/hotel-manage-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 hotel-manage-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: hotel-manage-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: hotel-manage-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/hotel-manage_deploy /root/.ssh/hotel-manage_deploy &>/dev/null pct exec 105 -- chmod 600 /root/.ssh/hotel-manage_deploy 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 <