#!/usr/bin/env bash # ┌─────────────────────────────────────────────────────────────────────────┐ # │ Hotel Manage — Proxmox Stack Installer │ # │ Provisions: postgres · auth · portal · npm · management · noticeboard │ # │ │ # │ Run on the Proxmox host shell: │ # │ bash install-stack.sh │ # │ │ # │ Or from Forgejo once repos are pushed: │ # │ bash <(curl -fsSL https://git.pterois.co.uk/hotel-manage-stack/stack-init/raw/branch/main/install-stack.sh) # └─────────────────────────────────────────────────────────────────────────┘ set -euo pipefail # ── Colour helpers ──────────────────────────────────────────────────────────── YW="\033[33m"; BL="\033[36m"; RD="\033[01;31m" GN="\033[1;92m"; DGN="\033[32m"; CL="\033[m" BFR="\\r\\033[K"; CM="${GN}✓${CL}"; CROSS="${RD}✗${CL}" msg_info() { printf " ◌ ${YW}%-55s${CL}" "$*"; } msg_ok() { printf "${BFR} ${CM} ${DGN}%s${CL}\n" "$*"; } msg_error() { printf "${BFR} ${CROSS} ${RD}%s${CL}\n" "$*"; exit 1; } msg_warn() { printf "\n ${CROSS} ${YW}%s${CL}\n" "$*"; } msg_step() { printf "\n${BL}── %s ─────────────────────────────${CL}\n" "$*"; } header_info() { clear printf "${BL}" cat <<'BANNER' ╔══════════════════════════════════════════════════════════════╗ ║ HOTEL MANAGE — PROXMOX STACK INSTALLER ║ ║ postgres · auth · portal · npm · mgmt · noticeboard · settings ║ ╚══════════════════════════════════════════════════════════════╝ BANNER printf "${CL}\n" } # ── Pre-flight ──────────────────────────────────────────────────────────────── [[ $EUID -ne 0 ]] && msg_error "Must run as root on the Proxmox VE host" command -v pct &>/dev/null || msg_error "pct not found — run this on a Proxmox VE host" command -v pvesm &>/dev/null || msg_error "pvesm not found — run this on a Proxmox VE host" command -v whiptail &>/dev/null || { apt-get install -y -qq whiptail &>/dev/null; } command -v openssl &>/dev/null || { apt-get install -y -qq openssl &>/dev/null; } # Defensive — when run via `bash <(curl ...)` BASH_SOURCE is a pipe, not a file SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd || echo /tmp)" REPO_ROOT="$(cd "${SCRIPT_DIR}/.." 2>/dev/null && pwd || echo /tmp)" header_info # ── Detect storage pool ─────────────────────────────────────────────────────── detect_storage() { if pvesm status 2>/dev/null | awk '{print $1}' | grep -q "^local-lvm$"; then echo "local-lvm" elif pvesm status 2>/dev/null | awk '{print $1}' | grep -q "^local-zfs$"; then echo "local-zfs" else echo "local" fi } STORAGE=$(detect_storage) # ── Internal network: bridge + NAT ──────────────────────────────────────────── # The isolated vmbr1 needs outbound NAT so LXCs can apt-install Docker, git # clone, and pull images. Persisted via an if-up.d hook so it survives reboot. ensure_nat() { local wan; wan=$(ip route show default 2>/dev/null | awk '{print $5; exit}'); wan=${wan:-vmbr0} msg_info "Enabling internal NAT (10.10.10.0/24 → ${wan})" sysctl -wq net.ipv4.ip_forward=1 2>/dev/null || echo 1 > /proc/sys/net/ipv4/ip_forward echo 'net.ipv4.ip_forward=1' > /etc/sysctl.d/99-hotel-manage.conf iptables -t nat -C POSTROUTING -s 10.10.10.0/24 ! -d 10.10.10.0/24 -o "$wan" -j MASQUERADE 2>/dev/null \ || iptables -t nat -A POSTROUTING -s 10.10.10.0/24 ! -d 10.10.10.0/24 -o "$wan" -j MASQUERADE cat > /etc/network/if-up.d/hotel-manage-nat <<'HOOK' #!/bin/sh [ "$IFACE" = "vmbr1" ] || exit 0 WAN=$(ip route show default | awk '{print $5; exit}'); WAN=${WAN:-vmbr0} sysctl -w net.ipv4.ip_forward=1 >/dev/null 2>&1 iptables -t nat -C POSTROUTING -s 10.10.10.0/24 ! -d 10.10.10.0/24 -o "$WAN" -j MASQUERADE 2>/dev/null \ || iptables -t nat -A POSTROUTING -s 10.10.10.0/24 ! -d 10.10.10.0/24 -o "$WAN" -j MASQUERADE HOOK chmod +x /etc/network/if-up.d/hotel-manage-nat msg_ok "Internal NAT enabled (via ${wan})" } check_vmbr1() { if ip link show vmbr1 &>/dev/null; then ensure_nat # bridge exists — make sure NAT/forwarding is in place return fi if whiptail --title "Create internal bridge vmbr1?" --yesno \ "The internal container bridge vmbr1 (10.10.10.1/24) doesn't exist yet. Create it now? This adds an ISOLATED bridge with no physical ports, so it cannot affect your LAN or vmbr0. It appends a stanza to /etc/network/interfaces, applies it with ifreload -a, and enables outbound NAT so the containers can reach the internet." 15 68; then if grep -qE '^\s*iface\s+vmbr1' /etc/network/interfaces 2>/dev/null; then msg_warn "vmbr1 already defined in /etc/network/interfaces — applying it" else cat >> /etc/network/interfaces <<'EOF' auto vmbr1 iface vmbr1 inet static address 10.10.10.1/24 bridge-ports none bridge-stp off bridge-fd 0 EOF fi msg_info "Bringing up vmbr1" ifreload -a &>/dev/null || systemctl restart networking &>/dev/null || true sleep 2 if ip link show vmbr1 &>/dev/null; then msg_ok "vmbr1 up (10.10.10.1/24)" else msg_error "vmbr1 still not up — check /etc/network/interfaces and re-run" fi ensure_nat else whiptail --title "vmbr1 Missing" --msgbox \ "Cancelled. Add this to /etc/network/interfaces, run 'ifreload -a', then re-run the installer: auto vmbr1 iface vmbr1 inet static address 10.10.10.1/24 bridge-ports none bridge-stp off bridge-fd 0" 16 66 exit 1 fi } # ── Ensure Ubuntu 22.04 template ───────────────────────────────────────────── ensure_template() { # NOTE: progress goes to stderr — stdout is captured as the template volid. local tmpl tmpl=$(pveam list local 2>/dev/null | awk '/ubuntu-22\.04-standard/{print $1; exit}') if [[ -z "$tmpl" ]]; then msg_info "Downloading Ubuntu 22.04 LXC template" >&2 pveam update &>/dev/null || true # Resolve the exact filename currently offered (pinned names go stale) local avail avail=$(pveam available --section system 2>/dev/null \ | awk '/ubuntu-22\.04-standard/{print $2}' | sort -V | tail -1) [[ -n "$avail" ]] || { msg_error "No ubuntu-22.04 template available via pveam" >&2; exit 1; } pveam download local "$avail" &>/dev/null || { msg_error "Template download failed" >&2; exit 1; } msg_ok "Template downloaded" >&2 tmpl="$avail" fi # Return full path for pct create echo "local:vztmpl/${tmpl##*/}" } # ── Collect site config ─────────────────────────────────────────────────────── collect_config() { SITE_NAME=$(whiptail --title "Hotel Manage — Site Config" \ --inputbox "Site name:" 8 52 "Hotel Number Four" 3>&1 1>&2 2>&3) || exit 0 DOMAIN=$(whiptail --title "Hotel Manage — Site Config" \ --inputbox "Public domain (e.g. manage.hotelnumberfour.com):" 8 64 "manage.hotelnumberfour.com" \ 3>&1 1>&2 2>&3) || exit 0 NPM_LAN_IP=$(whiptail --title "Hotel Manage — Site Config" \ --inputbox "NPM LXC static LAN IP (from your hotel LAN pool):" 8 64 "10.4.0.50" \ 3>&1 1>&2 2>&3) || exit 0 LAN_GW=$(whiptail --title "Hotel Manage — Site Config" \ --inputbox "LAN gateway IP:" 8 52 "10.4.0.1" 3>&1 1>&2 2>&3) || exit 0 OFFICE_IP=$(whiptail --title "Hotel Manage — Site Config" \ --inputbox \ "Onsite matcher(s) for offsite restriction — comma-separated, any match = onsite. Each can be: an IP, a CIDR, a DDNS hostname, or 'auto' (self-detect the site's public IP — no DDNS service needed). 'disabled' allows access from anywhere. Recommended for a dynamic public IP: 10.4.0.0/22,auto (LAN clients match the CIDR; public-IP/hairpin clients match auto)" \ 13 72 "10.4.0.0/22,auto" 3>&1 1>&2 2>&3) || exit 0 ADMIN_EMAIL=$(whiptail --title "Hotel Manage — Admin Account" \ --inputbox "Admin user email:" 8 52 "" 3>&1 1>&2 2>&3) || exit 0 ADMIN_PASS=$(whiptail --title "Hotel Manage — Admin Account" \ --passwordbox "Admin user password:" 8 52 3>&1 1>&2 2>&3) || exit 0 # Deploy source — per-service Forgejo repos (default) or a local copy on this host FORGEJO_BASE=$(whiptail --title "Hotel Manage — Forgejo" \ --inputbox \ "Forgejo org/base URL hosting the per-service repos. Each service is cloned from /.git → auth portal management noticeboard Example: https://git.pterois.co.uk/hotel-manage-stack" \ 13 66 "https://git.pterois.co.uk/hotel-manage-stack" 3>&1 1>&2 2>&3) || exit 0 FORGEJO_BASE="${FORGEJO_BASE%/}" FORGEJO_TOKEN=$(whiptail --title "Hotel Manage — Forgejo Token" \ --passwordbox \ "The repos are PUBLIC — leave this blank. (Only needed if you make them private again: an access token from Forgejo → Settings → Applications, scope read:repository, which gets embedded in each LXC's git remote for the updater.)" \ 12 66 3>&1 1>&2 2>&3) || exit 0 if whiptail --title "Hotel Manage — Deploy Source" --yesno \ "Deploy services from Forgejo? (recommended)\n\nNo = copy from a local repo at ${REPO_ROOT}\n(only works if you already copied the repo to this host)" \ 11 62; then USE_FORGEJO=true else USE_FORGEJO=false fi BACKUP_REMOTE=$(whiptail --title "Hotel Manage — Backup" \ --inputbox \ "Backup rsync target (leave blank to skip backup config). Example: backup@192.168.1.10:/backups/hotel-manage" \ 10 64 "" 3>&1 1>&2 2>&3) || exit 0 # Confirm LXC allocation whiptail --title "Hotel Manage — Confirm" --yesno \ "LXCs to be created (storage: ${STORAGE}): ID Hostname IP ────────────────────────────────────────── 100 hotel-manage-postgres 10.10.10.100 101 hotel-manage-auth 10.10.10.101 102 hotel-manage-portal 10.10.10.102 103 hotel-manage-npm 10.10.10.103 / ${NPM_LAN_IP} (dual-homed) 105 hotel-manage-management 10.10.10.105 112 hotel-manage-noticeboard 10.10.10.112 116 hotel-manage-settings 10.10.10.116 Domain: ${DOMAIN} Admin: ${ADMIN_EMAIL} Proceed?" 26 58 || exit 0 } # ── Generate secrets ────────────────────────────────────────────────────────── CREDS_FILE=/root/hotel-manage-credentials.txt gen_secrets() { # Resume-safe: if secrets already exist, reuse them so a re-run doesn't # mismatch an already-initialised Postgres / already-deployed services. if [[ -f "$CREDS_FILE" ]] && grep -q '^PG_SUPERPASS=' "$CREDS_FILE"; then msg_info "Reusing existing secrets from $CREDS_FILE" PG_SUPERPASS=$(sed -n 's/^PG_SUPERPASS=//p' "$CREDS_FILE" | head -1) AUTH_DB_PASS=$(sed -n 's/^AUTH_DB_PASS=//p' "$CREDS_FILE" | head -1) NOTICES_DB_PASS=$(sed -n 's/^NOTICES_DB_PASS=//p' "$CREDS_FILE" | head -1) SETTINGS_DB_PASS=$(sed -n 's/^SETTINGS_DB_PASS=//p' "$CREDS_FILE" | head -1) CASHUP_DB_PASS=$(sed -n 's/^CASHUP_DB_PASS=//p' "$CREDS_FILE" | head -1) HK_PLANNER_DB_PASS=$(sed -n 's/^HK_PLANNER_DB_PASS=//p' "$CREDS_FILE" | head -1) TWIN_OPT_DB_PASS=$(sed -n 's/^TWIN_OPT_DB_PASS=//p' "$CREDS_FILE" | head -1) ROOM_PLANNER_DB_PASS=$(sed -n 's/^ROOM_PLANNER_DB_PASS=//p' "$CREDS_FILE" | head -1) CENTRAL_AUTH_SECRET=$(sed -n 's/^CENTRAL_AUTH_SECRET=//p' "$CREDS_FILE" | head -1) SETTINGS_SECRET=$(sed -n 's/^SETTINGS_SECRET=//p' "$CREDS_FILE" | head -1) WEBHOOK_SECRET=$(sed -n 's/^WEBHOOK_SECRET=//p' "$CREDS_FILE" | head -1) NPM_LAN_IP=$(sed -n 's/^NPM_LAN_IP=//p' "$CREDS_FILE" | head -1) NPM_ADMIN_EMAIL=$(sed -n 's/^NPM_ADMIN_EMAIL=//p' "$CREDS_FILE" | head -1) NPM_ADMIN_PASS=$(sed -n 's/^NPM_ADMIN_PASS=//p' "$CREDS_FILE" | head -1) msg_ok "Reusing existing secrets" return fi msg_info "Generating secrets" PG_SUPERPASS=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24) AUTH_DB_PASS=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24) NOTICES_DB_PASS=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24) SETTINGS_DB_PASS=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24) CASHUP_DB_PASS=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24) HK_PLANNER_DB_PASS=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24) TWIN_OPT_DB_PASS=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24) ROOM_PLANNER_DB_PASS=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24) CENTRAL_AUTH_SECRET=$(openssl rand -hex 32) SETTINGS_SECRET=$(openssl rand -hex 32) WEBHOOK_SECRET=$(openssl rand -hex 24) NPM_ADMIN_PASS=$(openssl rand -base64 12 | tr -dc 'a-zA-Z0-9' | head -c 12) cat > "$CREDS_FILE" </dev/null msg_ok "SSH keypair generated → /root/.ssh/hotel-manage_deploy" else msg_ok "Using existing SSH keypair at /root/.ssh/hotel-manage_deploy" fi MGMT_PUBKEY=$(cat /root/.ssh/hotel-manage_deploy.pub) # record the pubkey once (don't duplicate on re-run) grep -qF "${MGMT_PUBKEY}" "$CREDS_FILE" 2>/dev/null || echo "${MGMT_PUBKEY}" >> "$CREDS_FILE" } # ── LXC lifecycle helpers ───────────────────────────────────────────────────── TEMPLATE_PATH="" get_template() { [[ -n "$TEMPLATE_PATH" ]] && { echo "$TEMPLATE_PATH"; return; } TEMPLATE_PATH=$(ensure_template) echo "$TEMPLATE_PATH" } lxc_exists() { pct status "$1" &>/dev/null; } lxc_running() { pct status "$1" 2>/dev/null | grep -q "running" } # Group the stack in a Proxmox resource pool + tag for tidy UI organisation. POOL="hotel-manage" POOL_OPT="" ensure_pool() { if ! pvesh get "/pools/${POOL}" &>/dev/null; then msg_info "Creating Proxmox resource pool '${POOL}'" pvesh create /pools --poolid "${POOL}" --comment "Hotel Manage stack" &>/dev/null || true pvesh get "/pools/${POOL}" &>/dev/null && msg_ok "Pool '${POOL}' ready" \ || msg_warn "Could not create pool '${POOL}' — continuing without it" fi # Only pass --pool if it actually exists (else pct create would fail) pvesh get "/pools/${POOL}" &>/dev/null && POOL_OPT="--pool ${POOL}" } # Docker-in-LXC: keep the nesting=1 feature (needed by Docker) and let each # container skip AppArmor via `security_opt: apparmor=unconfined` in its compose. # (Overriding lxc.apparmor.profile would cancel nesting — don't do that.) create_lxc() { local id=$1 ip=$2 name=$3 mem=${4:-512} cores=${5:-1} if lxc_exists "$id"; then msg_warn "LXC $id (hotel-manage-${name}) already exists — skipping creation" lxc_running "$id" || pct start "$id" return fi local tmpl; tmpl=$(get_template) pct create "$id" "$tmpl" \ --hostname "hotel-manage-${name}" \ --memory "$mem" \ --cores "$cores" \ --rootfs "${STORAGE}:8" \ --net0 "name=eth0,bridge=vmbr1,ip=${ip}/24,gw=10.10.10.1" \ --features nesting=1 \ --unprivileged 0 \ --onboot 1 \ --tags "${POOL}" \ ${POOL_OPT} \ --start 1 &>/dev/null sleep 5 } create_npm_lxc() { local id=103 if lxc_exists "$id"; then msg_warn "LXC $id (hotel-manage-npm) already exists — skipping creation" lxc_running "$id" || pct start "$id" return fi local tmpl; tmpl=$(get_template) pct create "$id" "$tmpl" \ --hostname "hotel-manage-npm" \ --memory 512 \ --cores 1 \ --rootfs "${STORAGE}:8" \ --net0 "name=eth0,bridge=vmbr0,ip=${NPM_LAN_IP}/22,gw=${LAN_GW}" \ --net1 "name=eth1,bridge=vmbr1,ip=10.10.10.3/24" \ --features nesting=1 \ --unprivileged 0 \ --onboot 1 \ --tags "${POOL}" \ ${POOL_OPT} \ --start 1 &>/dev/null sleep 5 } install_docker() { local id=$1 pct exec "$id" -- bash -s &>/dev/null <<'DOCKER_INSTALL' export DEBIAN_FRONTEND=noninteractive # Docker-in-LXC: neutralise AppArmor so Docker never tries to load a profile. # In a confined LXC that fails ("docker-default ... while confined") for BOTH # image builds and container runtime. Removing apparmor_parser makes Docker # run everything unconfined — the container itself is the isolation boundary. if [ -e /usr/sbin/apparmor_parser ]; then mv -f /usr/sbin/apparmor_parser /usr/sbin/apparmor_parser.disabled systemctl is-active --quiet docker && systemctl restart docker fi # Idempotent: if Docker's already here, just make sure it's running and bail. if command -v docker >/dev/null 2>&1; then systemctl enable --now docker ssh 2>/dev/null exit 0 fi apt-get update -qq 2>/dev/null apt-get install -y -qq ca-certificates curl gnupg git openssh-server 2>/dev/null install -m 0755 -d /etc/apt/keyrings rm -f /etc/apt/keyrings/docker.gpg curl -fsSL https://download.docker.com/linux/ubuntu/gpg \ | gpg --batch --yes --dearmor -o /etc/apt/keyrings/docker.gpg 2>/dev/null echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.gpg] \ https://download.docker.com/linux/ubuntu jammy stable" \ > /etc/apt/sources.list.d/docker.list apt-get update -qq 2>/dev/null apt-get install -y -qq docker-ce docker-ce-cli containerd.io docker-compose-plugin 2>/dev/null systemctl enable --now docker 2>/dev/null systemctl enable --now ssh 2>/dev/null DOCKER_INSTALL } install_mgmt_key() { local id=$1 pct exec "$id" -- bash -c " mkdir -p /root/.ssh chmod 700 /root/.ssh grep -qF '${MGMT_PUBKEY}' /root/.ssh/authorized_keys 2>/dev/null || \ echo '${MGMT_PUBKEY}' >> /root/.ssh/authorized_keys chmod 600 /root/.ssh/authorized_keys " &>/dev/null } push_file() { # Write content to a temp file, push into LXC, remove temp local id=$1 dest=$2; shift 2 local tmp; tmp=$(mktemp /tmp/hotel-manage-push-XXXX) cat > "$tmp" # reads stdin pct push "$id" "$tmp" "$dest" 2>/dev/null rm -f "$tmp" } push_dir() { # tar local dir → push tarball → extract in LXC at parent of dest local id=$1 src=$2 dest=$3 local tmp; tmp=$(mktemp /tmp/hotel-manage-dir-XXXX.tar.gz) tar czf "$tmp" -C "$(dirname "$src")" "$(basename "$src")" 2>/dev/null pct push "$id" "$tmp" /tmp/hotel-manage-deploy.tar.gz 2>/dev/null pct exec "$id" -- bash -c " mkdir -p '$(dirname "$dest")' tar xzf /tmp/hotel-manage-deploy.tar.gz -C '$(dirname "$dest")' mv '$(dirname "$dest")/$(basename "$src")' '${dest}' 2>/dev/null || true rm -f /tmp/hotel-manage-deploy.tar.gz " &>/dev/null rm -f "$tmp" } build_clone_url() { # Inject the Forgejo token into the clone URL so private repos work and the # updater can pull later without extra credentials. local repo=$1 local url="${FORGEJO_BASE}/${repo}.git" if [[ -n "${FORGEJO_TOKEN:-}" ]]; then url="${url/https:\/\//https://oauth2:${FORGEJO_TOKEN}@}" url="${url/http:\/\//http://oauth2:${FORGEJO_TOKEN}@}" fi echo "$url" } deploy_service() { # Either clone from Forgejo or push from a local repo copy on this host local id=$1 repo_name=$2 local_src=$3 dest=$4 if [[ "$USE_FORGEJO" == "true" ]]; then local url; url=$(build_clone_url "$repo_name") local tmp; tmp=$(mktemp /tmp/hotel-manage-deploy-out-XXXX) if ! pct exec "$id" -- bash -c " if [ -d '${dest}/.git' ]; then cd '${dest}' && git pull else git clone '${url}' '${dest}' fi " > "$tmp" 2>&1; then local out; out=$(cat "$tmp"); rm -f "$tmp" msg_error "Deploy of ${repo_name} to LXC ${id} failed: ${out} Debug: pct enter ${id} && git clone ${FORGEJO_BASE}/${repo_name}.git /tmp/test-clone" fi rm -f "$tmp" else push_dir "$id" "$local_src" "$dest" fi } wait_healthy() { local id=$1 url=$2 max=${3:-40} local i=0 while ! pct exec "$id" -- curl -sf --max-time 2 "$url" &>/dev/null; do sleep 3; i=$((i+1)) [[ $i -ge $max ]] && return 1 done return 0 } wait_pg() { local max=30 i=0 while ! pct exec 100 -- bash -c \ "docker exec hotel-manage-postgres pg_isready -U postgres" &>/dev/null; do sleep 3; i=$((i+1)) [[ $i -ge $max ]] && { msg_warn "Postgres not ready after 90s"; return 1; } done } # ════════════════════════════════════════════════════════════════════════════ # PHASE 1 — POSTGRES LXC 100 # ════════════════════════════════════════════════════════════════════════════ deploy_postgres() { msg_step "1/7 Postgres (LXC 100 · 10.10.10.100)" msg_info "Creating LXC 100" create_lxc 100 "10.10.10.100" "postgres" 512 1 msg_ok "LXC 100 created" msg_info "Installing Docker" install_docker 100 msg_ok "Docker installed" msg_info "Deploying postgres" pct exec 100 -- mkdir -p /opt/postgres/init # docker-compose.yml push_file 100 /opt/postgres/docker-compose.yml <<'EOF' services: postgres: container_name: hotel-manage-postgres image: postgres:16-alpine security_opt: - apparmor=unconfined environment: - POSTGRES_USER=postgres - POSTGRES_PASSWORD=${PG_SUPERPASS} volumes: - pg_data:/var/lib/postgresql/data - ./init:/docker-entrypoint-initdb.d:ro ports: - "5432:5432" healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 5s retries: 15 restart: unless-stopped volumes: pg_data: EOF # .env push_file 100 /opt/postgres/.env </dev/null msg_info "Waiting for postgres to be ready" wait_pg && msg_ok "Postgres running at 10.10.10.100:5432" || msg_warn "Postgres may need extra time — check LXC 100" install_mgmt_key 100 } # ════════════════════════════════════════════════════════════════════════════ # PHASE 2 — AUTH SERVICE LXC 101 # ════════════════════════════════════════════════════════════════════════════ deploy_auth() { msg_step "2/7 Auth service (LXC 101 · 10.10.10.101)" msg_info "Creating LXC 101" create_lxc 101 "10.10.10.101" "auth" 1024 1 msg_ok "LXC 101 created" msg_info "Installing Docker" install_docker 101 install_mgmt_key 101 msg_ok "Docker + SSH ready" msg_info "Deploying auth service" deploy_service 101 "auth" "${REPO_ROOT}/auth" /opt/auth push_file 101 /opt/auth/.env </dev/null msg_info "Waiting for auth service" wait_healthy 101 "http://localhost:3001/health" \ && msg_ok "Auth service running at 10.10.10.101:3001" \ || msg_warn "Auth service may need extra time — check LXC 101" } # ════════════════════════════════════════════════════════════════════════════ # PHASE 3 — PORTAL LXC 102 # ════════════════════════════════════════════════════════════════════════════ deploy_portal() { msg_step "3/7 Portal (LXC 102 · 10.10.10.102)" msg_info "Creating LXC 102" create_lxc 102 "10.10.10.102" "portal" 1024 2 msg_ok "LXC 102 created" msg_info "Installing Docker" install_docker 102 install_mgmt_key 102 msg_ok "Docker + SSH ready" msg_info "Deploying portal" deploy_service 102 "portal" "${REPO_ROOT}/portal" /opt/portal push_file 102 /opt/portal/.env </dev/null msg_info "Waiting for portal" wait_healthy 102 "http://localhost:3000/health" \ && msg_ok "Portal running at 10.10.10.102:3000" \ || msg_warn "Portal may need extra time — check LXC 102" } # ════════════════════════════════════════════════════════════════════════════ # PHASE 4 — NPM LXC 103 (dual-homed) # ════════════════════════════════════════════════════════════════════════════ deploy_npm() { msg_step "4/7 Nginx Proxy Manager (LXC 103 · ${NPM_LAN_IP} / 10.10.10.3)" msg_info "Creating NPM LXC 103 (dual-homed)" create_npm_lxc msg_ok "LXC 103 created" msg_info "Installing Docker" install_docker 103 msg_ok "Docker installed" msg_info "Deploying NPM" pct exec 103 -- mkdir -p /opt/npm push_file 103 /opt/npm/docker-compose.yml <<'EOF' services: npm: container_name: hotel-manage-npm image: jc21/nginx-proxy-manager:latest security_opt: - apparmor=unconfined ports: - "80:80" - "443:443" - "81:81" volumes: - npm_data:/data - npm_letsencrypt:/etc/letsencrypt restart: unless-stopped volumes: npm_data: npm_letsencrypt: EOF pct exec 103 -- bash -c "cd /opt/npm && docker compose up -d" &>/dev/null msg_info "Waiting for NPM admin UI" # NPM admin API on port 81 — wait up to 60s local i=0 while ! pct exec 103 -- curl -sf --max-time 3 "http://localhost:81/api/" &>/dev/null; do sleep 3; i=$((i+1)); [[ $i -ge 20 ]] && break done msg_ok "NPM running — admin UI at http://${NPM_LAN_IP}:81" msg_warn "NPM default login: admin@example.com / changeme (change immediately!)" } # ════════════════════════════════════════════════════════════════════════════ # PHASE 5 — MANAGEMENT LXC 105 # ════════════════════════════════════════════════════════════════════════════ deploy_management() { msg_step "5/7 Management (LXC 105 · 10.10.10.105)" msg_info "Creating LXC 105" create_lxc 105 "10.10.10.105" "management" 1024 1 msg_ok "LXC 105 created" msg_info "Installing Docker" install_docker 105 msg_ok "Docker installed" # Copy the management SSH private key into management container pct exec 105 -- mkdir -p /root/.ssh pct push 105 /root/.ssh/hotel-manage_deploy /root/.ssh/hotel-manage_deploy &>/dev/null pct exec 105 -- chmod 600 /root/.ssh/hotel-manage_deploy msg_info "Deploying management stack" deploy_service 105 "management" "${REPO_ROOT}/management" /opt/management push_file 105 /opt/management/.env </dev/null msg_info "Waiting for Uptime Kuma" wait_healthy 105 "http://localhost:3002" \ && msg_ok "Management running — Kuma at 10.10.10.105:3002" \ || msg_warn "Management may need extra time — check LXC 105" } # ════════════════════════════════════════════════════════════════════════════ # PHASE 6 — NOTICEBOARD LXC 112 # ════════════════════════════════════════════════════════════════════════════ deploy_noticeboard() { msg_step "6/7 Noticeboard (LXC 112 · 10.10.10.112)" msg_info "Creating LXC 112" create_lxc 112 "10.10.10.112" "noticeboard" 1024 1 msg_ok "LXC 112 created" msg_info "Installing Docker" install_docker 112 install_mgmt_key 112 msg_ok "Docker + SSH ready" msg_info "Deploying noticeboard" deploy_service 112 "noticeboard" "${REPO_ROOT}/noticeboard" /opt/noticeboard push_file 112 /opt/noticeboard/.env </dev/null msg_info "Waiting for noticeboard" wait_healthy 112 "http://localhost:3080/notices/health" \ && msg_ok "Noticeboard running at 10.10.10.112:3080" \ || msg_warn "Noticeboard may need extra time — check LXC 112" } # ════════════════════════════════════════════════════════════════════════════ # PHASE 7 — SETTINGS SERVICE LXC 116 # ════════════════════════════════════════════════════════════════════════════ deploy_settings() { msg_step "7/7 Settings service (LXC 116 · 10.10.10.116)" if [[ -z "${SETTINGS_DB_PASS:-}" ]]; then SETTINGS_DB_PASS=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24) printf '\nSETTINGS_DB_PASS=%s\n' "$SETTINGS_DB_PASS" >> "$CREDS_FILE" msg_ok "Generated SETTINGS_DB_PASS → ${CREDS_FILE}" fi if [[ -z "${SETTINGS_SECRET:-}" ]]; then SETTINGS_SECRET=$(openssl rand -hex 32) printf 'SETTINGS_SECRET=%s\n' "$SETTINGS_SECRET" >> "$CREDS_FILE" msg_ok "Generated SETTINGS_SECRET → ${CREDS_FILE}" fi msg_info "Creating LXC 116" create_lxc 116 "10.10.10.116" "settings" 512 1 msg_ok "LXC 116 created" msg_info "Installing Docker" install_docker 116 install_mgmt_key 116 msg_ok "Docker + SSH ready" msg_info "Deploying settings service" deploy_service 116 "settings" "${REPO_ROOT}/settings" /opt/settings push_file 116 /opt/settings/.env </dev/null msg_info "Waiting for settings service" wait_healthy 116 "http://localhost:3080/health" \ && msg_ok "Settings service running at 10.10.10.116:3080" \ || msg_warn "Settings may need extra time — check LXC 116" } # ════════════════════════════════════════════════════════════════════════════ # PHASE 8 — CASHUP LXC 117 # ════════════════════════════════════════════════════════════════════════════ deploy_cashup() { msg_step "8/8 Cashup (LXC 117 · 10.10.10.117)" # Generate DB password if not already in creds file (--only cashup on existing stack) if [[ -z "${CASHUP_DB_PASS:-}" ]]; then CASHUP_DB_PASS=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24) printf '\nCASHUP_DB_PASS=%s\n' "$CASHUP_DB_PASS" >> "$CREDS_FILE" msg_ok "Generated CASHUP_DB_PASS → ${CREDS_FILE}" fi msg_info "Creating LXC 117" create_lxc 117 "10.10.10.117" "cashup" 1024 1 msg_ok "LXC 117 created" msg_info "Installing Docker" install_docker 117 install_mgmt_key 117 msg_ok "Docker + SSH ready" # Create DB — idempotent via || true, works on fresh installs and re-runs msg_info "Creating cashup database" pct exec 100 -- bash -c " docker exec hotel-manage-postgres psql -U postgres -c \ \"CREATE USER cashup WITH PASSWORD '${CASHUP_DB_PASS}';\" 2>/dev/null || true docker exec hotel-manage-postgres psql -U postgres -c \ \"CREATE DATABASE cashup_db OWNER cashup;\" 2>/dev/null || true docker exec hotel-manage-postgres psql -U postgres -d cashup_db -c \ \"GRANT ALL ON SCHEMA public TO cashup;\" 2>/dev/null || true " &>/dev/null msg_ok "Database cashup_db ready" msg_info "Deploying cashup" deploy_service 117 "cashup" "${REPO_ROOT}/cashup" /opt/cashup push_file 117 /opt/cashup/.env <&1"); then msg_error "docker compose build failed in LXC 117: ${build_out}" fi msg_info "Waiting for cashup" wait_healthy 117 "http://localhost:3083/cashup/health" \ && msg_ok "Cashup running at 10.10.10.117:3083" \ || msg_warn "Cashup may need extra time — check LXC 117" # Add /cashup/ location to the existing NPM proxy host (idempotent) npm_add_location "/cashup/" "10.10.10.117" 3083 } # ════════════════════════════════════════════════════════════════════════════ # PHASE 9 — HK PLANNER LXC 118 # ════════════════════════════════════════════════════════════════════════════ deploy_hk_planner() { msg_step "9/10 HK Planner (LXC 118 · 10.10.10.118)" if [[ -z "${HK_PLANNER_DB_PASS:-}" ]]; then HK_PLANNER_DB_PASS=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24) printf '\nHK_PLANNER_DB_PASS=%s\n' "$HK_PLANNER_DB_PASS" >> "$CREDS_FILE" msg_ok "Generated HK_PLANNER_DB_PASS → ${CREDS_FILE}" fi msg_info "Creating LXC 118" create_lxc 118 "10.10.10.118" "hk-planner" 1024 1 msg_ok "LXC 118 created" msg_info "Installing Docker" install_docker 118 install_mgmt_key 118 msg_ok "Docker + SSH ready" msg_info "Creating hk_planner database" pct exec 100 -- bash -c " docker exec hotel-manage-postgres psql -U postgres -c \ \"CREATE USER hk_planner WITH PASSWORD '${HK_PLANNER_DB_PASS}';\" 2>/dev/null || true docker exec hotel-manage-postgres psql -U postgres -c \ \"CREATE DATABASE hk_planner_db OWNER hk_planner;\" 2>/dev/null || true docker exec hotel-manage-postgres psql -U postgres -d hk_planner_db -c \ \"GRANT ALL ON SCHEMA public TO hk_planner;\" 2>/dev/null || true " &>/dev/null msg_ok "Database hk_planner_db ready" msg_info "Deploying hk-planner" deploy_service 118 "hk-planner" "${REPO_ROOT}/hk-planner" /opt/hk-planner push_file 118 /opt/hk-planner/.env <&1"); then msg_error "docker compose build failed in LXC 118: ${build_out}" fi msg_info "Waiting for hk-planner" wait_healthy 118 "http://localhost:3080/hk-planner/health" \ && msg_ok "HK Planner running at 10.10.10.118:3080" \ || msg_warn "HK Planner may need extra time — check LXC 118" # Add /hk-planner/ location to NPM proxy host npm_add_location "/hk-planner/" "10.10.10.118" 3080 } deploy_twin_optimiser() { msg_step "10/10 Twin Optimiser (LXC 119 · 10.10.10.119)" if [[ -z "${TWIN_OPT_DB_PASS:-}" ]]; then TWIN_OPT_DB_PASS=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24) printf '\nTWIN_OPT_DB_PASS=%s\n' "$TWIN_OPT_DB_PASS" >> "$CREDS_FILE" msg_ok "Generated TWIN_OPT_DB_PASS → ${CREDS_FILE}" fi msg_info "Creating LXC 119" create_lxc 119 "10.10.10.119" "twin-optimiser" 1024 1 msg_ok "LXC 119 created" msg_info "Installing Docker" install_docker 119 install_mgmt_key 119 msg_ok "Docker + SSH ready" msg_info "Creating twin_optimiser database" pct exec 100 -- bash -c " docker exec hotel-manage-postgres psql -U postgres -c \ \"CREATE USER twin_optimiser WITH PASSWORD '${TWIN_OPT_DB_PASS}';\" 2>/dev/null || true docker exec hotel-manage-postgres psql -U postgres -c \ \"CREATE DATABASE twin_optimiser_db OWNER twin_optimiser;\" 2>/dev/null || true docker exec hotel-manage-postgres psql -U postgres -d twin_optimiser_db -c \ \"GRANT ALL ON SCHEMA public TO twin_optimiser;\" 2>/dev/null || true " &>/dev/null msg_ok "Database twin_optimiser_db ready" msg_info "Deploying twin-optimiser" deploy_service 119 "twin-optimiser" "${REPO_ROOT}/twin-optimiser" /opt/twin-optimiser push_file 119 /opt/twin-optimiser/.env <&1"); then msg_error "docker compose build failed in LXC 119: ${build_out}" fi msg_info "Waiting for twin-optimiser" wait_healthy 119 "http://localhost:3080/twin-optimiser/health" \ && msg_ok "Twin Optimiser running at 10.10.10.119:3080" \ || msg_warn "Twin Optimiser may need extra time — check LXC 119" # Add /twin-optimiser/ location to NPM proxy host npm_add_location "/twin-optimiser/" "10.10.10.119" 3080 } # ════════════════════════════════════════════════════════════════════════════ # PHASE 11 — ROOM PLANNER LXC 120 # ════════════════════════════════════════════════════════════════════════════ deploy_room_planner() { msg_step "11/11 Room Planner (LXC 120 · 10.10.10.120)" if [[ -z "${ROOM_PLANNER_DB_PASS:-}" ]]; then ROOM_PLANNER_DB_PASS=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24) printf '\nROOM_PLANNER_DB_PASS=%s\n' "$ROOM_PLANNER_DB_PASS" >> "$CREDS_FILE" msg_ok "Generated ROOM_PLANNER_DB_PASS → ${CREDS_FILE}" fi msg_info "Creating LXC 120" create_lxc 120 "10.10.10.120" "room-planner" 1024 1 msg_ok "LXC 120 created" msg_info "Installing Docker" install_docker 120 install_mgmt_key 120 msg_ok "Docker + SSH ready" msg_info "Creating room_planner database" pct exec 100 -- bash -c " docker exec hotel-manage-postgres psql -U postgres -c \ \"CREATE USER room_planner WITH PASSWORD '${ROOM_PLANNER_DB_PASS}';\" 2>/dev/null || true docker exec hotel-manage-postgres psql -U postgres -c \ \"CREATE DATABASE room_planner_db OWNER room_planner;\" 2>/dev/null || true docker exec hotel-manage-postgres psql -U postgres -d room_planner_db -c \ \"GRANT ALL ON SCHEMA public TO room_planner;\" 2>/dev/null || true " &>/dev/null msg_ok "Database room_planner_db ready" msg_info "Deploying room-planner" deploy_service 120 "room-planner" "${REPO_ROOT}/room-planner" /opt/room-planner # NEWBOOK_LOCATION_ID is the NewBook property location ID — set in credentials file if known local nb_location="${NEWBOOK_LOCATION_ID:-}" [[ -z "$nb_location" ]] && msg_warn "NEWBOOK_LOCATION_ID not set — add it to /opt/room-planner/.env on LXC 120" push_file 120 /opt/room-planner/.env <&1"); then msg_error "docker compose build failed in LXC 120: ${build_out}" fi msg_info "Waiting for room-planner" wait_healthy 120 "http://localhost:3080/room-planner/health" \ && msg_ok "Room Planner running at 10.10.10.120:3080" \ || msg_warn "Room Planner may need extra time — check LXC 120" # Add /room-planner/ location to NPM proxy host npm_add_location "/room-planner/" "10.10.10.120" 3080 } # ════════════════════════════════════════════════════════════════════════════ # NPM PROXY HOSTS (via API) # ════════════════════════════════════════════════════════════════════════════ # Returns a Bearer token for the NPM API, or empty string on failure. # Robust against a messy creds file: tries the currently-sourced NPM_ADMIN_* # pair, then EVERY email/pass pair found in the creds file (handles duplicates, # ordering, and stale placeholders), then the NPM factory default. npm_get_token() { local npm_ip="10.10.10.3" # stable internal NPM IP (vmbr1) — API only needs internal reach _try_token() { local email=$1 pass=$2 tok [[ -z "$email" || -z "$pass" ]] && return 1 tok=$(curl -sf -X POST "http://${npm_ip}:81/api/tokens" \ -H "Content-Type: application/json" \ -d "{\"identity\":\"${email}\",\"secret\":\"${pass}\"}" \ 2>/dev/null | grep -o '"token":"[^"]*"' | cut -d'"' -f4) [[ -n "$tok" ]] && { echo "$tok"; return 0; } return 1 } # 1. Currently-sourced pair _try_token "${NPM_ADMIN_EMAIL:-}" "${NPM_ADMIN_PASS:-}" && return # 2. Every email→pass pair in the creds file (pair each EMAIL with the # NPM_ADMIN_PASS that follows it) if [[ -f "$CREDS_FILE" ]]; then local email="" line key val while IFS= read -r line; do case "$line" in NPM_ADMIN_EMAIL=*) email="${line#*=}" ;; NPM_ADMIN_PASS=*) _try_token "$email" "${line#*=}" && return; email="" ;; esac done < "$CREDS_FILE" fi # 3. NPM factory default (fresh, unconfigured install) _try_token "admin@example.com" "changeme" && return echo "" } # Idempotently add a custom location (path → forward_host:port) to the NPM # proxy host that serves $DOMAIN. Robust JSON handling via python3 — the proxy # host is located by matching $DOMAIN inside its domain_names array (field # order in NPM's JSON is not guaranteed, so grep-based matching is unreliable). npm_add_location() { local path=$1 fwd_host=$2 fwd_port=$3 local npm_ip="10.10.10.3" msg_info "Adding ${path} to NPM proxy" local token; token=$(npm_get_token) if [[ -z "$token" ]]; then msg_warn "NPM API unavailable — add ${path} → ${fwd_host}:${fwd_port} manually in NPM admin" return fi local hosts_json host_id hosts_json=$(curl -sf "http://${npm_ip}:81/api/nginx/proxy-hosts" \ -H "Authorization: Bearer ${token}" 2>/dev/null) || true host_id=$(echo "$hosts_json" | python3 -c " import sys, json try: hosts = json.load(sys.stdin) except Exception: sys.exit(0) dom = '${DOMAIN}' for h in hosts: if dom in (h.get('domain_names') or []): print(h['id']); break " 2>/dev/null) || true if [[ -z "$host_id" ]]; then msg_warn "NPM proxy host for ${DOMAIN} not found — add ${path} → ${fwd_host}:${fwd_port} manually" return fi local existing merged existing=$(curl -sf "http://${npm_ip}:81/api/nginx/proxy-hosts/${host_id}" \ -H "Authorization: Bearer ${token}" 2>/dev/null) || true merged=$(echo "$existing" | python3 -c " import sys, json try: h = json.load(sys.stdin) except Exception: sys.exit(0) path, fh, fp = '${path}', '${fwd_host}', ${fwd_port} locs = h.get('locations') or [] if any(l.get('path') == path for l in locs): print('EXISTS'); sys.exit(0) locs.append({'path':path,'forward_scheme':'http','forward_host':fh,'forward_port':fp,'advanced_config':''}) print(json.dumps(locs)) " 2>/dev/null) || true if [[ "$merged" == "EXISTS" ]]; then msg_ok "NPM location ${path} already present" return fi if [[ -z "$merged" ]]; then msg_warn "NPM merge failed — add ${path} → ${fwd_host}:${fwd_port} manually" return fi if curl -sf -X PUT "http://${npm_ip}:81/api/nginx/proxy-hosts/${host_id}" \ -H "Authorization: Bearer ${token}" \ -H "Content-Type: application/json" \ -d "{\"locations\":${merged}}" &>/dev/null; then msg_ok "NPM location ${path} added" else msg_warn "NPM update failed — add ${path} → ${fwd_host}:${fwd_port} manually" fi } configure_npm_proxy_hosts() { msg_step "Configuring NPM proxy hosts" msg_info "Waiting for NPM API to be ready" local npm_ip="10.10.10.3" # stable internal NPM IP (vmbr1) local i=0 while ! curl -sf --max-time 3 "http://${npm_ip}:81/api/" &>/dev/null; do sleep 3; i=$((i+1)) [[ $i -ge 30 ]] && { msg_warn "NPM API not responding — configure proxy hosts manually"; return; } done local token token=$(npm_get_token) if [[ -z "$token" ]]; then msg_warn "Could not get NPM token — proxy hosts must be created manually (see summary)" return fi msg_ok "NPM API authenticated" # NPM's proxy-host endpoint is /api/nginx/proxy-hosts (NOT /api/proxy-hosts). # NPM already sets X-Real-IP / X-Forwarded-For by default, so no advanced_config. create_proxy_host() { local forward_host=$1 forward_port=$2 locations_json=${3:-'[]'} curl -sf -X POST "http://${npm_ip}:81/api/nginx/proxy-hosts" \ -H "Authorization: Bearer ${token}" \ -H "Content-Type: application/json" \ -d "{ \"domain_names\": [\"${DOMAIN}\"], \"forward_scheme\": \"http\", \"forward_host\": \"${forward_host}\", \"forward_port\": ${forward_port}, \"access_list_id\": 0, \"certificate_id\": 0, \"ssl_forced\": false, \"caching_enabled\": false, \"block_exploits\": true, \"allow_websocket_upgrade\": true, \"http2_support\": false, \"hsts_enabled\": false, \"hsts_subdomains\": false, \"advanced_config\": \"\", \"meta\": {\"letsencrypt_agree\": false, \"dns_challenge\": false}, \"locations\": ${locations_json} }" &>/dev/null && echo "created" || echo "failed" } # Single proxy host for the domain routed to the portal, with custom # locations per app path (NPM's "custom locations" feature). local locations='[ {"path":"/api/auth/","forward_scheme":"http","forward_host":"10.10.10.101","forward_port":3001,"advanced_config":""}, {"path":"/notices/","forward_scheme":"http","forward_host":"10.10.10.112","forward_port":3080,"advanced_config":""}, {"path":"/monitor/","forward_scheme":"http","forward_host":"10.10.10.105","forward_port":3002,"advanced_config":""}, {"path":"/cashup/","forward_scheme":"http","forward_host":"10.10.10.117","forward_port":3083,"advanced_config":""}, {"path":"/hk-planner/","forward_scheme":"http","forward_host":"10.10.10.118","forward_port":3080,"advanced_config":""}, {"path":"/twin-optimiser/","forward_scheme":"http","forward_host":"10.10.10.119","forward_port":3080,"advanced_config":""} ]' local result; result=$(create_proxy_host "10.10.10.102" 3000 "$locations") if [[ "$result" == "created" ]]; then msg_ok "NPM proxy host created for ${DOMAIN}" msg_warn "SSL certificate: configure in NPM admin UI after DNS is pointed at ${NPM_LAN_IP}" else msg_warn "NPM proxy host creation failed — create manually (see summary)" fi } # ════════════════════════════════════════════════════════════════════════════ # SUMMARY # ════════════════════════════════════════════════════════════════════════════ print_summary() { printf "\n${GN}" cat < # Sources existing credentials and redeploys just the named service. if [[ "${1:-}" == "--only" ]]; then ONLY="${2:-}" VALID="postgres auth portal npm management noticeboard settings cashup hk-planner twin-optimiser room-planner" [[ -z "$ONLY" ]] && msg_error "Usage: install-stack.sh --only (one of: ${VALID})" grep -qw "$ONLY" <<< "$VALID" || msg_error "Unknown service '${ONLY}'. Valid: ${VALID}" CREDS_FILE=/root/hotel-manage-credentials.txt [[ -f "$CREDS_FILE" ]] || msg_error "No credentials file at ${CREDS_FILE} — run the full installer first" # Read key=value pairs safely — handles values that contain spaces (e.g. SITE_NAME) while IFS= read -r line; do [[ "$line" =~ ^[A-Z_][A-Z0-9_]*= ]] || continue key="${line%%=*}"; value="${line#*=}" export "$key"="$value" done < "$CREDS_FILE" # Generate and append any secrets that didn't exist when the stack was first installed _append_secret() { local var=$1 val=$2 export "$var"="$val" echo "${var}=${val}" >> "$CREDS_FILE" msg_ok "Generated missing secret: ${var}" } [[ -z "${SETTINGS_DB_PASS:-}" ]] && _append_secret SETTINGS_DB_PASS \ "$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24)" [[ -z "${SETTINGS_SECRET:-}" ]] && _append_secret SETTINGS_SECRET \ "$(openssl rand -hex 32)" [[ -z "${HK_PLANNER_DB_PASS:-}" ]] && _append_secret HK_PLANNER_DB_PASS \ "$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24)" [[ -z "${TWIN_OPT_DB_PASS:-}" ]] && _append_secret TWIN_OPT_DB_PASS \ "$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24)" 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 ;; esac exit 0 fi check_vmbr1 collect_config gen_secrets gen_mgmt_ssh_key ensure_pool deploy_postgres deploy_auth deploy_portal deploy_npm deploy_management deploy_noticeboard deploy_settings deploy_cashup deploy_hk_planner deploy_twin_optimiser deploy_room_planner configure_npm_proxy_hosts print_summary