812 lines
32 KiB
Bash
Executable file
812 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/proxmox-helpers/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/proxmox-helpers" \
|
|
13 66 "https://git.pterois.co.uk/proxmox-helpers" 3>&1 1>&2 2>&3) || exit 0
|
|
FORGEJO_BASE="${FORGEJO_BASE%/}"
|
|
|
|
FORGEJO_TOKEN=$(whiptail --title "HNF Stack — Forgejo Token" \
|
|
--passwordbox \
|
|
"Access token for cloning private repos.
|
|
Create in Forgejo: Settings → Applications → Generate Token
|
|
(scope: read:repository). It is embedded in each LXC's git
|
|
remote so the management updater can pull on webhook.
|
|
|
|
Leave blank if the repos are public." \
|
|
13 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/proxmox-helpers/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
|