stack-init/install-stack.sh
jtricerolph 68e6c951fd settings: re-assert DB role password on every deploy, not just first install
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>
2026-08-13 12:21:02 +00:00

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