#!/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 · settings ║ ╚══════════════════════════════════════════════════════════════╝ 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 116 hotel-manage-settings 10.10.10.116 Domain: ${DOMAIN} Admin: ${ADMIN_EMAIL} Proceed?" 26 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) SETTINGS_DB_PASS=$(sed -n 's/^SETTINGS_DB_PASS=//p' "$CREDS_FILE" | head -1) CASHUP_DB_PASS=$(sed -n 's/^CASHUP_DB_PASS=//p' "$CREDS_FILE" | head -1) HK_PLANNER_DB_PASS=$(sed -n 's/^HK_PLANNER_DB_PASS=//p' "$CREDS_FILE" | head -1) TWIN_OPT_DB_PASS=$(sed -n 's/^TWIN_OPT_DB_PASS=//p' "$CREDS_FILE" | head -1) ROOM_PLANNER_DB_PASS=$(sed -n 's/^ROOM_PLANNER_DB_PASS=//p' "$CREDS_FILE" | head -1) CENTRAL_AUTH_SECRET=$(sed -n 's/^CENTRAL_AUTH_SECRET=//p' "$CREDS_FILE" | head -1) SETTINGS_SECRET=$(sed -n 's/^SETTINGS_SECRET=//p' "$CREDS_FILE" | head -1) WEBHOOK_SECRET=$(sed -n 's/^WEBHOOK_SECRET=//p' "$CREDS_FILE" | head -1) NPM_LAN_IP=$(sed -n 's/^NPM_LAN_IP=//p' "$CREDS_FILE" | head -1) NPM_ADMIN_EMAIL=$(sed -n 's/^NPM_ADMIN_EMAIL=//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) SETTINGS_DB_PASS=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24) CASHUP_DB_PASS=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24) HK_PLANNER_DB_PASS=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24) TWIN_OPT_DB_PASS=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24) ROOM_PLANNER_DB_PASS=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24) CENTRAL_AUTH_SECRET=$(openssl rand -hex 32) SETTINGS_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: keep the nesting=1 feature (needed by Docker) and let each # container skip AppArmor via `security_opt: apparmor=unconfined` in its compose. # (Overriding lxc.apparmor.profile would cancel nesting — don't do that.) create_lxc() { local id=$1 ip=$2 name=$3 mem=${4:-512} cores=${5:-1} if lxc_exists "$id"; then msg_warn "LXC $id (hotel-manage-${name}) already exists — skipping creation" lxc_running "$id" || pct start "$id" return fi 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} \ --start 1 &>/dev/null sleep 5 } create_npm_lxc() { local id=103 if lxc_exists "$id"; then msg_warn "LXC $id (hotel-manage-npm) already exists — skipping creation" lxc_running "$id" || pct start "$id" return fi 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} \ --start 1 &>/dev/null sleep 5 } install_docker() { local id=$1 pct exec "$id" -- bash -s &>/dev/null <<'DOCKER_INSTALL' export DEBIAN_FRONTEND=noninteractive # Docker-in-LXC: neutralise AppArmor so Docker never tries to load a profile. # In a confined LXC that fails ("docker-default ... while confined") for BOTH # image builds and container runtime. Removing apparmor_parser makes Docker # run everything unconfined — the container itself is the isolation boundary. if [ -e /usr/sbin/apparmor_parser ]; then mv -f /usr/sbin/apparmor_parser /usr/sbin/apparmor_parser.disabled systemctl is-active --quiet docker && systemctl restart docker fi # Idempotent: if Docker's already here, just make sure it's running and bail. if command -v docker >/dev/null 2>&1; then systemctl enable --now docker ssh 2>/dev/null exit 0 fi 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 rm -f /etc/apt/keyrings/docker.gpg curl -fsSL https://download.docker.com/linux/ubuntu/gpg \ | gpg --batch --yes --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") local tmp; tmp=$(mktemp /tmp/hotel-manage-deploy-out-XXXX) if ! pct exec "$id" -- bash -c " if [ -d '${dest}/.git' ]; then cd '${dest}' && git pull else git clone '${url}' '${dest}' fi " > "$tmp" 2>&1; then local out; out=$(cat "$tmp"); rm -f "$tmp" msg_error "Deploy of ${repo_name} to LXC ${id} failed: ${out} Debug: pct enter ${id} && git clone ${FORGEJO_BASE}/${repo_name}.git /tmp/test-clone" fi rm -f "$tmp" 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+1)) [[ $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+1)) [[ $i -ge $max ]] && { msg_warn "Postgres not ready after 90s"; return 1; } done } # ════════════════════════════════════════════════════════════════════════════ # PHASE 1 — POSTGRES LXC 100 # ════════════════════════════════════════════════════════════════════════════ deploy_postgres() { msg_step "1/7 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 security_opt: - apparmor=unconfined 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/7 Auth service (LXC 101 · 10.10.10.101)" msg_info "Creating LXC 101" create_lxc 101 "10.10.10.101" "auth" 1024 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/7 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/7 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 security_opt: - apparmor=unconfined 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+1)); [[ $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/7 Management (LXC 105 · 10.10.10.105)" msg_info "Creating LXC 105" create_lxc 105 "10.10.10.105" "management" 1024 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/7 Noticeboard (LXC 112 · 10.10.10.112)" msg_info "Creating LXC 112" create_lxc 112 "10.10.10.112" "noticeboard" 1024 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" } # ════════════════════════════════════════════════════════════════════════════ # PHASE 7 — SETTINGS SERVICE LXC 116 # ════════════════════════════════════════════════════════════════════════════ deploy_settings() { msg_step "7/7 Settings service (LXC 116 · 10.10.10.116)" if [[ -z "${SETTINGS_DB_PASS:-}" ]]; then SETTINGS_DB_PASS=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24) printf '\nSETTINGS_DB_PASS=%s\n' "$SETTINGS_DB_PASS" >> "$CREDS_FILE" msg_ok "Generated SETTINGS_DB_PASS → ${CREDS_FILE}" fi if [[ -z "${SETTINGS_SECRET:-}" ]]; then SETTINGS_SECRET=$(openssl rand -hex 32) printf 'SETTINGS_SECRET=%s\n' "$SETTINGS_SECRET" >> "$CREDS_FILE" msg_ok "Generated SETTINGS_SECRET → ${CREDS_FILE}" fi msg_info "Creating LXC 116" create_lxc 116 "10.10.10.116" "settings" 512 1 msg_ok "LXC 116 created" msg_info "Installing Docker" install_docker 116 install_mgmt_key 116 msg_ok "Docker + SSH ready" msg_info "Deploying settings service" deploy_service 116 "settings" "${REPO_ROOT}/settings" /opt/settings push_file 116 /opt/settings/.env </dev/null msg_info "Waiting for settings service" wait_healthy 116 "http://localhost:3080/health" \ && msg_ok "Settings service running at 10.10.10.116:3080" \ || msg_warn "Settings may need extra time — check LXC 116" } # ════════════════════════════════════════════════════════════════════════════ # PHASE 8 — CASHUP LXC 117 # ════════════════════════════════════════════════════════════════════════════ deploy_cashup() { msg_step "8/8 Cashup (LXC 117 · 10.10.10.117)" # Generate DB password if not already in creds file (--only cashup on existing stack) if [[ -z "${CASHUP_DB_PASS:-}" ]]; then CASHUP_DB_PASS=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24) printf '\nCASHUP_DB_PASS=%s\n' "$CASHUP_DB_PASS" >> "$CREDS_FILE" msg_ok "Generated CASHUP_DB_PASS → ${CREDS_FILE}" fi msg_info "Creating LXC 117" create_lxc 117 "10.10.10.117" "cashup" 1024 1 msg_ok "LXC 117 created" msg_info "Installing Docker" install_docker 117 install_mgmt_key 117 msg_ok "Docker + SSH ready" # Create DB — idempotent via || true, works on fresh installs and re-runs msg_info "Creating cashup database" pct exec 100 -- bash -c " docker exec hotel-manage-postgres psql -U postgres -c \ \"CREATE USER cashup WITH PASSWORD '${CASHUP_DB_PASS}';\" 2>/dev/null || true docker exec hotel-manage-postgres psql -U postgres -c \ \"CREATE DATABASE cashup_db OWNER cashup;\" 2>/dev/null || true docker exec hotel-manage-postgres psql -U postgres -d cashup_db -c \ \"GRANT ALL ON SCHEMA public TO cashup;\" 2>/dev/null || true " &>/dev/null msg_ok "Database cashup_db ready" msg_info "Deploying cashup" deploy_service 117 "cashup" "${REPO_ROOT}/cashup" /opt/cashup push_file 117 /opt/cashup/.env <&1"); then msg_error "docker compose build failed in LXC 117: ${build_out}" fi msg_info "Waiting for cashup" wait_healthy 117 "http://localhost:3083/cashup/health" \ && msg_ok "Cashup running at 10.10.10.117:3083" \ || msg_warn "Cashup may need extra time — check LXC 117" # Add /cashup/ location to the existing NPM proxy host (idempotent) npm_add_location "/cashup/" "10.10.10.117" 3083 } # ════════════════════════════════════════════════════════════════════════════ # PHASE 9 — HK PLANNER LXC 118 # ════════════════════════════════════════════════════════════════════════════ deploy_hk_planner() { msg_step "9/10 HK Planner (LXC 118 · 10.10.10.118)" if [[ -z "${HK_PLANNER_DB_PASS:-}" ]]; then HK_PLANNER_DB_PASS=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24) printf '\nHK_PLANNER_DB_PASS=%s\n' "$HK_PLANNER_DB_PASS" >> "$CREDS_FILE" msg_ok "Generated HK_PLANNER_DB_PASS → ${CREDS_FILE}" fi msg_info "Creating LXC 118" create_lxc 118 "10.10.10.118" "hk-planner" 1024 1 msg_ok "LXC 118 created" msg_info "Installing Docker" install_docker 118 install_mgmt_key 118 msg_ok "Docker + SSH ready" msg_info "Creating hk_planner database" pct exec 100 -- bash -c " docker exec hotel-manage-postgres psql -U postgres -c \ \"CREATE USER hk_planner WITH PASSWORD '${HK_PLANNER_DB_PASS}';\" 2>/dev/null || true docker exec hotel-manage-postgres psql -U postgres -c \ \"CREATE DATABASE hk_planner_db OWNER hk_planner;\" 2>/dev/null || true docker exec hotel-manage-postgres psql -U postgres -d hk_planner_db -c \ \"GRANT ALL ON SCHEMA public TO hk_planner;\" 2>/dev/null || true " &>/dev/null msg_ok "Database hk_planner_db ready" msg_info "Deploying hk-planner" deploy_service 118 "hk-planner" "${REPO_ROOT}/hk-planner" /opt/hk-planner push_file 118 /opt/hk-planner/.env <&1"); then msg_error "docker compose build failed in LXC 118: ${build_out}" fi msg_info "Waiting for hk-planner" wait_healthy 118 "http://localhost:3080/hk-planner/health" \ && msg_ok "HK Planner running at 10.10.10.118:3080" \ || msg_warn "HK Planner may need extra time — check LXC 118" # Add /hk-planner/ location to NPM proxy host npm_add_location "/hk-planner/" "10.10.10.118" 3080 } deploy_twin_optimiser() { msg_step "10/10 Twin Optimiser (LXC 119 · 10.10.10.119)" if [[ -z "${TWIN_OPT_DB_PASS:-}" ]]; then TWIN_OPT_DB_PASS=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24) printf '\nTWIN_OPT_DB_PASS=%s\n' "$TWIN_OPT_DB_PASS" >> "$CREDS_FILE" msg_ok "Generated TWIN_OPT_DB_PASS → ${CREDS_FILE}" fi msg_info "Creating LXC 119" create_lxc 119 "10.10.10.119" "twin-optimiser" 1024 1 msg_ok "LXC 119 created" msg_info "Installing Docker" install_docker 119 install_mgmt_key 119 msg_ok "Docker + SSH ready" msg_info "Creating twin_optimiser database" pct exec 100 -- bash -c " docker exec hotel-manage-postgres psql -U postgres -c \ \"CREATE USER twin_optimiser WITH PASSWORD '${TWIN_OPT_DB_PASS}';\" 2>/dev/null || true docker exec hotel-manage-postgres psql -U postgres -c \ \"CREATE DATABASE twin_optimiser_db OWNER twin_optimiser;\" 2>/dev/null || true docker exec hotel-manage-postgres psql -U postgres -d twin_optimiser_db -c \ \"GRANT ALL ON SCHEMA public TO twin_optimiser;\" 2>/dev/null || true " &>/dev/null msg_ok "Database twin_optimiser_db ready" msg_info "Deploying twin-optimiser" deploy_service 119 "twin-optimiser" "${REPO_ROOT}/twin-optimiser" /opt/twin-optimiser push_file 119 /opt/twin-optimiser/.env <&1"); then msg_error "docker compose build failed in LXC 119: ${build_out}" fi msg_info "Waiting for twin-optimiser" wait_healthy 119 "http://localhost:3080/twin-optimiser/health" \ && msg_ok "Twin Optimiser running at 10.10.10.119:3080" \ || msg_warn "Twin Optimiser may need extra time — check LXC 119" # Add /twin-optimiser/ location to NPM proxy host npm_add_location "/twin-optimiser/" "10.10.10.119" 3080 } # ════════════════════════════════════════════════════════════════════════════ # PHASE 11 — ROOM PLANNER LXC 120 # ════════════════════════════════════════════════════════════════════════════ deploy_room_planner() { msg_step "11/11 Room Planner (LXC 120 · 10.10.10.120)" if [[ -z "${ROOM_PLANNER_DB_PASS:-}" ]]; then ROOM_PLANNER_DB_PASS=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24) printf '\nROOM_PLANNER_DB_PASS=%s\n' "$ROOM_PLANNER_DB_PASS" >> "$CREDS_FILE" msg_ok "Generated ROOM_PLANNER_DB_PASS → ${CREDS_FILE}" fi msg_info "Creating LXC 120" create_lxc 120 "10.10.10.120" "room-planner" 1024 1 msg_ok "LXC 120 created" msg_info "Installing Docker" install_docker 120 install_mgmt_key 120 msg_ok "Docker + SSH ready" msg_info "Creating room_planner database" pct exec 100 -- bash -c " docker exec hotel-manage-postgres psql -U postgres -c \ \"CREATE USER room_planner WITH PASSWORD '${ROOM_PLANNER_DB_PASS}';\" 2>/dev/null || true docker exec hotel-manage-postgres psql -U postgres -c \ \"CREATE DATABASE room_planner_db OWNER room_planner;\" 2>/dev/null || true docker exec hotel-manage-postgres psql -U postgres -d room_planner_db -c \ \"GRANT ALL ON SCHEMA public TO room_planner;\" 2>/dev/null || true " &>/dev/null msg_ok "Database room_planner_db ready" msg_info "Deploying room-planner" deploy_service 120 "room-planner" "${REPO_ROOT}/room-planner" /opt/room-planner push_file 120 /opt/room-planner/.env <&1"); then msg_error "docker compose build failed in LXC 120: ${build_out}" fi msg_info "Waiting for room-planner" wait_healthy 120 "http://localhost:3080/room-planner/health" \ && msg_ok "Room Planner running at 10.10.10.120:3080" \ || msg_warn "Room Planner may need extra time — check LXC 120" # Seed app + capabilities into auth DB (psql on LXC 100 — same pattern as add-app.sh) msg_info "Seeding room-planner into auth DB" pct exec 100 -- docker exec hotel-manage-postgres psql -U postgres -d auth_db -c " INSERT INTO apps (slug, name, description, base_path, icon, theme_color, category, internal_host, internal_port) VALUES ('room-planner', 'Room Planner', 'Daily housekeeping room view with NewBook task management', '/room-planner', 'BedDouble', '#2d6a4f', 'Housekeeping', '10.10.10.120', 3080) ON CONFLICT (slug) DO UPDATE SET name=EXCLUDED.name, description=EXCLUDED.description, base_path=EXCLUDED.base_path, icon=EXCLUDED.icon, theme_color=EXCLUDED.theme_color, category=EXCLUDED.category, internal_host=EXCLUDED.internal_host, internal_port=EXCLUDED.internal_port; INSERT INTO app_capabilities (app_id, slug, name, description, sort_order) SELECT a.id, c.slug, c.name, c.description, c.sort_order FROM apps a, (VALUES ('view','View Planner','Access the room planner view',1), ('guest_details','Guest Details','View guest names and personal information',2), ('rate_details','Rate Details','View pricing and rate plan information',3), ('view_all_notes','View All Notes','View all booking note types',4), ('complete_tasks','Complete Tasks','Mark NewBook tasks as complete',5), ('update_status','Update Room Status','Mark rooms clean, dirty or inspected',6), ('settings','Settings','Configure room planner settings',7) ) AS c(slug, name, description, sort_order) WHERE a.slug = 'room-planner' ON CONFLICT (app_id, slug) DO NOTHING; " &>/dev/null \ && msg_ok "room-planner seeded into auth DB" \ || msg_warn "Seed failed — run the SQL in room-planner/seed-app.js manually via psql on LXC 100" # Add /room-planner/ location to NPM proxy host npm_add_location "/room-planner/" "10.10.10.120" 3080 } deploy_maintenance() { msg_step "12/12 Maintenance (LXC 121 · 10.10.10.121)" if [[ -z "${MAINTENANCE_DB_PASS:-}" ]]; then MAINTENANCE_DB_PASS=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24) printf '\nMAINTENANCE_DB_PASS=%s\n' "$MAINTENANCE_DB_PASS" >> "$CREDS_FILE" msg_ok "Generated MAINTENANCE_DB_PASS → ${CREDS_FILE}" fi msg_info "Creating LXC 121" create_lxc 121 "10.10.10.121" "maintenance" 1024 1 msg_ok "LXC 121 created" msg_info "Installing Docker" install_docker 121 install_mgmt_key 121 msg_ok "Docker + SSH ready" msg_info "Creating maintenance database" pct exec 100 -- bash -c " docker exec hotel-manage-postgres psql -U postgres -c \ \"CREATE USER maintenance WITH PASSWORD '${MAINTENANCE_DB_PASS}';\" 2>/dev/null || true docker exec hotel-manage-postgres psql -U postgres -c \ \"CREATE DATABASE maintenance_db OWNER maintenance;\" 2>/dev/null || true docker exec hotel-manage-postgres psql -U postgres -d maintenance_db -c \ \"GRANT ALL ON SCHEMA public TO maintenance;\" 2>/dev/null || true " &>/dev/null msg_ok "Database maintenance_db ready" msg_info "Deploying maintenance" deploy_service 121 "maintenance" "${REPO_ROOT}/maintenance" /opt/maintenance push_file 121 /opt/maintenance/.env <&1"); then msg_error "docker compose build failed in LXC 121: ${build_out}" fi msg_info "Waiting for maintenance" wait_healthy 121 "http://localhost:3080/maintenance/health" \ && msg_ok "Maintenance running at 10.10.10.121:3080" \ || msg_warn "Maintenance may need extra time — check LXC 121" # Seed app + capabilities into auth DB (psql on LXC 100 — same pattern as add-app.sh) msg_info "Seeding maintenance into auth DB" pct exec 100 -- docker exec hotel-manage-postgres psql -U postgres -d auth_db -c " INSERT INTO apps (slug, name, description, base_path, icon, theme_color, category, internal_host, internal_port) VALUES ('maintenance', 'Maintenance', 'Maintenance log book — faults, recurring tasks, assets and contractors', '/maintenance', 'Wrench', '#b45309', 'Operations', '10.10.10.121', 3080) ON CONFLICT (slug) DO UPDATE SET name=EXCLUDED.name, description=EXCLUDED.description, base_path=EXCLUDED.base_path, icon=EXCLUDED.icon, theme_color=EXCLUDED.theme_color, category=EXCLUDED.category, internal_host=EXCLUDED.internal_host, internal_port=EXCLUDED.internal_port; INSERT INTO app_capabilities (app_id, slug, name, description, sort_order) SELECT a.id, c.slug, c.name, c.description, c.sort_order FROM apps a, (VALUES ('view','View Tasks','View the maintenance log and history',1), ('report','Report Faults','Create tasks, add photos and comments',2), ('update','Update Tasks','Change task state, reassign, edit details',3), ('resolve','Resolve Tasks','Mark tasks temporary fixed or fixed',4), ('costs','View Costs','See and enter repair cost values',5), ('manage_locations','Manage Locations','Manage locations, categories and NewBook sync',6), ('manage_assets','Manage Assets','Create and edit the asset register',7), ('manage_contractors','Manage Contractors','Manage contractors and their documents',8), ('manage_templates','Manage Recurring','Create and edit recurring task templates',9), ('settings','Settings','Configure maintenance app settings',10) ) AS c(slug, name, description, sort_order) WHERE a.slug = 'maintenance' ON CONFLICT (app_id, slug) DO NOTHING; " &>/dev/null \ && msg_ok "maintenance seeded into auth DB" \ || msg_warn "Seed failed — run maintenance/seed-app.js manually via psql on LXC 100" # Add /maintenance/ location to NPM proxy host npm_add_location "/maintenance/" "10.10.10.121" 3080 } deploy_forecasting() { msg_step "Forecasting (LXC 113 · 10.10.10.113)" if [[ -z "${FORECASTING_DB_PASS:-}" ]]; then FORECASTING_DB_PASS=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24) printf '\nFORECASTING_DB_PASS=%s\n' "$FORECASTING_DB_PASS" >> "$CREDS_FILE" msg_ok "Generated FORECASTING_DB_PASS → ${CREDS_FILE}" fi # Forecasting needs more RAM (ML models) and more disk (Chromium, Prophet/CmdStan) msg_info "Creating LXC 113 (4 GB RAM, 2 cores)" create_lxc 113 "10.10.10.113" "forecasting" 4096 2 pct resize 113 rootfs 20G &>/dev/null || true msg_ok "LXC 113 created" msg_info "Installing Docker" install_docker 113 install_mgmt_key 113 msg_ok "Docker + SSH ready" msg_info "Creating forecasting database" pct exec 100 -- bash -c " docker exec hotel-manage-postgres psql -U postgres -c \ \"CREATE USER forecasting WITH PASSWORD '${FORECASTING_DB_PASS}';\" 2>/dev/null || true docker exec hotel-manage-postgres psql -U postgres -c \ \"CREATE DATABASE forecasting_db OWNER forecasting;\" 2>/dev/null || true docker exec hotel-manage-postgres psql -U postgres -d forecasting_db -c \ \"GRANT ALL ON SCHEMA public TO forecasting;\" 2>/dev/null || true " &>/dev/null msg_ok "Database forecasting_db ready (schema applied by Python backend on first start)" msg_info "Deploying forecasting" deploy_service 113 "forecasting" "${REPO_ROOT}/forecasting" /opt/forecasting # NewBook credentials come from the central Settings service (SETTINGS_URL). # RESOS_API_KEY is configured via the app's Settings page after first deploy. # ANTHROPIC_API_KEY is optional — enables the AI Dashboard feature. push_file 113 /opt/forecasting/.env <&1"); then msg_error "docker compose build failed in LXC 113: ${build_out}" fi msg_info "Waiting for forecasting (up to 10 min)" wait_healthy 113 "http://localhost:3080/forecasting/health" 200 \ && msg_ok "Forecasting running at 10.10.10.113:3080" \ || msg_warn "Forecasting may need extra time — check LXC 113 logs" # Seed app + capabilities into auth DB msg_info "Seeding forecasting into auth DB" pct exec 100 -- docker exec hotel-manage-postgres psql -U postgres -d auth_db -c " INSERT INTO apps (slug, name, description, base_path, icon, theme_color, category, internal_host, internal_port) VALUES ('forecasting', 'Forecasting', 'Revenue forecasting and reporting', '/forecasting', 'TrendingUp', '#0077b6', 'Finance', '10.10.10.113', 3080) ON CONFLICT (slug) DO UPDATE SET name=EXCLUDED.name, description=EXCLUDED.description, base_path=EXCLUDED.base_path, icon=EXCLUDED.icon, theme_color=EXCLUDED.theme_color, category=EXCLUDED.category, internal_host=EXCLUDED.internal_host, internal_port=EXCLUDED.internal_port; INSERT INTO app_capabilities (app_id, slug, name, description, sort_order) SELECT a.id, c.slug, c.name, c.description, c.sort_order FROM apps a, (VALUES ('view','View Forecasts','View dashboard, forecasts, and history',1), ('view_bookability','View Bookability','View the rate / bookability matrix',2), ('view_competitor_rates','View Competitors','View competitor rate scraping and matrix',3), ('view_accuracy','View Accuracy','View model accuracy metrics and backtesting',4), ('manage_sync','Manage Data Sync','Trigger Newbook / Resos data syncs manually',5), ('settings','Manage Settings','Configure Newbook credentials and sync schedules',6) ) AS c(slug, name, description, sort_order) WHERE a.slug = 'forecasting' ON CONFLICT (app_id, slug) DO NOTHING; " &>/dev/null \ && msg_ok "forecasting seeded into auth DB" \ || msg_warn "Seed failed — run forecasting/seed-app.js manually" npm_add_location "/forecasting/" "10.10.10.113" 3080 } deploy_rates() { msg_step "Rate Monitor (LXC 115 · 10.10.10.115)" if [[ -z "${RATES_DB_PASS:-}" ]]; then RATES_DB_PASS=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24) printf '\nRATES_DB_PASS=%s\n' "$RATES_DB_PASS" >> "$CREDS_FILE" msg_ok "Generated RATES_DB_PASS → ${CREDS_FILE}" fi if [[ -z "${RATES_SECRET:-}" ]]; then RATES_SECRET=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 32) printf '\nRATES_SECRET=%s\n' "$RATES_SECRET" >> "$CREDS_FILE" msg_ok "Generated RATES_SECRET → ${CREDS_FILE}" fi msg_info "Creating LXC 115 (2 GB RAM, 2 cores)" create_lxc 115 "10.10.10.115" "rates" 2048 2 msg_ok "LXC 115 created" msg_info "Installing Docker" install_docker 115 install_mgmt_key 115 msg_ok "Docker + SSH ready" msg_info "Creating rates database" pct exec 100 -- bash -c " docker exec hotel-manage-postgres psql -U postgres -c \ \"CREATE USER rates WITH PASSWORD '${RATES_DB_PASS}';\" 2>/dev/null || true docker exec hotel-manage-postgres psql -U postgres -c \ \"CREATE DATABASE rates_db OWNER rates;\" 2>/dev/null || true docker exec hotel-manage-postgres psql -U postgres -d rates_db -c \ \"GRANT ALL ON SCHEMA public TO rates;\" 2>/dev/null || true " &>/dev/null msg_ok "Database rates_db ready (schema applied by Python backend on first start)" msg_info "Deploying rates" deploy_service 115 "rates" "${REPO_ROOT}/rates" /opt/rates push_file 115 /opt/rates/.env <&1"); then msg_error "docker compose build failed in LXC 115: ${build_out}" fi msg_info "Waiting for rates" wait_healthy 115 "http://localhost:3080/rates/health" 200 \ && msg_ok "Rate Monitor running at 10.10.10.115:3080" \ || msg_warn "Rate Monitor may need extra time — check LXC 115 logs" # Seed app + capabilities into auth DB msg_info "Seeding rates into auth DB" pct exec 100 -- docker exec hotel-manage-postgres psql -U postgres -d auth_db -c " INSERT INTO apps (slug, name, description, base_path, icon, theme_color, category, internal_host, internal_port) VALUES ('rates', 'Rate Monitor', 'Competitor rate monitoring and direct booking engine rates', '/rates', 'Tag', '#7b4f00', 'Finance', '10.10.10.115', 3080) ON CONFLICT (slug) DO UPDATE SET name=EXCLUDED.name, description=EXCLUDED.description, base_path=EXCLUDED.base_path, icon=EXCLUDED.icon, theme_color=EXCLUDED.theme_color, category=EXCLUDED.category, internal_host=EXCLUDED.internal_host, internal_port=EXCLUDED.internal_port; INSERT INTO app_capabilities (app_id, slug, name, description, sort_order) SELECT a.id, c.slug, c.name, c.description, c.sort_order FROM apps a, (VALUES ('view_own_rates','View Bookability','View own hotel rate and tariff availability',1), ('view_competitors','View Market View','View competitor Booking.com rates',2), ('view_direct_rates','View Direct Rates','View competitor direct booking engine rates',3), ('rate_analysis','Rate Analysis','Drill into competitor pricing structure',4), ('manage_scraper','Manage Scraper','Configure scraper, trigger manual scrapes',5), ('manage_hotels','Manage Competitors','Classify and configure competitor hotels',6) ) AS c(slug, name, description, sort_order) WHERE a.slug = 'rates' ON CONFLICT (app_id, slug) DO NOTHING; " &>/dev/null \ && msg_ok "rates seeded into auth DB" \ || msg_warn "Seed failed — run auth db.js manually" npm_add_location "/rates/" "10.10.10.115" 3080 } # ════════════════════════════════════════════════════════════════════════════ # NPM PROXY HOSTS (via API) # ════════════════════════════════════════════════════════════════════════════ # Returns a Bearer token for the NPM API, or empty string on failure. # Robust against a messy creds file: tries the currently-sourced NPM_ADMIN_* # pair, then EVERY email/pass pair found in the creds file (handles duplicates, # ordering, and stale placeholders), then the NPM factory default. npm_get_token() { local npm_ip="10.10.10.3" # stable internal NPM IP (vmbr1) — API only needs internal reach _try_token() { local email=$1 pass=$2 tok [[ -z "$email" || -z "$pass" ]] && return 1 tok=$(curl -sf -X POST "http://${npm_ip}:81/api/tokens" \ -H "Content-Type: application/json" \ -d "{\"identity\":\"${email}\",\"secret\":\"${pass}\"}" \ 2>/dev/null | grep -o '"token":"[^"]*"' | cut -d'"' -f4) [[ -n "$tok" ]] && { echo "$tok"; return 0; } return 1 } # 1. Currently-sourced pair _try_token "${NPM_ADMIN_EMAIL:-}" "${NPM_ADMIN_PASS:-}" && return # 2. Every email→pass pair in the creds file (pair each EMAIL with the # NPM_ADMIN_PASS that follows it) if [[ -f "$CREDS_FILE" ]]; then local email="" line key val while IFS= read -r line; do case "$line" in NPM_ADMIN_EMAIL=*) email="${line#*=}" ;; NPM_ADMIN_PASS=*) _try_token "$email" "${line#*=}" && return; email="" ;; esac done < "$CREDS_FILE" fi # 3. NPM factory default (fresh, unconfigured install) _try_token "admin@example.com" "changeme" && return echo "" } # Idempotently add a custom location (path → forward_host:port) to the NPM # proxy host that serves $DOMAIN. Robust JSON handling via python3 — the proxy # host is located by matching $DOMAIN inside its domain_names array (field # order in NPM's JSON is not guaranteed, so grep-based matching is unreliable). npm_add_location() { local path=$1 fwd_host=$2 fwd_port=$3 local npm_ip="10.10.10.3" msg_info "Adding ${path} to NPM proxy" local token; token=$(npm_get_token) if [[ -z "$token" ]]; then msg_warn "NPM API unavailable — add ${path} → ${fwd_host}:${fwd_port} manually in NPM admin" return fi local hosts_json host_id hosts_json=$(curl -sf "http://${npm_ip}:81/api/nginx/proxy-hosts" \ -H "Authorization: Bearer ${token}" 2>/dev/null) || true host_id=$(echo "$hosts_json" | python3 -c " import sys, json try: hosts = json.load(sys.stdin) except Exception: sys.exit(0) dom = '${DOMAIN}' for h in hosts: if dom in (h.get('domain_names') or []): print(h['id']); break " 2>/dev/null) || true if [[ -z "$host_id" ]]; then msg_warn "NPM proxy host for ${DOMAIN} not found — add ${path} → ${fwd_host}:${fwd_port} manually" return fi local existing merged existing=$(curl -sf "http://${npm_ip}:81/api/nginx/proxy-hosts/${host_id}" \ -H "Authorization: Bearer ${token}" 2>/dev/null) || true merged=$(echo "$existing" | python3 -c " import sys, json try: h = json.load(sys.stdin) except Exception: sys.exit(0) path, fh, fp = '${path}', '${fwd_host}', ${fwd_port} locs = h.get('locations') or [] if any(l.get('path') == path for l in locs): print('EXISTS'); sys.exit(0) locs.append({'path':path,'forward_scheme':'http','forward_host':fh,'forward_port':fp,'advanced_config':''}) print(json.dumps(locs)) " 2>/dev/null) || true if [[ "$merged" == "EXISTS" ]]; then msg_ok "NPM location ${path} already present" return fi if [[ -z "$merged" ]]; then msg_warn "NPM merge failed — add ${path} → ${fwd_host}:${fwd_port} manually" return fi if curl -sf -X PUT "http://${npm_ip}:81/api/nginx/proxy-hosts/${host_id}" \ -H "Authorization: Bearer ${token}" \ -H "Content-Type: application/json" \ -d "{\"locations\":${merged}}" &>/dev/null; then msg_ok "NPM location ${path} added" else msg_warn "NPM update failed — add ${path} → ${fwd_host}:${fwd_port} manually" fi } configure_npm_proxy_hosts() { msg_step "Configuring NPM proxy hosts" msg_info "Waiting for NPM API to be ready" local npm_ip="10.10.10.3" # stable internal NPM IP (vmbr1) local i=0 while ! curl -sf --max-time 3 "http://${npm_ip}:81/api/" &>/dev/null; do sleep 3; i=$((i+1)) [[ $i -ge 30 ]] && { msg_warn "NPM API not responding — configure proxy hosts manually"; return; } done local token token=$(npm_get_token) 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" # NPM's proxy-host endpoint is /api/nginx/proxy-hosts (NOT /api/proxy-hosts). # NPM already sets X-Real-IP / X-Forwarded-For by default, so no advanced_config. create_proxy_host() { local forward_host=$1 forward_port=$2 locations_json=${3:-'[]'} curl -sf -X POST "http://${npm_ip}:81/api/nginx/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}, \"access_list_id\": 0, \"certificate_id\": 0, \"ssl_forced\": false, \"caching_enabled\": false, \"block_exploits\": true, \"allow_websocket_upgrade\": true, \"http2_support\": false, \"hsts_enabled\": false, \"hsts_subdomains\": false, \"advanced_config\": \"\", \"meta\": {\"letsencrypt_agree\": false, \"dns_challenge\": false}, \"locations\": ${locations_json} }" &>/dev/null && echo "created" || echo "failed" } # Single proxy host for the domain routed to the portal, with custom # locations per app path (NPM's "custom locations" feature). local locations='[ {"path":"/api/auth/","forward_scheme":"http","forward_host":"10.10.10.101","forward_port":3001,"advanced_config":""}, {"path":"/notices/","forward_scheme":"http","forward_host":"10.10.10.112","forward_port":3080,"advanced_config":""}, {"path":"/monitor/","forward_scheme":"http","forward_host":"10.10.10.105","forward_port":3002,"advanced_config":""}, {"path":"/cashup/","forward_scheme":"http","forward_host":"10.10.10.117","forward_port":3083,"advanced_config":""}, {"path":"/hk-planner/","forward_scheme":"http","forward_host":"10.10.10.118","forward_port":3080,"advanced_config":""}, {"path":"/twin-optimiser/","forward_scheme":"http","forward_host":"10.10.10.119","forward_port":3080,"advanced_config":""} ]' local result; result=$(create_proxy_host "10.10.10.102" 3000 "$locations") if [[ "$result" == "created" ]]; then msg_ok "NPM proxy host created for ${DOMAIN}" msg_warn "SSL certificate: configure in NPM admin UI after DNS is pointed at ${NPM_LAN_IP}" else msg_warn "NPM proxy host creation failed — create manually (see summary)" fi } # ════════════════════════════════════════════════════════════════════════════ # SUMMARY # ════════════════════════════════════════════════════════════════════════════ print_summary() { printf "\n${GN}" cat < # Sources existing credentials and redeploys just the named service. if [[ "${1:-}" == "--only" ]]; then ONLY="${2:-}" VALID="postgres auth portal npm management noticeboard settings cashup hk-planner twin-optimiser room-planner maintenance forecasting" [[ -z "$ONLY" ]] && msg_error "Usage: install-stack.sh --only (one of: ${VALID})" grep -qw "$ONLY" <<< "$VALID" || msg_error "Unknown service '${ONLY}'. Valid: ${VALID}" CREDS_FILE=/root/hotel-manage-credentials.txt [[ -f "$CREDS_FILE" ]] || msg_error "No credentials file at ${CREDS_FILE} — run the full installer first" # Read key=value pairs safely — handles values that contain spaces (e.g. SITE_NAME) while IFS= read -r line; do [[ "$line" =~ ^[A-Z_][A-Z0-9_]*= ]] || continue key="${line%%=*}"; value="${line#*=}" export "$key"="$value" done < "$CREDS_FILE" # Generate and append any secrets that didn't exist when the stack was first installed _append_secret() { local var=$1 val=$2 export "$var"="$val" echo "${var}=${val}" >> "$CREDS_FILE" msg_ok "Generated missing secret: ${var}" } [[ -z "${SETTINGS_DB_PASS:-}" ]] && _append_secret SETTINGS_DB_PASS \ "$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24)" [[ -z "${SETTINGS_SECRET:-}" ]] && _append_secret SETTINGS_SECRET \ "$(openssl rand -hex 32)" [[ -z "${HK_PLANNER_DB_PASS:-}" ]] && _append_secret HK_PLANNER_DB_PASS \ "$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24)" [[ -z "${TWIN_OPT_DB_PASS:-}" ]] && _append_secret TWIN_OPT_DB_PASS \ "$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24)" [[ -z "${FORECASTING_DB_PASS:-}" ]] && _append_secret FORECASTING_DB_PASS \ "$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24)" USE_FORGEJO=true [[ -f /root/.ssh/hotel-manage_deploy.pub ]] \ && MGMT_PUBKEY=$(cat /root/.ssh/hotel-manage_deploy.pub) || MGMT_PUBKEY="" check_vmbr1 ensure_pool case "$ONLY" in postgres) deploy_postgres ;; auth) deploy_auth ;; portal) deploy_portal ;; npm) deploy_npm ;; management) deploy_management ;; noticeboard) deploy_noticeboard ;; settings) deploy_settings ;; cashup) deploy_cashup ;; hk-planner) deploy_hk_planner ;; twin-optimiser) deploy_twin_optimiser ;; room-planner) deploy_room_planner ;; maintenance) deploy_maintenance ;; forecasting) deploy_forecasting ;; rates) deploy_rates ;; esac exit 0 fi check_vmbr1 collect_config gen_secrets gen_mgmt_ssh_key ensure_pool deploy_postgres deploy_auth deploy_portal deploy_npm deploy_management deploy_noticeboard deploy_settings deploy_cashup deploy_hk_planner deploy_twin_optimiser deploy_room_planner deploy_maintenance deploy_forecasting deploy_rates configure_npm_proxy_hosts print_summary