Hit in production 2026-08-13: settings crash-looped with 'password authentication failed' after a routine --only settings redeploy. The settings Postgres role was only ever created once, in deploy_postgres()'s init SQL (fresh-volume-only) — so if CREDS_FILE's SETTINGS_DB_PASS ever drifted from the role's actual password, redeploying settings had no way to self-heal. Mirrors the CREATE-then-fallback pattern other apps' deploy functions use, but falls back to ALTER instead of swallowing the error, since CREATE failing because the role already exists doesn't fix a drifted password. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2689 lines
118 KiB
Bash
Executable file
2689 lines
118 KiB
Bash
Executable file
#!/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 <base>/<service>.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" <<EOF
|
|
# Hotel Manage credentials — generated $(date '+%Y-%m-%d %H:%M')
|
|
# !! KEEP THIS FILE SAFE — store a copy offsite !!
|
|
|
|
SITE_NAME="${SITE_NAME}"
|
|
DOMAIN=${DOMAIN}
|
|
ADMIN_EMAIL=${ADMIN_EMAIL}
|
|
ADMIN_PASS="${ADMIN_PASS}"
|
|
OFFICE_IP_CHECK=${OFFICE_IP}
|
|
|
|
PG_SUPERPASS=${PG_SUPERPASS}
|
|
AUTH_DB_PASS=${AUTH_DB_PASS}
|
|
NOTICES_DB_PASS=${NOTICES_DB_PASS}
|
|
SETTINGS_DB_PASS=${SETTINGS_DB_PASS}
|
|
CASHUP_DB_PASS=${CASHUP_DB_PASS}
|
|
HK_PLANNER_DB_PASS=${HK_PLANNER_DB_PASS}
|
|
TWIN_OPT_DB_PASS=${TWIN_OPT_DB_PASS}
|
|
ROOM_PLANNER_DB_PASS=${ROOM_PLANNER_DB_PASS}
|
|
CENTRAL_AUTH_SECRET=${CENTRAL_AUTH_SECRET}
|
|
SETTINGS_SECRET=${SETTINGS_SECRET}
|
|
WEBHOOK_SECRET=${WEBHOOK_SECRET}
|
|
|
|
NPM_LAN_IP=${NPM_LAN_IP}
|
|
NPM_ADMIN_EMAIL=manage@hotel.com
|
|
NPM_ADMIN_PASS=${NPM_ADMIN_PASS}
|
|
|
|
FORGEJO_BASE=${FORGEJO_BASE}
|
|
FORGEJO_TOKEN=${FORGEJO_TOKEN}
|
|
|
|
BACKUP_REMOTE=${BACKUP_REMOTE}
|
|
EOF
|
|
chmod 600 "$CREDS_FILE"
|
|
msg_ok "Secrets generated → $CREDS_FILE"
|
|
}
|
|
|
|
# ── SSH keypair for management → app LXCs ────────────────────────────────────
|
|
gen_mgmt_ssh_key() {
|
|
if [[ ! -f /root/.ssh/hotel-manage_deploy ]]; then
|
|
msg_info "Generating management SSH keypair"
|
|
mkdir -p /root/.ssh
|
|
ssh-keygen -t ed25519 -f /root/.ssh/hotel-manage_deploy -N "" -C "hotel-manage-management-deploy" &>/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 <<EOF
|
|
PG_SUPERPASS=${PG_SUPERPASS}
|
|
EOF
|
|
|
|
# Init SQL — auth DB
|
|
push_file 100 /opt/postgres/init/01-auth.sql <<EOF
|
|
CREATE USER auth WITH PASSWORD '${AUTH_DB_PASS}';
|
|
CREATE DATABASE auth_db OWNER auth;
|
|
\c auth_db
|
|
GRANT ALL ON SCHEMA public TO auth;
|
|
EOF
|
|
|
|
# Init SQL — noticeboard DB
|
|
push_file 100 /opt/postgres/init/02-noticeboard.sql <<EOF
|
|
CREATE USER noticeboard WITH PASSWORD '${NOTICES_DB_PASS}';
|
|
CREATE DATABASE noticeboard_db OWNER noticeboard;
|
|
\c noticeboard_db
|
|
GRANT ALL ON SCHEMA public TO noticeboard;
|
|
EOF
|
|
|
|
# Init SQL — settings DB
|
|
push_file 100 /opt/postgres/init/03-settings.sql <<EOF
|
|
CREATE USER settings WITH PASSWORD '${SETTINGS_DB_PASS}';
|
|
CREATE DATABASE settings_db OWNER settings;
|
|
\c settings_db
|
|
GRANT ALL ON SCHEMA public TO settings;
|
|
EOF
|
|
|
|
# Init SQL — cashup DB
|
|
push_file 100 /opt/postgres/init/04-cashup.sql <<EOF
|
|
CREATE USER cashup WITH PASSWORD '${CASHUP_DB_PASS}';
|
|
CREATE DATABASE cashup_db OWNER cashup;
|
|
\c cashup_db
|
|
GRANT ALL ON SCHEMA public TO cashup;
|
|
EOF
|
|
|
|
# Init SQL — hk-planner DB
|
|
push_file 100 /opt/postgres/init/05-hk-planner.sql <<EOF
|
|
CREATE USER hk_planner WITH PASSWORD '${HK_PLANNER_DB_PASS}';
|
|
CREATE DATABASE hk_planner_db OWNER hk_planner;
|
|
\c hk_planner_db
|
|
GRANT ALL ON SCHEMA public TO hk_planner;
|
|
EOF
|
|
|
|
# Init SQL — twin-optimiser DB
|
|
push_file 100 /opt/postgres/init/06-twin-optimiser.sql <<EOF
|
|
CREATE USER twin_optimiser WITH PASSWORD '${TWIN_OPT_DB_PASS}';
|
|
CREATE DATABASE twin_optimiser_db OWNER twin_optimiser;
|
|
\c twin_optimiser_db
|
|
GRANT ALL ON SCHEMA public TO twin_optimiser;
|
|
EOF
|
|
|
|
# Init SQL — room-planner DB
|
|
push_file 100 /opt/postgres/init/07-room-planner.sql <<EOF
|
|
CREATE USER room_planner WITH PASSWORD '${ROOM_PLANNER_DB_PASS}';
|
|
CREATE DATABASE room_planner_db OWNER room_planner;
|
|
\c room_planner_db
|
|
GRANT ALL ON SCHEMA public TO room_planner;
|
|
EOF
|
|
|
|
pct exec 100 -- bash -c "cd /opt/postgres && docker compose up -d" &>/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 <<EOF
|
|
NODE_ENV=production
|
|
PORT=3001
|
|
DATABASE_URL=postgresql://auth:${AUTH_DB_PASS}@10.10.10.100:5432/auth_db
|
|
CENTRAL_AUTH_SECRET=${CENTRAL_AUTH_SECRET}
|
|
DOMAIN=${DOMAIN}
|
|
ADMIN_EMAIL=${ADMIN_EMAIL}
|
|
ADMIN_PASSWORD=${ADMIN_PASS}
|
|
OFFICE_IP_CHECK=${OFFICE_IP_CHECK:-${OFFICE_IP:-disabled}}
|
|
CORS_ORIGIN=https://${DOMAIN}
|
|
SESSION_DAYS=30
|
|
SETTINGS_URL=http://10.10.10.116:3080
|
|
SETTINGS_SECRET=${SETTINGS_SECRET}
|
|
EOF
|
|
|
|
pct exec 101 -- bash -c "cd /opt/auth && docker compose up -d --build" &>/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 <<EOF
|
|
NODE_ENV=production
|
|
VITE_API_BASE=
|
|
EOF
|
|
|
|
# Patch nginx.conf with real auth LXC IP (already correct in template but be explicit)
|
|
pct exec 102 -- bash -c "
|
|
cd /opt/portal && docker compose up -d --build
|
|
" &>/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 <<EOF
|
|
WEBHOOK_SECRET=${WEBHOOK_SECRET}
|
|
CENTRAL_AUTH_SECRET=${CENTRAL_AUTH_SECRET}
|
|
AUTH_URL=http://10.10.10.101:3001
|
|
SETTINGS_URL=http://10.10.10.116:3080
|
|
SETTINGS_SECRET=${SETTINGS_SECRET}
|
|
PG_SUPERPASS=${PG_SUPERPASS}
|
|
EOF
|
|
|
|
pct exec 105 -- bash -c "cd /opt/management && docker compose up -d --build" &>/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 <<EOF
|
|
DATABASE_URL=postgresql://noticeboard:${NOTICES_DB_PASS}@10.10.10.100:5432/noticeboard_db
|
|
CENTRAL_AUTH_SECRET=${CENTRAL_AUTH_SECRET}
|
|
APP_SLUG=noticeboard
|
|
OFFICE_IP_CHECK=${OFFICE_IP_CHECK:-${OFFICE_IP:-disabled}}
|
|
NODE_ENV=production
|
|
AUTH_URL=http://10.10.10.101:3001
|
|
EOF
|
|
|
|
pct exec 112 -- bash -c "cd /opt/noticeboard && docker compose up -d --build" &>/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"
|
|
|
|
# Re-assert the settings DB role/password on every deploy, not just first
|
|
# install — the role was originally only created once, in deploy_postgres()'s
|
|
# init SQL (fresh-volume-only), so a redeploy had no way to self-heal if
|
|
# CREDS_FILE's SETTINGS_DB_PASS ever drifted from the role's actual password
|
|
# (hit in production 2026-08-13: settings crash-looped on "password
|
|
# authentication failed" after a routine redeploy). CREATE first (covers a
|
|
# brand-new postgres where the role doesn't exist yet), ALTER as fallback
|
|
# (covers drift on an already-existing role) — CREATE-with-swallowed-error
|
|
# alone, as other apps' deploy functions use, can't fix drift.
|
|
msg_info "Syncing settings database credentials"
|
|
pct exec 100 -- bash -c "
|
|
docker exec hotel-manage-postgres psql -U postgres -c \
|
|
\"CREATE USER settings WITH PASSWORD '${SETTINGS_DB_PASS}';\" 2>/dev/null || \
|
|
docker exec hotel-manage-postgres psql -U postgres -c \
|
|
\"ALTER USER settings WITH PASSWORD '${SETTINGS_DB_PASS}';\"
|
|
docker exec hotel-manage-postgres psql -U postgres -c \
|
|
\"CREATE DATABASE settings_db OWNER settings;\" 2>/dev/null || true
|
|
docker exec hotel-manage-postgres psql -U postgres -d settings_db -c \
|
|
\"GRANT ALL ON SCHEMA public TO settings;\" 2>/dev/null || true
|
|
" &>/dev/null
|
|
msg_ok "Database settings_db ready"
|
|
|
|
# Copy the shared deploy SSH key so settings can SSH into the MQTT broker
|
|
# LXC (104) to manage dynamic-security clients — same mechanism management
|
|
# uses for its Shell/exec tab, just consumed by a second container.
|
|
pct exec 116 -- mkdir -p /root/.ssh
|
|
pct push 116 /root/.ssh/hotel-manage_deploy /root/.ssh/hotel-manage_deploy &>/dev/null
|
|
pct exec 116 -- chmod 600 /root/.ssh/hotel-manage_deploy
|
|
|
|
msg_info "Deploying settings service"
|
|
deploy_service 116 "settings" "${REPO_ROOT}/settings" /opt/settings
|
|
|
|
push_file 116 /opt/settings/.env <<EOF
|
|
NODE_ENV=production
|
|
DATABASE_URL=postgresql://settings:${SETTINGS_DB_PASS}@10.10.10.100:5432/settings_db
|
|
SETTINGS_SECRET=${SETTINGS_SECRET}
|
|
AUTH_URL=http://10.10.10.101:3001
|
|
MQTT_BROKER_HOST=10.10.10.104
|
|
MQTT_ADMIN_USER=${MQTT_ADMIN_USER:-}
|
|
MQTT_ADMIN_PASS=${MQTT_ADMIN_PASS:-}
|
|
EOF
|
|
|
|
pct exec 116 -- bash -c "cd /opt/settings && docker compose up -d --build" &>/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 <<EOF
|
|
NODE_ENV=production
|
|
APP_SLUG=cashup
|
|
DATABASE_URL=postgresql://cashup:${CASHUP_DB_PASS}@10.10.10.100:5432/cashup_db
|
|
CENTRAL_AUTH_SECRET=${CENTRAL_AUTH_SECRET}
|
|
SETTINGS_URL=http://10.10.10.116:3080
|
|
SETTINGS_SECRET=${SETTINGS_SECRET}
|
|
OFFICE_IP_CHECK=${OFFICE_IP_CHECK:-${OFFICE_IP:-disabled}}
|
|
FRONTEND_PORT=3083
|
|
EOF
|
|
|
|
local build_out
|
|
if ! build_out=$(pct exec 117 -- bash -c "cd /opt/cashup && docker compose up -d --build 2>&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 <<EOF
|
|
NODE_ENV=production
|
|
APP_SLUG=hk-planner
|
|
DATABASE_URL=postgresql://hk_planner:${HK_PLANNER_DB_PASS}@10.10.10.100:5432/hk_planner_db
|
|
CENTRAL_AUTH_SECRET=${CENTRAL_AUTH_SECRET}
|
|
SETTINGS_URL=http://10.10.10.116:3080
|
|
SETTINGS_SECRET=${SETTINGS_SECRET}
|
|
OFFICE_IP_CHECK=${OFFICE_IP_CHECK:-${OFFICE_IP:-disabled}}
|
|
VITE_HOTEL_NAME=${SITE_NAME}
|
|
FRONTEND_PORT=3080
|
|
EOF
|
|
|
|
local build_out
|
|
if ! build_out=$(pct exec 118 -- bash -c "cd /opt/hk-planner && docker compose up -d --build 2>&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 <<EOF
|
|
NODE_ENV=production
|
|
APP_SLUG=twin-optimiser
|
|
DATABASE_URL=postgresql://twin_optimiser:${TWIN_OPT_DB_PASS}@10.10.10.100:5432/twin_optimiser_db
|
|
CENTRAL_AUTH_SECRET=${CENTRAL_AUTH_SECRET}
|
|
SETTINGS_URL=http://10.10.10.116:3080
|
|
SETTINGS_SECRET=${SETTINGS_SECRET}
|
|
OFFICE_IP_CHECK=${OFFICE_IP_CHECK:-${OFFICE_IP:-disabled}}
|
|
VITE_HOTEL_NAME=${SITE_NAME}
|
|
FRONTEND_PORT=3080
|
|
EOF
|
|
|
|
local build_out
|
|
if ! build_out=$(pct exec 119 -- bash -c "cd /opt/twin-optimiser && docker compose up -d --build 2>&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 <<EOF
|
|
NODE_ENV=production
|
|
APP_SLUG=room-planner
|
|
DATABASE_URL=postgresql://room_planner:${ROOM_PLANNER_DB_PASS}@10.10.10.100:5432/room_planner_db
|
|
CENTRAL_AUTH_SECRET=${CENTRAL_AUTH_SECRET}
|
|
SETTINGS_URL=http://10.10.10.116:3080
|
|
SETTINGS_SECRET=${SETTINGS_SECRET}
|
|
OFFICE_IP_CHECK=${OFFICE_IP_CHECK:-${OFFICE_IP:-disabled}}
|
|
DEFAULT_CHECKOUT_TIME=11:00
|
|
FRONTEND_PORT=3080
|
|
EOF
|
|
|
|
local build_out
|
|
if ! build_out=$(pct exec 120 -- bash -c "cd /opt/room-planner && docker compose up -d --build 2>&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 <<EOF
|
|
NODE_ENV=production
|
|
APP_SLUG=maintenance
|
|
DATABASE_URL=postgresql://maintenance:${MAINTENANCE_DB_PASS}@10.10.10.100:5432/maintenance_db
|
|
CENTRAL_AUTH_SECRET=${CENTRAL_AUTH_SECRET}
|
|
SETTINGS_URL=http://10.10.10.116:3080
|
|
SETTINGS_SECRET=${SETTINGS_SECRET}
|
|
OFFICE_IP_CHECK=${OFFICE_IP_CHECK:-${OFFICE_IP:-disabled}}
|
|
FRONTEND_PORT=3080
|
|
EOF
|
|
|
|
local build_out
|
|
if ! build_out=$(pct exec 121 -- bash -c "cd /opt/maintenance && docker compose up -d --build 2>&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 <<EOF
|
|
APP_SLUG=forecasting
|
|
DATABASE_URL=postgresql://forecasting:${FORECASTING_DB_PASS}@10.10.10.100:5432/forecasting_db
|
|
CENTRAL_AUTH_SECRET=${CENTRAL_AUTH_SECRET}
|
|
SETTINGS_URL=http://10.10.10.116:3080
|
|
SETTINGS_SECRET=${SETTINGS_SECRET}
|
|
RESOS_API_KEY=${RESOS_API_KEY:-}
|
|
ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
|
|
VITE_HOTEL_NAME=${SITE_NAME:-Hotel}
|
|
EOF
|
|
|
|
# Python ML build takes 20-30 min for Prophet/CmdStan compilation + Playwright install
|
|
msg_info "Building forecasting (ML deps — this takes 20-30 min)"
|
|
local build_out
|
|
if ! build_out=$(pct exec 113 -- bash -c "cd /opt/forecasting && docker compose up -d --build 2>&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 <<EOF
|
|
APP_SLUG=rates
|
|
DATABASE_URL=postgresql://rates:${RATES_DB_PASS}@10.10.10.100:5432/rates_db
|
|
CENTRAL_AUTH_SECRET=${CENTRAL_AUTH_SECRET}
|
|
SETTINGS_URL=http://10.10.10.116:3080
|
|
SETTINGS_SECRET=${SETTINGS_SECRET}
|
|
EOF
|
|
|
|
msg_info "Building rates (Playwright install — takes 5-10 min)"
|
|
local build_out
|
|
if ! build_out=$(pct exec 115 -- bash -c "cd /opt/rates && docker compose up -d --build 2>&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 <<EOF
|
|
NODE_ENV=production
|
|
APP_SLUG=reports
|
|
DATABASE_URL=postgresql://reports:${REPORTS_DB_PASS}@10.10.10.100:5432/reports_db
|
|
FORECASTING_URL=http://10.10.10.113:3080
|
|
FORECASTING_API_KEY=${FORECASTING_API_KEY:-}
|
|
UTILITIES_URL=http://10.10.10.127:3080
|
|
UTILITIES_API_KEY=${UTILITIES_API_KEY:-}
|
|
CENTRAL_AUTH_SECRET=${CENTRAL_AUTH_SECRET}
|
|
SETTINGS_URL=http://10.10.10.116:3080
|
|
SETTINGS_SECRET=${SETTINGS_SECRET}
|
|
OFFICE_IP_CHECK=${OFFICE_IP_CHECK:-${OFFICE_IP:-disabled}}
|
|
FRONTEND_PORT=3080
|
|
RESOS_API_KEY=
|
|
SAMBA_DATABASE_URL=
|
|
EOF
|
|
|
|
local build_out
|
|
if ! build_out=$(pct exec 122 -- bash -c "cd /opt/reports && docker compose up -d --build 2>&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 <<EOF
|
|
NODE_ENV=production
|
|
APP_SLUG=wages
|
|
DATABASE_URL=postgresql://wages:${WAGES_DB_PASS}@10.10.10.100:5432/wages_db
|
|
CENTRAL_AUTH_SECRET=${CENTRAL_AUTH_SECRET}
|
|
SETTINGS_URL=http://10.10.10.116:3080
|
|
SETTINGS_SECRET=${SETTINGS_SECRET}
|
|
OFFICE_IP_CHECK=${OFFICE_IP_CHECK:-${OFFICE_IP:-disabled}}
|
|
FRONTEND_PORT=3080
|
|
EOF
|
|
|
|
local build_out
|
|
if ! build_out=$(pct exec 124 -- bash -c "cd /opt/wages && docker compose up -d --build 2>&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 <<EOF
|
|
NODE_ENV=production
|
|
APP_SLUG=utilities
|
|
DATABASE_URL=postgresql://utilities:${UTILITIES_DB_PASS}@10.10.10.100:5432/utilities_db
|
|
CENTRAL_AUTH_SECRET=${CENTRAL_AUTH_SECRET}
|
|
SETTINGS_URL=http://10.10.10.116:3080
|
|
SETTINGS_SECRET=${SETTINGS_SECRET}
|
|
OFFICE_IP_CHECK=${OFFICE_IP_CHECK:-${OFFICE_IP:-disabled}}
|
|
UTILITIES_API_KEY=${UTILITIES_API_KEY}
|
|
FRONTEND_PORT=3080
|
|
EOF
|
|
|
|
local build_out
|
|
if ! build_out=$(pct exec 127 -- bash -c "cd /opt/utilities && docker compose up -d --build 2>&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', 'Hotel', '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
|
|
# eclipse-mosquitto:2 runs as its own non-root 'mosquitto' user (uid/gid 1883)
|
|
# and — unlike older images — no longer auto-chowns bind-mounted volumes, so
|
|
# a host-created (root-owned) data/log dir leaves it unable to write either.
|
|
pct exec 104 -- chown -R 1883:1883 /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_opt_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 <user> <pass>"
|
|
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 <<EOF
|
|
NODE_ENV=production
|
|
APP_SLUG=hvac
|
|
DATABASE_URL=postgresql://hvac:${HVAC_DB_PASS}@10.10.10.100:5432/hvac_db
|
|
CENTRAL_AUTH_SECRET=${CENTRAL_AUTH_SECRET}
|
|
SETTINGS_URL=http://10.10.10.116:3080
|
|
SETTINGS_SECRET=${SETTINGS_SECRET}
|
|
OFFICE_IP_CHECK=${OFFICE_IP_CHECK:-${OFFICE_IP:-disabled}}
|
|
NEWBOOK_LOCATION_ID=${NEWBOOK_LOCATION_ID:-}
|
|
FRONTEND_PORT=3080
|
|
EOF
|
|
|
|
local build_out
|
|
if ! build_out=$(pct exec 128 -- bash -c "cd /opt/hvac && docker compose up -d --build 2>&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 <<EOF
|
|
NODE_ENV=production
|
|
APP_SLUG=calendar
|
|
DATABASE_URL=postgresql://calendar:${CALENDAR_DB_PASS}@10.10.10.100:5432/calendar_db
|
|
CENTRAL_AUTH_SECRET=${CENTRAL_AUTH_SECRET}
|
|
SETTINGS_URL=http://10.10.10.116:3080
|
|
SETTINGS_SECRET=${SETTINGS_SECRET}
|
|
OFFICE_IP_CHECK=${OFFICE_IP_CHECK:-${OFFICE_IP:-disabled}}
|
|
FRONTEND_PORT=3080
|
|
EOF
|
|
|
|
local build_out
|
|
if ! build_out=$(pct exec 126 -- bash -c "cd /opt/calendar && docker compose up -d --build 2>&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
|
|
}
|
|
|
|
# Plant-room equipment monitoring — read-only MQTT telemetry (boilers, water
|
|
# softener, calorifiers, pumps). No admin-VLAN NIC needed (unlike hvac): plant
|
|
# only subscribes to the shared MQTT broker over the internal network, it never
|
|
# talks to devices directly. Depends on deploy_mqtt_broker having already run
|
|
# (see the ordering note above deploy_mqtt_broker in the main flow below).
|
|
deploy_plant() {
|
|
msg_step "Plant Room (LXC 123 · 10.10.10.123)"
|
|
|
|
if [[ -z "${PLANT_DB_PASS:-}" ]]; then
|
|
PLANT_DB_PASS=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24)
|
|
printf '\nPLANT_DB_PASS=%s\n' "$PLANT_DB_PASS" >> "$CREDS_FILE"
|
|
msg_ok "Generated PLANT_DB_PASS → ${CREDS_FILE}"
|
|
fi
|
|
|
|
msg_info "Creating LXC 123"
|
|
create_lxc 123 "10.10.10.123" "plant" 1024 1
|
|
msg_ok "LXC 123 created"
|
|
|
|
msg_info "Installing Docker"
|
|
install_docker 123
|
|
install_mgmt_key 123
|
|
msg_ok "Docker + SSH ready"
|
|
|
|
msg_info "Creating plant database"
|
|
pct exec 100 -- bash -c "
|
|
docker exec hotel-manage-postgres psql -U postgres -c \
|
|
\"CREATE USER plant WITH PASSWORD '${PLANT_DB_PASS}';\" 2>/dev/null || true
|
|
docker exec hotel-manage-postgres psql -U postgres -c \
|
|
\"CREATE DATABASE plant_db OWNER plant;\" 2>/dev/null || true
|
|
docker exec hotel-manage-postgres psql -U postgres -d plant_db -c \
|
|
\"GRANT ALL ON SCHEMA public TO plant;\" 2>/dev/null || true
|
|
" &>/dev/null
|
|
msg_ok "Database plant_db ready"
|
|
|
|
msg_info "Deploying plant"
|
|
deploy_service 123 "plant" "${REPO_ROOT}/plant" /opt/plant
|
|
|
|
push_file 123 /opt/plant/.env <<EOF
|
|
NODE_ENV=production
|
|
APP_SLUG=plant
|
|
DATABASE_URL=postgresql://plant:${PLANT_DB_PASS}@10.10.10.100:5432/plant_db
|
|
CENTRAL_AUTH_SECRET=${CENTRAL_AUTH_SECRET}
|
|
SETTINGS_URL=http://10.10.10.116:3080
|
|
SETTINGS_SECRET=${SETTINGS_SECRET}
|
|
OFFICE_IP_CHECK=${OFFICE_IP_CHECK:-${OFFICE_IP:-disabled}}
|
|
FRONTEND_PORT=3080
|
|
EOF
|
|
|
|
local build_out
|
|
if ! build_out=$(pct exec 123 -- bash -c "cd /opt/plant && docker compose up -d --build 2>&1"); then
|
|
msg_error "docker compose build failed in LXC 123:
|
|
${build_out}"
|
|
fi
|
|
|
|
msg_info "Waiting for plant"
|
|
wait_healthy 123 "http://localhost:3080/plant/health" \
|
|
&& msg_ok "Plant Room running at 10.10.10.123:3080" \
|
|
|| msg_warn "Plant Room may need extra time — check LXC 123"
|
|
|
|
msg_info "Seeding plant 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 ('plant', 'Plant Room', 'Plant-room equipment monitoring and alerting — boilers, water softener, calorifiers and pumps via MQTT telemetry', '/plant', 'Gauge', '#0e7490', 'Hotel', '10.10.10.123', 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 the plant-room dashboard and alert list',1),
|
|
('manage_assets','Manage Assets','Create/edit assets, alert rules and asset photos',2),
|
|
('acknowledge_alerts','Acknowledge Alerts','Acknowledge and resolve triggered alerts',3),
|
|
('settings','Settings','Configure plant app settings',4)
|
|
) AS c(slug, name, description, sort_order)
|
|
WHERE a.slug = 'plant'
|
|
ON CONFLICT (app_id, slug) DO NOTHING;
|
|
" &>/dev/null \
|
|
&& msg_ok "plant seeded into auth DB" \
|
|
|| msg_warn "Seed failed — run plant/seed-app.js manually"
|
|
|
|
npm_add_location "/plant/" "10.10.10.123" 3080
|
|
|
|
msg_warn "plant deployed but the shared MQTT broker (LXC 104) is separate infra — telemetry ingestion will retry/backoff until it's reachable and the water-softener (or any other) asset is created with its mqtt_topic_prefix set on the Assets page."
|
|
}
|
|
|
|
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 <<EOF
|
|
APP_SLUG=kitchen
|
|
DATABASE_URL=postgresql://kitchen:${KITCHEN_DB_PASS}@10.10.10.100:5432/kitchen_db
|
|
CENTRAL_AUTH_SECRET=${CENTRAL_AUTH_SECRET}
|
|
STACK_INTERNAL_SECRET=${STACK_INTERNAL_SECRET:-}
|
|
SETTINGS_URL=http://10.10.10.116:3080
|
|
SETTINGS_SECRET=${SETTINGS_SECRET}
|
|
AZURE_DI_ENDPOINT=${AZURE_DI_ENDPOINT:-}
|
|
AZURE_DI_KEY=${AZURE_DI_KEY:-}
|
|
ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
|
|
OFFICE_IP_CHECK=${OFFICE_IP_CHECK:-${OFFICE_IP:-disabled}}
|
|
VITE_HOTEL_NAME=${SITE_NAME:-Hotel}
|
|
EOF
|
|
|
|
# MSSQL ODBC drivers add ~5 min to the build
|
|
msg_info "Building kitchen (MSSQL ODBC drivers — takes ~5 min)"
|
|
local build_out
|
|
if ! build_out=$(pct exec 110 -- bash -c "cd /opt/kitchen && docker compose up -d --build 2>&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
|
|
|
|
if [[ -z "${KDS_DB_PASS:-}" ]]; then
|
|
KDS_DB_PASS=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24)
|
|
printf '\nKDS_DB_PASS=%s\n' "$KDS_DB_PASS" >> "$CREDS_FILE"
|
|
msg_ok "Generated KDS_DB_PASS → ${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"
|
|
|
|
# Scoped `kds` DB role — E17: KDS used to share the `kitchen` role wholesale
|
|
# (full read/write on all 74 kitchen_db tables). Grant only what KDS
|
|
# actually touches: its own tables, read-only ResOS bookings, and the
|
|
# kds_* columns on kitchen_settings — not NewBook/ResOS/Nextcloud/Dext/
|
|
# SambaPOS-sales/Azure/Anthropic credentials that live in the same table.
|
|
# kds_tickets/kds_course_bumps don't exist yet at this point (KDS's own
|
|
# migrations create them on first boot, run via the privileged connection
|
|
# below) — that grant runs in a second pass after the container is up.
|
|
msg_info "Scoping kds DB role"
|
|
pct exec 100 -- docker exec hotel-manage-postgres psql -U postgres -d kitchen_db -c "
|
|
DO \$\$ BEGIN
|
|
CREATE ROLE kds LOGIN PASSWORD '${KDS_DB_PASS}';
|
|
EXCEPTION WHEN duplicate_object THEN
|
|
ALTER ROLE kds WITH PASSWORD '${KDS_DB_PASS}';
|
|
END \$\$;
|
|
GRANT CONNECT ON DATABASE kitchen_db TO kds;
|
|
GRANT USAGE ON SCHEMA public TO kds;
|
|
GRANT SELECT ON resos_bookings, resos_opening_hours TO kds;
|
|
GRANT SELECT (
|
|
id, kitchen_id, kds_enabled, kds_graphql_url, kds_graphql_username,
|
|
kds_graphql_password, kds_graphql_client_id, kds_poll_interval_seconds,
|
|
kds_timer_green_seconds, kds_timer_amber_seconds, kds_timer_red_seconds,
|
|
kds_course_order, kds_show_completed_for_seconds,
|
|
kds_away_timer_green_seconds, kds_away_timer_amber_seconds,
|
|
kds_away_timer_red_seconds, kds_bookings_refresh_seconds
|
|
) ON kitchen_settings TO kds;
|
|
GRANT UPDATE (
|
|
kds_enabled, kds_graphql_url, kds_graphql_username, kds_graphql_password,
|
|
kds_graphql_client_id, kds_poll_interval_seconds, kds_timer_green_seconds,
|
|
kds_timer_amber_seconds, kds_timer_red_seconds, kds_course_order,
|
|
kds_show_completed_for_seconds, kds_away_timer_green_seconds,
|
|
kds_away_timer_amber_seconds, kds_away_timer_red_seconds,
|
|
kds_bookings_refresh_seconds
|
|
) ON kitchen_settings TO kds;
|
|
" &>/dev/null \
|
|
&& msg_ok "kds DB role scoped" \
|
|
|| msg_warn "kds role setup failed — check LXC 100 postgres logs"
|
|
|
|
msg_info "Deploying kds"
|
|
deploy_service 125 "kds" "${REPO_ROOT}/kds" /opt/kds
|
|
|
|
# DATABASE_URL is the scoped `kds` role (runtime queries). MIGRATION_DATABASE_URL
|
|
# is the privileged `kitchen` role, used only at startup to create KDS's own
|
|
# tables and ALTER kitchen_settings — never touched by request handling.
|
|
push_file 125 /opt/kds/.env <<EOF
|
|
APP_SLUG=kds
|
|
DATABASE_URL=postgresql://kds:${KDS_DB_PASS}@10.10.10.100:5432/kitchen_db
|
|
MIGRATION_DATABASE_URL=postgresql://kitchen:${KITCHEN_DB_PASS}@10.10.10.100:5432/kitchen_db
|
|
CENTRAL_AUTH_SECRET=${CENTRAL_AUTH_SECRET}
|
|
VITE_HOTEL_NAME=${SITE_NAME:-Hotel}
|
|
EOF
|
|
|
|
local build_out
|
|
if ! build_out=$(pct exec 125 -- bash -c "cd /opt/kds && docker compose up -d --build 2>&1"); then
|
|
msg_error "docker compose build failed in LXC 125:
|
|
${build_out}"
|
|
fi
|
|
|
|
# Second grants pass — kds_tickets/kds_course_bumps now exist (created by
|
|
# KDS's own migrations on the boot above, via the privileged connection).
|
|
msg_info "Granting kds role access to its own tables"
|
|
pct exec 100 -- docker exec hotel-manage-postgres psql -U postgres -d kitchen_db -c "
|
|
GRANT SELECT, INSERT, UPDATE, DELETE ON kds_tickets, kds_course_bumps TO kds;
|
|
" &>/dev/null \
|
|
&& msg_ok "kds table grants applied" \
|
|
|| msg_warn "kds table grants failed — check kds_tickets/kds_course_bumps exist, then re-run"
|
|
|
|
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', '#ea580c', '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 <<SUMMARY
|
|
╔══════════════════════════════════════════════════════════════╗
|
|
║ Hotel Manage — Stack Deployed ║
|
|
╚══════════════════════════════════════════════════════════════╝
|
|
SUMMARY
|
|
printf "${CL}"
|
|
|
|
cat <<SUMMARY
|
|
|
|
Site: ${SITE_NAME}
|
|
Domain: https://${DOMAIN}
|
|
|
|
── Services ──────────────────────────────────────────────────
|
|
LXC Hostname IP Port Status
|
|
100 hotel-manage-postgres 10.10.10.100 5432 (internal only)
|
|
101 hotel-manage-auth 10.10.10.101 3001 /api/auth/*
|
|
102 hotel-manage-portal 10.10.10.102 3000 /
|
|
103 hotel-manage-npm 10.10.10.3 80/443 entry point
|
|
(LAN) ${NPM_LAN_IP} 81 NPM admin
|
|
105 hotel-manage-management 10.10.10.105 3002 Uptime Kuma
|
|
9000 Forgejo webhooks
|
|
112 hotel-manage-noticeboard 10.10.10.112 3080 /notices/
|
|
116 hotel-manage-settings 10.10.10.116 3080 /settings/api/
|
|
117 hotel-manage-cashup 10.10.10.117 3083 /cashup/
|
|
118 hotel-manage-hk-planner 10.10.10.118 3080 /hk-planner/
|
|
119 hotel-manage-twin-optimiser 10.10.10.119 3080 /twin-optimiser/
|
|
|
|
── Credentials ───────────────────────────────────────────────
|
|
Admin login: ${ADMIN_EMAIL}
|
|
Credentials: /root/hotel-manage-credentials.txt (chmod 600)
|
|
|
|
── Next steps ────────────────────────────────────────────────
|
|
1. Point DNS: ${DOMAIN} → ${NPM_LAN_IP}
|
|
|
|
2. NPM admin UI: http://${NPM_LAN_IP}:81
|
|
Default: admin@example.com / changeme
|
|
→ Change password → Add SSL cert for ${DOMAIN}
|
|
→ Verify proxy host paths are routing correctly
|
|
|
|
3. Set up Uptime Kuma monitors:
|
|
http://10.10.10.105:3002
|
|
Health endpoints to monitor:
|
|
http://10.10.10.101:3001/health (auth)
|
|
http://10.10.10.102:3000/health (portal)
|
|
http://10.10.10.112:3080/notices/health (noticeboard)
|
|
|
|
4. Forgejo webhooks (when repos are pushed):
|
|
URL: http://10.10.10.105:9000/webhook
|
|
Secret: ${WEBHOOK_SECRET}
|
|
Events: Push
|
|
|
|
5. To add an app LXC later, on this host run:
|
|
bash <(curl -fsSL https://git.pterois.co.uk/hotel-manage-stack/stack-init/raw/branch/main/add-app.sh)
|
|
|
|
SUMMARY
|
|
}
|
|
|
|
# ════════════════════════════════════════════════════════════════════════════
|
|
# ENTRY POINT
|
|
# ════════════════════════════════════════════════════════════════════════════
|
|
|
|
# Single-service mode: install-stack.sh --only <service>
|
|
# 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 plant"
|
|
[[ -z "$ONLY" ]] && msg_error "Usage: install-stack.sh --only <service> (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 "${KDS_DB_PASS:-}" ]] && _append_secret KDS_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 ;;
|
|
plant) deploy_plant ;;
|
|
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
|
|
# mqtt-broker before settings: settings' own deploy threads MQTT_ADMIN_USER/PASS
|
|
# into its .env, which only exist in the creds file once the broker has run.
|
|
deploy_mqtt_broker
|
|
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_hvac
|
|
deploy_calendar
|
|
deploy_plant
|
|
configure_npm_proxy_hosts
|
|
print_summary
|