#!/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} vlan_ip=${6:-} 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) # Optional second NIC on the admin VLAN (dual-homed, same pattern as NPM/103) — # used by containers that need direct LAN reach (e.g. hvac's Modbus/Midea/Daikin # drivers, the shared MQTT broker). Bridge/tag are site-specific — see # ensure_admin_vlan_config(). local net1_args=() if [[ -n "$vlan_ip" ]]; then local tag_part="" [[ -n "${ADMIN_VLAN_TAG:-}" ]] && tag_part=",tag=${ADMIN_VLAN_TAG}" net1_args=(--net1 "name=eth1,bridge=${ADMIN_VLAN_BRIDGE:-vmbr0}${tag_part},ip=${vlan_ip}/24") fi 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" \ "${net1_args[@]}" \ --features nesting=1 \ --unprivileged 0 \ --onboot 1 \ --tags "${POOL}" \ ${POOL_OPT} \ --start 1 &>/dev/null sleep 5 } # ── Admin VLAN config (dual-homed containers) — asked once, lazily, per hotel ── # Bridge name and VLAN tag vary per hotel install (e.g. a plain vmbr0 with no tag # at one site, a tagged VLAN on a trunk port at another) — never hardcode either. ensure_admin_vlan_config() { [[ -n "${ADMIN_VLAN_BRIDGE:-}" ]] && return ADMIN_VLAN_BRIDGE=$(whiptail --title "Hotel Manage — Admin VLAN" \ --inputbox "Proxmox bridge carrying the admin/HVAC-device VLAN (varies per hotel):" \ 8 66 "vmbr0" 3>&1 1>&2 2>&3) || exit 0 ADMIN_VLAN_TAG=$(whiptail --title "Hotel Manage — Admin VLAN" \ --inputbox "VLAN tag number for that bridge (leave blank if untagged/native VLAN — varies per hotel):" \ 8 66 "" 3>&1 1>&2 2>&3) || exit 0 { echo "ADMIN_VLAN_BRIDGE=${ADMIN_VLAN_BRIDGE}" echo "ADMIN_VLAN_TAG=${ADMIN_VLAN_TAG}" } >> "$CREDS_FILE" msg_ok "Admin VLAN config saved → ${CREDS_FILE}" } # Ask for (and persist) a static IP on the admin VLAN for one dual-homed # container. Kept per-consumer (not a single shared value) since each # dual-homed LXC needs its own address on that VLAN. ensure_admin_vlan_ip() { local var=$1 label=$2 default=$3 [[ -n "${!var:-}" ]] && return local val val=$(whiptail --title "Hotel Manage — Admin VLAN" \ --inputbox "${label} (varies per hotel):" 8 66 "${default}" 3>&1 1>&2 2>&3) || exit 0 export "$var"="$val" echo "${var}=${val}" >> "$CREDS_FILE" msg_ok "${var} saved → ${CREDS_FILE}" } 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 (4 GB RAM, 2 cores)" # 4 GB required: Playwright/Chromium scrapes + image builds wedge a 2 GB LXC create_lxc 115 "10.10.10.115" "rates" 4096 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 } deploy_reports() { msg_step "Reports (LXC 122 · 10.10.10.122)" if [[ -z "${REPORTS_DB_PASS:-}" ]]; then REPORTS_DB_PASS=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24) printf '\nREPORTS_DB_PASS=%s\n' "$REPORTS_DB_PASS" >> "$CREDS_FILE" msg_ok "Generated REPORTS_DB_PASS → ${CREDS_FILE}" fi msg_info "Creating LXC 122" create_lxc 122 "10.10.10.122" "reports" 1024 1 msg_ok "LXC 122 created" msg_info "Installing Docker" install_docker 122 install_mgmt_key 122 msg_ok "Docker + SSH ready" msg_info "Creating reports database" pct exec 100 -- bash -c " docker exec hotel-manage-postgres psql -U postgres -c \ \"CREATE USER reports WITH PASSWORD '${REPORTS_DB_PASS}';\" 2>/dev/null || true docker exec hotel-manage-postgres psql -U postgres -c \ \"CREATE DATABASE reports_db OWNER reports;\" 2>/dev/null || true docker exec hotel-manage-postgres psql -U postgres -d reports_db -c \ \"GRANT ALL ON SCHEMA public TO reports;\" 2>/dev/null || true " &>/dev/null msg_ok "Database reports_db ready" msg_info "Deploying reports" deploy_service 122 "reports" "${REPO_ROOT}/reports" /opt/reports push_file 122 /opt/reports/.env <&1"); then msg_error "docker compose build failed in LXC 122: ${build_out}" fi msg_info "Waiting for reports" wait_healthy 122 "http://localhost:3080/reports/health" \ && msg_ok "Reports running at 10.10.10.122:3080" \ || msg_warn "Reports may need extra time — check LXC 122" msg_info "Seeding reports 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 ('reports', 'Reports', 'Custom reports for NewBook, ResOS, SambaPOS and internal data', '/reports', 'BarChart2', '#1d4ed8', 'Management', '10.10.10.122', 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 & Run Reports', 'Browse and run all custom reports', 1), ('export', 'Export to CSV', 'Download report results as a CSV file', 2), ('edit', 'Edit Directors Forecast','Edit pickup/dry/wet overrides and save forecast snapshots', 3) ) AS c(slug, name, description, sort_order) WHERE a.slug = 'reports' ON CONFLICT (app_id, slug) DO NOTHING; " &>/dev/null \ && msg_ok "reports seeded into auth DB" \ || msg_warn "Seed failed — run auth db.js manually" npm_add_location "/reports/" "10.10.10.122" 3080 } deploy_wages() { msg_step "Wages (LXC 124 · 10.10.10.124)" if [[ -z "${WAGES_DB_PASS:-}" ]]; then WAGES_DB_PASS=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24) printf '\nWAGES_DB_PASS=%s\n' "$WAGES_DB_PASS" >> "$CREDS_FILE" msg_ok "Generated WAGES_DB_PASS → ${CREDS_FILE}" fi msg_info "Creating LXC 124" create_lxc 124 "10.10.10.124" "wages" 1024 1 msg_ok "LXC 124 created" msg_info "Installing Docker" install_docker 124 install_mgmt_key 124 msg_ok "Docker + SSH ready" msg_info "Creating wages database" pct exec 100 -- bash -c " docker exec hotel-manage-postgres psql -U postgres -c \ \"CREATE USER wages WITH PASSWORD '${WAGES_DB_PASS}';\" 2>/dev/null || true docker exec hotel-manage-postgres psql -U postgres -c \ \"CREATE DATABASE wages_db OWNER wages;\" 2>/dev/null || true docker exec hotel-manage-postgres psql -U postgres -d wages_db -c \ \"GRANT ALL ON SCHEMA public TO wages;\" 2>/dev/null || true " &>/dev/null msg_ok "Database wages_db ready" msg_info "Deploying wages" deploy_service 124 "wages" "${REPO_ROOT}/wages" /opt/wages push_file 124 /opt/wages/.env <&1"); then msg_error "docker compose build failed in LXC 124: ${build_out}" fi msg_info "Waiting for wages" wait_healthy 124 "http://localhost:3080/wages/health" \ && msg_ok "Wages running at 10.10.10.124:3080" \ || msg_warn "Wages may need extra time — check LXC 124" msg_info "Seeding wages 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 ('wages', 'Wage Costs', 'Live wage cost reporting — weekly, monthly, and rolling history vs budget and net sales', '/wages', 'DollarSign', '#065f46', 'Finance', '10.10.10.124', 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 Reports', 'View all wage cost reports (weekly, monthly, rolling)', 1), ('budget', 'Edit Budgets', 'Set monthly wage budget targets', 2), ('sync', 'Manual Sync', 'Trigger a Workforce API data sync or backfill', 3), ('settings', 'Settings', 'App settings, API configuration and department filter', 4) ) AS c(slug, name, description, sort_order) WHERE a.slug = 'wages' ON CONFLICT (app_id, slug) DO NOTHING; " &>/dev/null \ && msg_ok "wages seeded into auth DB" \ || msg_warn "Seed failed — run wages/seed-app.js manually" npm_add_location "/wages/" "10.10.10.124" 3080 } deploy_utilities() { msg_step "Utilities (LXC 127 · 10.10.10.127)" if [[ -z "${UTILITIES_DB_PASS:-}" ]]; then UTILITIES_DB_PASS=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24) printf '\nUTILITIES_DB_PASS=%s\n' "$UTILITIES_DB_PASS" >> "$CREDS_FILE" msg_ok "Generated UTILITIES_DB_PASS → ${CREDS_FILE}" fi if [[ -z "${UTILITIES_API_KEY:-}" ]]; then UTILITIES_API_KEY=$(openssl rand -hex 24) printf '\nUTILITIES_API_KEY=%s\n' "$UTILITIES_API_KEY" >> "$CREDS_FILE" msg_ok "Generated UTILITIES_API_KEY → ${CREDS_FILE}" fi msg_info "Creating LXC 127" create_lxc 127 "10.10.10.127" "utilities" 1024 1 msg_ok "LXC 127 created" msg_info "Installing Docker" install_docker 127 install_mgmt_key 127 msg_ok "Docker + SSH ready" msg_info "Creating utilities database" pct exec 100 -- bash -c " docker exec hotel-manage-postgres psql -U postgres -c \ \"CREATE USER utilities WITH PASSWORD '${UTILITIES_DB_PASS}';\" 2>/dev/null || true docker exec hotel-manage-postgres psql -U postgres -c \ \"CREATE DATABASE utilities_db OWNER utilities;\" 2>/dev/null || true docker exec hotel-manage-postgres psql -U postgres -d utilities_db -c \ \"GRANT ALL ON SCHEMA public TO utilities;\" 2>/dev/null || true " &>/dev/null msg_ok "Database utilities_db ready" msg_info "Deploying utilities" deploy_service 127 "utilities" "${REPO_ROOT}/utilities" /opt/utilities push_file 127 /opt/utilities/.env <&1"); then msg_error "docker compose build failed in LXC 127: ${build_out}" fi msg_info "Waiting for utilities" wait_healthy 127 "http://localhost:3080/utilities/health" \ && msg_ok "Utilities running at 10.10.10.127:3080" \ || msg_warn "Utilities may need extra time — check LXC 127" msg_info "Seeding utilities 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 ('utilities', 'Utilities', 'Meter readings, tariffs and energy cost tracking', '/utilities', 'Zap', '#1e6091', 'Operations', '10.10.10.127', 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 ('readings', 'Enter Readings', 'Enter manual meter readings and view reading history', 1), ('meters', 'Manage Meters', 'Create/edit categories, meters, locations and images', 2), ('tariffs', 'Manage Tariffs', 'Create/edit tariffs, rate windows, standing charges and CCL', 3), ('reports', 'View Reports', 'View consumption and cost reports', 4), ('estimates', 'View Estimates', 'View and adjust period cost estimates', 5), ('settings', 'Settings', 'App settings', 6) ) AS c(slug, name, description, sort_order) WHERE a.slug = 'utilities' ON CONFLICT (app_id, slug) DO NOTHING; " &>/dev/null \ && msg_ok "utilities seeded into auth DB" \ || msg_warn "Seed failed — run utilities/seed-app.js manually" npm_add_location "/utilities/" "10.10.10.127" 3080 } # ════════════════════════════════════════════════════════════════════════════ # MQTT BROKER LXC 104 — shared infra (dual-homed), no app/frontend/auth entry. # Same tier as postgres (100): a container other apps depend on, not a stack # "app" itself. hvac needs it for Phase 1 TRVs; a future utility-meter app # would reuse it too — see the hvac plan doc's 'MQTT is shared infrastructure' # decision for why this isn't a sidecar inside hvac's own compose. # ════════════════════════════════════════════════════════════════════════════ deploy_mqtt_broker() { msg_step "MQTT broker (LXC 104 · 10.10.10.104)" ensure_admin_vlan_config ensure_admin_vlan_ip MQTT_ADMIN_VLAN_IP \ "Static IP for the MQTT broker's admin-VLAN interface (net1 — this is what physical devices like TRVs publish into)" \ "10.4.0.61" if [[ -z "${MQTT_ADMIN_PASS:-}" ]]; then MQTT_ADMIN_USER="hotel-manage-admin" MQTT_ADMIN_PASS=$(openssl rand -base64 18 | tr -dc 'a-zA-Z0-9' | head -c 18) { echo "MQTT_ADMIN_USER=${MQTT_ADMIN_USER}"; echo "MQTT_ADMIN_PASS=${MQTT_ADMIN_PASS}"; } >> "$CREDS_FILE" msg_ok "Generated MQTT broker admin credential → ${CREDS_FILE}" fi MQTT_ADMIN_USER="${MQTT_ADMIN_USER:-hotel-manage-admin}" msg_info "Creating LXC 104 (dual-homed)" create_lxc 104 "10.10.10.104" "mqtt-broker" 512 1 "${MQTT_ADMIN_VLAN_IP}" msg_ok "LXC 104 created (10.10.10.104 + ${MQTT_ADMIN_VLAN_IP} on ${ADMIN_VLAN_BRIDGE}${ADMIN_VLAN_TAG:+ tag ${ADMIN_VLAN_TAG}})" msg_info "Installing Docker" install_docker 104 install_mgmt_key 104 msg_ok "Docker + SSH ready" msg_info "Deploying Mosquitto" pct exec 104 -- mkdir -p /opt/mqtt-broker/config /opt/mqtt-broker/data /opt/mqtt-broker/log # Dynamic-security plugin, not static password/ACL files — lets per-consumer # clients be created/revoked live later (via mosquitto_ctrl or, eventually, # the settings app's 'MQTT Broker Clients' page) without restarting the broker. push_file 104 /opt/mqtt-broker/config/mosquitto.conf <<'EOF' listener 1883 0.0.0.0 allow_anonymous false persistence true persistence_location /mosquitto/data/ log_dest file /mosquitto/log/mosquitto.log log_dest stdout plugin /usr/lib/mosquitto_dynamic_security.so plugin_opts { config_file /mosquitto/data/dynamic-security.json } EOF push_file 104 /opt/mqtt-broker/docker-compose.yml <<'EOF' services: mosquitto: container_name: hotel-manage-mqtt-broker image: eclipse-mosquitto:2 security_opt: - apparmor=unconfined volumes: - ./config:/mosquitto/config:ro - ./data:/mosquitto/data - ./log:/mosquitto/log ports: - "1883:1883" restart: unless-stopped EOF # One-time dynamic-security bootstrap — seeds a single admin identity before # the broker ever starts against this file. Resume-safe: skipped if the file # already exists, so re-running this deploy never resets an existing broker's # already-provisioned clients. if ! pct exec 104 -- test -f /opt/mqtt-broker/data/dynamic-security.json 2>/dev/null; then msg_info "Bootstrapping dynamic-security admin identity" if pct exec 104 -- docker run --rm -v /opt/mqtt-broker/data:/mosquitto/data \ eclipse-mosquitto:2 \ mosquitto_ctrl dynsec init /mosquitto/data/dynamic-security.json "${MQTT_ADMIN_USER}" "${MQTT_ADMIN_PASS}" \ &>/dev/null; then msg_ok "Dynamic-security admin '${MQTT_ADMIN_USER}' created" else msg_warn "dynsec init failed — verify manually: pct exec 104 -- docker run --rm -v /opt/mqtt-broker/data:/mosquitto/data eclipse-mosquitto:2 mosquitto_ctrl dynsec init /mosquitto/data/dynamic-security.json " fi else msg_ok "dynamic-security.json already exists — skipping bootstrap" fi pct exec 104 -- bash -c "cd /opt/mqtt-broker && docker compose up -d" &>/dev/null msg_info "Waiting for broker" sleep 3 if pct exec 104 -- docker exec hotel-manage-mqtt-broker sh -c "pgrep mosquitto" &>/dev/null; then msg_ok "MQTT broker running at 10.10.10.104:1883 (internal) / ${MQTT_ADMIN_VLAN_IP}:1883 (admin VLAN)" else msg_warn "MQTT broker may need extra time or manual check — pct exec 104 -- docker compose -f /opt/mqtt-broker/docker-compose.yml logs" fi msg_warn "Broker admin credential is installer-only, never exposed via any UI — stored in ${CREDS_FILE} as MQTT_ADMIN_USER/MQTT_ADMIN_PASS." msg_warn "Per-consumer clients (hvac-backend, shelly-devices, etc.) are NOT created by this installer — that's self-service via the settings app's 'MQTT Broker Clients' page (not yet built). Until then, provision manually with mosquitto_ctrl dynsec createClient/addRoleToClient using the admin credential above." } deploy_hvac() { msg_step "HVAC (LXC 128 · 10.10.10.128)" if [[ -z "${HVAC_DB_PASS:-}" ]]; then HVAC_DB_PASS=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24) printf '\nHVAC_DB_PASS=%s\n' "$HVAC_DB_PASS" >> "$CREDS_FILE" msg_ok "Generated HVAC_DB_PASS → ${CREDS_FILE}" fi ensure_admin_vlan_config ensure_admin_vlan_ip HVAC_ADMIN_VLAN_IP \ "Static IP for hvac's admin-VLAN interface (net1 — direct Modbus/Midea/Daikin device access)" \ "10.4.0.60" msg_info "Creating LXC 128" create_lxc 128 "10.10.10.128" "hvac" 1024 1 "${HVAC_ADMIN_VLAN_IP}" msg_ok "LXC 128 created (dual-homed: 10.10.10.128 + ${HVAC_ADMIN_VLAN_IP} on ${ADMIN_VLAN_BRIDGE}${ADMIN_VLAN_TAG:+ tag ${ADMIN_VLAN_TAG}})" msg_info "Installing Docker" install_docker 128 install_mgmt_key 128 msg_ok "Docker + SSH ready" msg_info "Creating hvac database" pct exec 100 -- bash -c " docker exec hotel-manage-postgres psql -U postgres -c \ \"CREATE USER hvac WITH PASSWORD '${HVAC_DB_PASS}';\" 2>/dev/null || true docker exec hotel-manage-postgres psql -U postgres -c \ \"CREATE DATABASE hvac_db OWNER hvac;\" 2>/dev/null || true docker exec hotel-manage-postgres psql -U postgres -d hvac_db -c \ \"GRANT ALL ON SCHEMA public TO hvac;\" 2>/dev/null || true " &>/dev/null msg_ok "Database hvac_db ready" msg_info "Deploying hvac" deploy_service 128 "hvac" "${REPO_ROOT}/hvac" /opt/hvac # No MQTT broker LXC exists yet (separate infra piece, not part of this deploy) — # hvac's mqtt.js connects out and retries with backoff, so this is safe to deploy # before the broker exists. NEWBOOK_LOCATION_ID matches every other NewBook app. push_file 128 /opt/hvac/.env <&1"); then msg_error "docker compose build failed in LXC 128: ${build_out}" fi msg_info "Waiting for hvac" wait_healthy 128 "http://localhost:3080/hvac/health" \ && msg_ok "HVAC running at 10.10.10.128:3080" \ || msg_warn "HVAC may need extra time — check LXC 128" msg_info "Seeding hvac 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 ('hvac', 'HVAC', 'Room heating control — NewBook-driven TRV scheduling, aircon and boiler (phased)', '/hvac', 'Thermometer', '#c1440e', 'Operations', '10.10.10.128', 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','View zone dashboard, device status and activity',1), ('control','Manual Override','Force a zone''s temperature and disable auto mode',2), ('schedule_edit','Edit Schedules','Adjust per-zone temps, offsets and auto mode',3), ('manage_devices','Manage Devices','Discover, map, photograph devices; sync zones from NewBook',4), ('public_area_control','Public Area Control','Central control of public-area zones (Phase 3)',5), ('boiler_view','Boiler — View','View boiler controller status (Phase 4)',6), ('boiler_control','Boiler — Control','Adjust boiler weather-compensation / pump disable (Phase 4)',7), ('settings','Settings','Configure hvac app settings',8) ) AS c(slug, name, description, sort_order) WHERE a.slug = 'hvac' ON CONFLICT (app_id, slug) DO NOTHING; " &>/dev/null \ && msg_ok "hvac seeded into auth DB" \ || msg_warn "Seed failed — run hvac/seed-app.js manually" npm_add_location "/hvac/" "10.10.10.128" 3080 msg_warn "hvac deployed but the shared MQTT broker (LXC 104) is separate infra and not provisioned by this installer — device control will retry/backoff until it exists. See the hvac plan doc's 'MQTT settings & auth' section." msg_warn "hvac's admin-VLAN NIC (${HVAC_ADMIN_VLAN_IP}) is up but unused by Phase 1 (TRVs go via the MQTT broker) — it's provisioned now for Phase 2/3 Modbus/Midea/Daikin drivers, which aren't built yet." } deploy_calendar() { msg_step "Calendar (LXC 126 · 10.10.10.126)" if [[ -z "${CALENDAR_DB_PASS:-}" ]]; then CALENDAR_DB_PASS=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24) printf '\nCALENDAR_DB_PASS=%s\n' "$CALENDAR_DB_PASS" >> "$CREDS_FILE" msg_ok "Generated CALENDAR_DB_PASS → ${CREDS_FILE}" fi msg_info "Creating LXC 126" create_lxc 126 "10.10.10.126" "calendar" 1024 1 msg_ok "LXC 126 created" msg_info "Installing Docker" install_docker 126 install_mgmt_key 126 msg_ok "Docker + SSH ready" msg_info "Creating calendar database" pct exec 100 -- bash -c " docker exec hotel-manage-postgres psql -U postgres -c \ \"CREATE USER calendar WITH PASSWORD '${CALENDAR_DB_PASS}';\" 2>/dev/null || true docker exec hotel-manage-postgres psql -U postgres -c \ \"CREATE DATABASE calendar_db OWNER calendar;\" 2>/dev/null || true docker exec hotel-manage-postgres psql -U postgres -d calendar_db -c \ \"GRANT ALL ON SCHEMA public TO calendar;\" 2>/dev/null || true " &>/dev/null msg_ok "Database calendar_db ready" msg_info "Deploying calendar" deploy_service 126 "calendar" "${REPO_ROOT}/calendar" /opt/calendar push_file 126 /opt/calendar/.env <&1"); then msg_error "docker compose build failed in LXC 126: ${build_out}" fi msg_info "Waiting for calendar" wait_healthy 126 "http://localhost:3080/calendar/health" \ && msg_ok "Calendar running at 10.10.10.126:3080" \ || msg_warn "Calendar may need extra time — check LXC 126" msg_info "Seeding calendar 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 ('calendar', 'Calendar', 'Shared events calendar — departments, staff, bank holidays, phone sync', '/calendar', 'CalendarDays', '#c9a84c', 'Operations', '10.10.10.126', 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 Calendar', 'View events, calendars and bank holidays', 1), ('create', 'Create Events', 'Add new events to non-system calendars', 2), ('edit', 'Edit Events', 'Edit, delete and attach files to events; manage own CalDAV credentials', 3), ('manage_calendars', 'Manage Calendars', 'Create, rename, recolour and delete calendars', 4), ('admin', 'View Activity Log & Admin', 'View the full activity/audit log', 5) ) AS c(slug, name, description, sort_order) WHERE a.slug = 'calendar' ON CONFLICT (app_id, slug) DO NOTHING; " &>/dev/null \ && msg_ok "calendar seeded into auth DB" \ || msg_warn "Seed failed — run calendar/seed-app.js manually" npm_add_location "/calendar/" "10.10.10.126" 3080 } deploy_kitchen() { msg_step "Kitchen (LXC 110 · 10.10.10.110)" if [[ -z "${KITCHEN_DB_PASS:-}" ]]; then KITCHEN_DB_PASS=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24) printf '\nKITCHEN_DB_PASS=%s\n' "$KITCHEN_DB_PASS" >> "$CREDS_FILE" msg_ok "Generated KITCHEN_DB_PASS → ${CREDS_FILE}" fi # 2 GB RAM — FastAPI + MSSQL ODBC drivers + Azure DI OCR msg_info "Creating LXC 110 (2 GB RAM)" create_lxc 110 "10.10.10.110" "kitchen" 2048 2 pct resize 110 rootfs 10G &>/dev/null || true msg_ok "LXC 110 created" msg_info "Installing Docker" install_docker 110 install_mgmt_key 110 msg_ok "Docker + SSH ready" msg_info "Creating kitchen_db (shared by kitchen and kds)" pct exec 100 -- bash -c " docker exec hotel-manage-postgres psql -U postgres -c \ \"CREATE USER kitchen WITH PASSWORD '${KITCHEN_DB_PASS}';\" 2>/dev/null || true docker exec hotel-manage-postgres psql -U postgres -c \ \"CREATE DATABASE kitchen_db OWNER kitchen;\" 2>/dev/null || true docker exec hotel-manage-postgres psql -U postgres -d kitchen_db -c \ \"GRANT ALL ON SCHEMA public TO kitchen;\" 2>/dev/null || true " &>/dev/null msg_ok "Database kitchen_db ready (schema applied by Python backend on first start)" msg_info "Deploying kitchen" deploy_service 110 "kitchen" "${REPO_ROOT}/kitchen" /opt/kitchen # AZURE_DI_ENDPOINT and AZURE_DI_KEY are for the OCR invoice upload pipeline. # ANTHROPIC_API_KEY enables the LLM integration (allergen / flag analysis). # Leave blank to disable optional integrations; they can be added post-deploy via .env. push_file 110 /opt/kitchen/.env <&1"); then msg_error "docker compose build failed in LXC 110: ${build_out}" fi msg_info "Waiting for kitchen" wait_healthy 110 "http://localhost:3080/kitchen/health" 200 \ && msg_ok "Kitchen running at 10.10.10.110:3080" \ || msg_warn "Kitchen may need extra time — check LXC 110 logs" msg_info "Seeding kitchen 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 ('kitchen', 'Kitchen', 'Invoice/GP, recipes, menus and kitchen management', '/kitchen', 'ChefHat', '#0d9488', 'Kitchen', '10.10.10.110', 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 app', 'Access the kitchen app and dashboard', 1), ('invoices', 'View invoices', 'View invoice list, details and search', 2), ('invoices_manage', 'Manage invoices', 'Upload, edit, approve and delete invoices', 3), ('disputes', 'Disputes', 'Open, manage and resolve invoice disputes', 4), ('logbook', 'Wastage logbook', 'Record and view wastage logbook entries', 5), ('orders', 'Purchase orders', 'Create and manage purchase orders', 6), ('recipes', 'Recipes', 'View and edit recipes, ingredients and allergens', 7), ('menus', 'Menus', 'Build, edit and publish menus and dishes', 8), ('manage_flags', 'Manage flags', 'Review and dismiss food compliance and allergen flags', 9), ('settings', 'Manage settings', 'App settings: integrations, API keys, SambaPOS config', 10) ) AS c(slug, name, description, sort_order) WHERE a.slug = 'kitchen' ON CONFLICT (app_id, slug) DO NOTHING; " &>/dev/null \ && msg_ok "kitchen seeded into auth DB" \ || msg_warn "Seed failed — run auth db.js manually" npm_add_location "/kitchen/" "10.10.10.110" 3080 } deploy_kds() { msg_step "KDS (LXC 125 · 10.10.10.125)" # KDS uses kitchen_db (shared schema). KITCHEN_DB_PASS must exist (deploy_kitchen first). if [[ -z "${KITCHEN_DB_PASS:-}" ]]; then msg_error "KITCHEN_DB_PASS not set — deploy kitchen first, or add it to ${CREDS_FILE}" fi msg_info "Creating LXC 125" create_lxc 125 "10.10.10.125" "kds" 1024 1 msg_ok "LXC 125 created" msg_info "Installing Docker" install_docker 125 install_mgmt_key 125 msg_ok "Docker + SSH ready" msg_info "Deploying kds" deploy_service 125 "kds" "${REPO_ROOT}/kds" /opt/kds push_file 125 /opt/kds/.env <&1"); then msg_error "docker compose build failed in LXC 125: ${build_out}" fi msg_info "Waiting for kds" wait_healthy 125 "http://localhost:3080/kds/health" 200 \ && msg_ok "KDS running at 10.10.10.125:3080" \ || msg_warn "KDS may need extra time — check LXC 125 logs" msg_info "Seeding kds 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 ('kds', 'Kitchen Display', 'SambaPOS ticket feed and course flow display', '/kds', 'Monitor', '#0d9488', 'Kitchen', '10.10.10.125', 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 board', 'View the KDS ticket board', 1), ('manage', 'Manage courses', 'Call away, mark sent and clear courses on live tickets', 2), ('settings', 'Manage settings', 'KDS timer thresholds, SambaPOS GraphQL config', 3) ) AS c(slug, name, description, sort_order) WHERE a.slug = 'kds' ON CONFLICT (app_id, slug) DO NOTHING; INSERT INTO role_capabilities (role_id, capability_id) SELECT r.id, ac.id FROM roles r JOIN app_capabilities ac ON ac.app_id = (SELECT id FROM apps WHERE slug = 'kds') WHERE r.slug = 'staff' AND ac.slug IN ('view', 'manage') AND NOT EXISTS (SELECT 1 FROM role_capabilities rc WHERE rc.role_id = r.id AND rc.capability_id = ac.id) ON CONFLICT DO NOTHING; " &>/dev/null \ && msg_ok "kds seeded into auth DB" \ || msg_warn "Seed failed — run auth db.js manually" npm_add_location "/kds/" "10.10.10.125" 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 rates reports kitchen kds wages utilities mqtt-broker hvac calendar" [[ -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)" [[ -z "${REPORTS_DB_PASS:-}" ]] && _append_secret REPORTS_DB_PASS \ "$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24)" [[ -z "${KITCHEN_DB_PASS:-}" ]] && _append_secret KITCHEN_DB_PASS \ "$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24)" [[ -z "${WAGES_DB_PASS:-}" ]] && _append_secret WAGES_DB_PASS \ "$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24)" [[ -z "${UTILITIES_DB_PASS:-}" ]] && _append_secret UTILITIES_DB_PASS \ "$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24)" [[ -z "${UTILITIES_API_KEY:-}" ]] && _append_secret UTILITIES_API_KEY \ "$(openssl rand -hex 24)" [[ -z "${HVAC_DB_PASS:-}" ]] && _append_secret HVAC_DB_PASS \ "$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24)" [[ -z "${CALENDAR_DB_PASS:-}" ]] && _append_secret CALENDAR_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 ;; reports) deploy_reports ;; kitchen) deploy_kitchen ;; kds) deploy_kds ;; wages) deploy_wages ;; utilities) deploy_utilities ;; mqtt-broker) deploy_mqtt_broker ;; hvac) deploy_hvac ;; calendar) deploy_calendar ;; 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 deploy_utilities deploy_reports deploy_kitchen deploy_kds deploy_wages deploy_mqtt_broker deploy_hvac deploy_calendar configure_npm_proxy_hosts print_summary