stack-init/install-stack.sh

811 lines
32 KiB
Bash
Executable file

#!/usr/bin/env bash
# ┌─────────────────────────────────────────────────────────────────────────┐
# │ HNF 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/jtr/proxmox-helpers-hotel-manage-stack/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'
╔══════════════════════════════════════════════════════════════╗
║ HNF MANAGE — PROXMOX STACK INSTALLER ║
║ postgres · auth · portal · npm · mgmt · noticeboard ║
╚══════════════════════════════════════════════════════════════╝
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)
# ── Check vmbr1 internal bridge ───────────────────────────────────────────────
check_vmbr1() {
if ! ip link show vmbr1 &>/dev/null; then
whiptail --title "vmbr1 Missing" --msgbox \
"The internal container bridge vmbr1 does not exist yet.
Add the following to /etc/network/interfaces on this host,
then run: ifreload -a
auto vmbr1
iface vmbr1 inet static
address 10.10.10.1/24
bridge-ports none
bridge-stp off
bridge-fd 0
Then re-run this installer." 18 62
exit 1
fi
}
# ── Ensure Ubuntu 22.04 template ─────────────────────────────────────────────
ensure_template() {
local tmpl
tmpl=$(pveam list local 2>/dev/null | awk '/ubuntu-22\.04/{print $1; exit}')
if [[ -z "$tmpl" ]]; then
msg_info "Downloading Ubuntu 22.04 LXC template"
pveam update &>/dev/null
pveam download local ubuntu-22.04-standard_22.04-1_amd64.tar.zst &>/dev/null
msg_ok "Template downloaded"
tmpl="ubuntu-22.04-standard_22.04-1_amd64.tar.zst"
fi
# Return full path for pct create
echo "local:vztmpl/${tmpl##*/}"
}
# ── Collect site config ───────────────────────────────────────────────────────
collect_config() {
SITE_NAME=$(whiptail --title "HNF Stack — Site Config" \
--inputbox "Site name:" 8 52 "Hotel Number Four" 3>&1 1>&2 2>&3) || exit 0
DOMAIN=$(whiptail --title "HNF Stack — 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 "HNF Stack — 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 "HNF Stack — Site Config" \
--inputbox "LAN gateway IP:" 8 52 "10.4.0.1" 3>&1 1>&2 2>&3) || exit 0
OFFICE_IP=$(whiptail --title "HNF Stack — Site Config" \
--inputbox \
"Office IP / CIDR / DDNS hostname for offsite restriction.
Examples: 203.0.113.5 10.4.0.0/22 hotel.dyndns.org
Type 'disabled' to allow access from anywhere:" \
11 64 "disabled" 3>&1 1>&2 2>&3) || exit 0
ADMIN_EMAIL=$(whiptail --title "HNF Stack — Admin Account" \
--inputbox "Admin user email:" 8 52 "" 3>&1 1>&2 2>&3) || exit 0
ADMIN_PASS=$(whiptail --title "HNF Stack — 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 "HNF Stack — 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/jtr" \
13 66 "https://git.pterois.co.uk/jtr" 3>&1 1>&2 2>&3) || exit 0
FORGEJO_BASE="${FORGEJO_BASE%/}"
FORGEJO_TOKEN=$(whiptail --title "HNF Stack — 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 "HNF Stack — 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 "HNF Stack — Backup" \
--inputbox \
"Backup rsync target (leave blank to skip backup config).
Example: backup@192.168.1.10:/backups/hnf" \
10 64 "" 3>&1 1>&2 2>&3) || exit 0
# Confirm LXC allocation
whiptail --title "HNF Stack — Confirm" --yesno \
"LXCs to be created (storage: ${STORAGE}):
ID Hostname IP
──────────────────────────────────────────
100 hnf-postgres 10.10.10.100
101 hnf-auth 10.10.10.101
102 hnf-portal 10.10.10.102
103 hnf-npm 10.10.10.103 / ${NPM_LAN_IP} (dual-homed)
105 hnf-management 10.10.10.105
112 hnf-noticeboard 10.10.10.112
Domain: ${DOMAIN}
Admin: ${ADMIN_EMAIL}
Proceed?" 24 58 || exit 0
}
# ── Generate secrets ──────────────────────────────────────────────────────────
gen_secrets() {
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)
CENTRAL_AUTH_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 > /root/hnf-credentials.txt <<EOF
# HNF 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}
CENTRAL_AUTH_SECRET=${CENTRAL_AUTH_SECRET}
WEBHOOK_SECRET=${WEBHOOK_SECRET}
NPM_ADMIN_EMAIL=admin@${DOMAIN}
NPM_ADMIN_PASS=${NPM_ADMIN_PASS}
FORGEJO_BASE=${FORGEJO_BASE}
FORGEJO_TOKEN=${FORGEJO_TOKEN}
BACKUP_REMOTE=${BACKUP_REMOTE}
EOF
chmod 600 /root/hnf-credentials.txt
msg_ok "Secrets generated → /root/hnf-credentials.txt"
}
# ── SSH keypair for management → app LXCs ────────────────────────────────────
gen_mgmt_ssh_key() {
if [[ ! -f /root/.ssh/hnf_management ]]; then
msg_info "Generating management SSH keypair"
mkdir -p /root/.ssh
ssh-keygen -t ed25519 -f /root/.ssh/hnf_management -N "" -C "hnf-management-deploy" &>/dev/null
msg_ok "SSH keypair generated → /root/.ssh/hnf_management"
else
msg_ok "Using existing SSH keypair at /root/.ssh/hnf_management"
fi
MGMT_PUBKEY=$(cat /root/.ssh/hnf_management.pub)
echo "${MGMT_PUBKEY}" >> /root/hnf-credentials.txt
}
# ── 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"
}
create_lxc() {
local id=$1 ip=$2 name=$3 mem=${4:-512} cores=${5:-1}
if lxc_exists "$id"; then
msg_warn "LXC $id (hnf-${name}) already exists — skipping creation"
lxc_running "$id" || pct start "$id"
return
fi
local tmpl; tmpl=$(get_template)
pct create "$id" "$tmpl" \
--hostname "hnf-${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 \
--start 1 &>/dev/null
sleep 5 # let systemd start
}
create_npm_lxc() {
local id=103
if lxc_exists "$id"; then
msg_warn "LXC $id (hnf-npm) already exists — skipping creation"
lxc_running "$id" || pct start "$id"
return
fi
local tmpl; tmpl=$(get_template)
pct create "$id" "$tmpl" \
--hostname "hnf-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 \
--start 1 &>/dev/null
sleep 5
}
install_docker() {
local id=$1
pct exec "$id" -- bash -s &>/dev/null <<'DOCKER_INSTALL'
export DEBIAN_FRONTEND=noninteractive
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
curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
| gpg --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/hnf-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/hnf-dir-XXXX.tar.gz)
tar czf "$tmp" -C "$(dirname "$src")" "$(basename "$src")" 2>/dev/null
pct push "$id" "$tmp" /tmp/hnf-deploy.tar.gz 2>/dev/null
pct exec "$id" -- bash -c "
mkdir -p '$(dirname "$dest")'
tar xzf /tmp/hnf-deploy.tar.gz -C '$(dirname "$dest")'
mv '$(dirname "$dest")/$(basename "$src")' '${dest}' 2>/dev/null || true
rm -f /tmp/hnf-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")
pct exec "$id" -- bash -c "
if [ -d '${dest}/.git' ]; then cd '${dest}' && git pull -q; \
else git clone -q '${url}' '${dest}'; fi
" &>/dev/null
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 -ge $max ]] && return 1
done
return 0
}
wait_pg() {
local max=30 i=0
while ! pct exec 100 -- bash -c \
"docker exec hnf-postgres pg_isready -U postgres" &>/dev/null; do
sleep 3; ((i++))
[[ $i -ge $max ]] && { msg_warn "Postgres not ready after 90s"; return 1; }
done
}
# ════════════════════════════════════════════════════════════════════════════
# PHASE 1 — POSTGRES LXC 100
# ════════════════════════════════════════════════════════════════════════════
deploy_postgres() {
msg_step "1/6 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: hnf-postgres
image: postgres:16-alpine
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
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/6 Auth service (LXC 101 · 10.10.10.101)"
msg_info "Creating LXC 101"
create_lxc 101 "10.10.10.101" "auth" 512 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}
CORS_ORIGIN=https://${DOMAIN}
SESSION_DAYS=30
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/6 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/6 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: hnf-npm
image: jc21/nginx-proxy-manager:latest
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 -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/6 Management (LXC 105 · 10.10.10.105)"
msg_info "Creating LXC 105"
create_lxc 105 "10.10.10.105" "management" 512 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/hnf_management /root/.ssh/hnf_management &>/dev/null
pct exec 105 -- chmod 600 /root/.ssh/hnf_management
msg_info "Deploying management stack"
deploy_service 105 "management" "${REPO_ROOT}/management" /opt/management
push_file 105 /opt/management/.env <<EOF
FORGEJO_WEBHOOK_SECRET=${WEBHOOK_SECRET}
BACKUP_REMOTE=${BACKUP_REMOTE}
BACKUP_PG_HOST=10.10.10.100
BACKUP_PG_USER=postgres
BACKUP_PG_PASS=${PG_SUPERPASS}
UPTIME_KUMA_PORT=3002
UPDATER_PORT=9000
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/6 Noticeboard (LXC 112 · 10.10.10.112)"
msg_info "Creating LXC 112"
create_lxc 112 "10.10.10.112" "noticeboard" 512 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}
NODE_ENV=production
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"
}
# ════════════════════════════════════════════════════════════════════════════
# NPM PROXY HOSTS (via API)
# ════════════════════════════════════════════════════════════════════════════
configure_npm_proxy_hosts() {
msg_step "Configuring NPM proxy hosts"
msg_info "Waiting for NPM API to be ready"
local i=0
while ! curl -sf --max-time 3 "http://${NPM_LAN_IP}:81/api/" &>/dev/null; do
sleep 3; ((i++))
[[ $i -ge 30 ]] && { msg_warn "NPM API not responding — configure proxy hosts manually"; return; }
done
# Get token with default credentials
local token
token=$(curl -sf -X POST "http://${NPM_LAN_IP}:81/api/tokens" \
-H "Content-Type: application/json" \
-d '{"identity":"admin@example.com","secret":"changeme"}' \
2>/dev/null | grep -o '"token":"[^"]*"' | cut -d'"' -f4) || true
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"
create_proxy_host() {
local name=$1 forward_host=$2 forward_port=$3 locations_json=${4:-'[]'}
curl -sf -X POST "http://${NPM_LAN_IP}:81/api/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},
\"ssl_forced\": false,
\"locations\": ${locations_json},
\"block_exploits\": true,
\"allow_websocket_upgrade\": true,
\"http2_support\": false,
\"advanced_config\": \"proxy_set_header X-Real-IP \$remote_addr;\nproxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;\"
}" &>/dev/null && echo "created" || echo "failed"
}
# Single proxy host for the domain routing everything through portal,
# with custom locations per app path.
# NPM "custom locations" feature handles path-based routing.
local locations
locations=$(cat <<LOCS
[
{
"path": "/api/auth/",
"forward_scheme": "http",
"forward_host": "10.10.10.101",
"forward_port": 3001,
"advanced_config": "proxy_set_header X-Real-IP \$remote_addr;\nproxy_set_header Host \$host;"
},
{
"path": "/notices/",
"forward_scheme": "http",
"forward_host": "10.10.10.112",
"forward_port": 3080,
"advanced_config": "proxy_set_header X-Real-IP \$remote_addr;\nproxy_set_header Host \$host;"
},
{
"path": "/monitor/",
"forward_scheme": "http",
"forward_host": "10.10.10.105",
"forward_port": 3002,
"advanced_config": "proxy_set_header X-Real-IP \$remote_addr;\nproxy_set_header Host \$host;"
}
]
LOCS
)
local result; result=$(create_proxy_host "${DOMAIN}" "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
╔══════════════════════════════════════════════════════════════╗
║ HNF Manage — Stack Deployed ║
╚══════════════════════════════════════════════════════════════╝
SUMMARY
printf "${CL}"
cat <<SUMMARY
Site: ${SITE_NAME}
Domain: https://${DOMAIN}
── Services ──────────────────────────────────────────────────
LXC Hostname IP Port Status
100 hnf-postgres 10.10.10.100 5432 (internal only)
101 hnf-auth 10.10.10.101 3001 /api/auth/*
102 hnf-portal 10.10.10.102 3000 /
103 hnf-npm 10.10.10.3 80/443 entry point
(LAN) ${NPM_LAN_IP} 81 NPM admin
105 hnf-management 10.10.10.105 3002 Uptime Kuma
9000 Forgejo webhooks
112 hnf-noticeboard 10.10.10.112 3080 /notices/
── Credentials ───────────────────────────────────────────────
Admin login: ${ADMIN_EMAIL}
Credentials: /root/hnf-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/jtr/proxmox-helpers-hotel-manage-stack/raw/branch/main/add-app.sh)
SUMMARY
}
# ════════════════════════════════════════════════════════════════════════════
# ENTRY POINT
# ════════════════════════════════════════════════════════════════════════════
check_vmbr1
collect_config
gen_secrets
gen_mgmt_ssh_key
deploy_postgres
deploy_auth
deploy_portal
deploy_npm
deploy_management
deploy_noticeboard
configure_npm_proxy_hosts
print_summary