From fe16a07dd78b1c88b2174bc06737a146454b1f94 Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Wed, 1 Jul 2026 12:09:54 +0000 Subject: [PATCH] Initial commit: stack --- .gitignore | 45 + README.md | 105 +++ add-app.sh | 240 ++++++ docs/APP-INTEGRATION-GUIDE.md | 700 +++++++++++++++ infrastructure/lxc-templates/npm-compose.yml | 15 + infrastructure/lxc-templates/provision-npm.sh | 48 ++ infrastructure/lxc-templates/provision.sh | 66 ++ infrastructure/postgres/docker-compose.yml | 19 + infrastructure/postgres/init/auth.sql | 4 + infrastructure/postgres/init/noticeboard.sql | 4 + install-stack.sh | 812 ++++++++++++++++++ 11 files changed, 2058 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100755 add-app.sh create mode 100644 docs/APP-INTEGRATION-GUIDE.md create mode 100644 infrastructure/lxc-templates/npm-compose.yml create mode 100644 infrastructure/lxc-templates/provision-npm.sh create mode 100644 infrastructure/lxc-templates/provision.sh create mode 100644 infrastructure/postgres/docker-compose.yml create mode 100644 infrastructure/postgres/init/auth.sql create mode 100644 infrastructure/postgres/init/noticeboard.sql create mode 100755 install-stack.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a4ce5d8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +# Dependencies +node_modules/ +.pnp/ +.pnp.js + +# Build output +dist/ +build/ +.next/ +out/ + +# Environment / secrets +.env +.env.local +.env.*.local +!.env.example + +# Editor +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +npm-debug.log* + +# Docker volumes (if any are mounted locally) +postgres-data/ + +# Python +__pycache__/ +*.pyc +*.pyo +.venv/ +venv/ +*.egg-info/ + +# Temp +*.tar.gz +*.tmp diff --git a/README.md b/README.md new file mode 100644 index 0000000..6077c8b --- /dev/null +++ b/README.md @@ -0,0 +1,105 @@ +# Proxmox Helpers — HNF Stack Installer + +tteck-style helper scripts that provision the stack onto a fresh Proxmox host — +no local copy of the repo required on the host. + +## Install vs. update — two separate paths + +- **Install** (creating an LXC) runs **on the Proxmox host**, because `pct` only + exists on the hypervisor. That's what these scripts are for. +- **Updates** (git pull + `docker compose up --build`) are driven **from the + management container** over SSH — no hypervisor access needed. Push to a + service repo → Forgejo webhook → management redeploys that LXC in place. + +So the management container never creates LXCs; it only updates, monitors and +backs up what these scripts provisioned. + +## Repo layout (Forgejo — org `proxmox-helpers`) + +Property-neutral names, since the stack deploys at multiple hotels: + +| Repo | Contents | Provisioned to | +|------|----------|----------------| +| `stack` | Installer scripts + `docs/` + `infrastructure/` reference | run on the Proxmox host | +| `auth` | central auth service | LXC 101 | +| `portal` | PWA portal shell | LXC 102 | +| `management` | updater + Kuma + backup | LXC 105 | +| `noticeboard` | starter app | LXC 112 | +| `kitchen`, `cashup`, … | one repo per app | added later | + +Postgres (LXC 100) and NPM (LXC 103) have no repo — the installer generates +their compose files inline (they carry secrets / are pure infra). + +> Owner assumed to be a Forgejo org named `proxmox-helpers`. If your repos live +> under a user account or a different org, adjust the URLs below and the +> `FORGEJO_BASE` default in the wizard. + +## One-time host prep + +Add the internal bridge to `/etc/network/interfaces`, then `ifreload -a`: + +``` +auto vmbr1 +iface vmbr1 inet static + address 10.10.10.1/24 + bridge-ports none + bridge-stp off + bridge-fd 0 +``` + +The installer will offer to download the Ubuntu 22.04 template if missing. + +## Run the installer (foundation) + +On the Proxmox host shell (as root): + +**Public repo:** +```bash +bash <(curl -fsSL https://git.pterois.co.uk/proxmox-helpers/stack/raw/branch/main/install-stack.sh) +``` + +**Private repo** (raw fetch needs the same token you'll paste into the wizard): +```bash +TOKEN=xxxxxxxx +bash <(curl -fsSL -H "Authorization: token $TOKEN" \ + https://git.pterois.co.uk/proxmox-helpers/stack/raw/branch/main/install-stack.sh) +``` + +The wizard collects site name, domain, NPM LAN IP/gateway, office IP for +offsite restriction, admin credentials, the Forgejo base URL + access token, +and a backup target. It then provisions the six foundation LXCs (postgres, +auth, portal, npm, management, noticeboard), health-checks each, and configures +the NPM proxy routes. + +Secrets are written to `/root/hnf-credentials.txt` (chmod 600) — copy this +offsite. + +## Add an app later + +On the Proxmox host (again, because it creates an LXC): + +```bash +bash <(curl -fsSL -H "Authorization: token $TOKEN" \ + https://git.pterois.co.uk/proxmox-helpers/stack/raw/branch/main/add-app.sh) +``` + +Reads `/root/hnf-credentials.txt` for the shared secret, Forgejo token and +office IP, provisions a new LXC, optionally creates a dedicated postgres DB, +clones the app repo, and prints the NPM route / Uptime Kuma / webhook / +deploy-map lines to finish wiring it in. After that, ongoing updates flow +through the management container automatically. + +## Replicating to another hotel + +Same command on the new host. Only the wizard answers differ per site: +`DOMAIN`, NPM LAN IP + gateway (that site's LAN pool), and `OFFICE_IP_CHECK`. +The internal `10.10.10.0/24` network and all service IPs are identical +everywhere, so the repos are reused unchanged. + +## Notes + +- The Forgejo token is embedded in each LXC's git remote URL so the management + updater can `git pull` on webhook without extra credentials. Use a + dedicated, least-privilege token (read:repository). +- `install-stack.sh` is idempotent-ish: existing LXCs are skipped (started if + stopped) rather than recreated, so a re-run resumes a partial install. diff --git a/add-app.sh b/add-app.sh new file mode 100755 index 0000000..5229777 --- /dev/null +++ b/add-app.sh @@ -0,0 +1,240 @@ +#!/usr/bin/env bash +# ┌─────────────────────────────────────────────────────────────────────────┐ +# │ HNF Manage — Add App LXC │ +# │ Provisions a single app container and wires it into the stack. │ +# │ │ +# │ Usage: bash add-app.sh (or curl-bootstrap, see install/README.md) │ +# └─────────────────────────────────────────────────────────────────────────┘ +set -euo pipefail + +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" "$*"; } + +[[ $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" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +# Load secrets from credentials file if present +CREDS_FILE=/root/hnf-credentials.txt +if [[ -f "$CREDS_FILE" ]]; then + # shellcheck disable=SC1090 + set -a; source <(grep -v '^#' "$CREDS_FILE" | grep '='); set +a +fi + +# ── Collect config ──────────────────────────────────────────────────────────── +APP_NAME=$(whiptail --title "Add App LXC" \ + --inputbox "App slug (e.g. kitchen, cashup, housekeeping):" 8 52 "" 3>&1 1>&2 2>&3) || exit 0 + +APP_NAME=$(echo "$APP_NAME" | tr '[:upper:]' '[:lower:]' | tr ' ' '-') + +LXC_ID=$(whiptail --title "Add App LXC" \ + --inputbox "LXC ID to allocate (check Proxmox for free IDs):" 8 52 "" 3>&1 1>&2 2>&3) || exit 0 + +LAST_OCTET=$(whiptail --title "Add App LXC" \ + --inputbox "Internal IP last octet (10.10.10.X):" 8 52 "$LXC_ID" 3>&1 1>&2 2>&3) || exit 0 + +APP_PORT=$(whiptail --title "Add App LXC" \ + --inputbox "Port the app container listens on internally:" 8 52 "3000" 3>&1 1>&2 2>&3) || exit 0 + +APP_PATH=$(whiptail --title "Add App LXC" \ + --inputbox "URL path prefix (e.g. /kitchen/):" 8 52 "/${APP_NAME}/" 3>&1 1>&2 2>&3) || exit 0 + +APP_DB=$(whiptail --title "Add App LXC" --yesno \ + "Create a dedicated postgres database for this app?" 8 52 && echo "yes" || echo "no") + +if [[ "$APP_DB" == "yes" ]]; then + APP_DB_PASS=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24) + APP_DB_USER="$APP_NAME" + APP_DB_NAME="${APP_NAME}_db" +fi + +if whiptail --title "Add App LXC" --yesno \ + "Deploy from Forgejo git repo?" 8 52; then + USE_FORGEJO=true + FORGEJO_REPO=$(whiptail --title "Add App LXC" \ + --inputbox "Full Forgejo repo URL (.git):" 8 70 \ + "${FORGEJO_BASE:-https://git.pterois.co.uk/proxmox-helpers}/${APP_NAME}.git" \ + 3>&1 1>&2 2>&3) || exit 0 +else + USE_FORGEJO=false + LOCAL_SRC=$(whiptail --title "Add App LXC" \ + --inputbox "Local app directory path:" 8 70 \ + "${REPO_ROOT}/apps/${APP_NAME}" 3>&1 1>&2 2>&3) || exit 0 +fi + +ENV_EXTRA=$(whiptail --title "Add App LXC" \ + --inputbox \ +"Additional .env lines (KEY=VALUE, one per line). +CENTRAL_AUTH_SECRET and DATABASE_URL are added automatically. +Leave blank if none." \ + 12 70 "" 3>&1 1>&2 2>&3) || exit 0 + +# Confirm +whiptail --title "Add App LXC — Confirm" --yesno \ +"Create LXC for app: ${APP_NAME} + + LXC ID: ${LXC_ID} + IP: 10.10.10.${LAST_OCTET} + Port: ${APP_PORT} + Path: ${APP_PATH} + Database: $( [[ "$APP_DB" == "yes" ]] && echo "${APP_DB_NAME}" || echo "none" ) + +Proceed?" 16 52 || exit 0 + +# ── Detect storage ──────────────────────────────────────────────────────────── +STORAGE=$(pvesm status 2>/dev/null | awk '{print $1}' | grep -E "^local-lvm$|^local-zfs$" | head -1) +STORAGE="${STORAGE:-local}" + +# ── Get template ────────────────────────────────────────────────────────────── +TEMPLATE=$(pveam list local 2>/dev/null | awk '/ubuntu-22\.04/{print "local:vztmpl/"$1; exit}') +if [[ -z "$TEMPLATE" ]]; then + msg_info "Downloading Ubuntu 22.04 template" + pveam update &>/dev/null && pveam download local ubuntu-22.04-standard_22.04-1_amd64.tar.zst &>/dev/null + TEMPLATE=$(pveam list local 2>/dev/null | awk '/ubuntu-22\.04/{print "local:vztmpl/"$1; exit}') + msg_ok "Template ready" +fi + +# ── Get mgmt public key ─────────────────────────────────────────────────────── +MGMT_PUBKEY="" +[[ -f /root/.ssh/hnf_management.pub ]] && MGMT_PUBKEY=$(cat /root/.ssh/hnf_management.pub) + +# ── Create LXC ──────────────────────────────────────────────────────────────── +msg_info "Creating LXC ${LXC_ID} (hnf-${APP_NAME} at 10.10.10.${LAST_OCTET})" +pct create "$LXC_ID" "$TEMPLATE" \ + --hostname "hnf-${APP_NAME}" \ + --memory 512 \ + --cores 1 \ + --rootfs "${STORAGE}:8" \ + --net0 "name=eth0,bridge=vmbr1,ip=10.10.10.${LAST_OCTET}/24,gw=10.10.10.1" \ + --features nesting=1 \ + --unprivileged 0 \ + --onboot 1 \ + --start 1 &>/dev/null +sleep 5 +msg_ok "LXC ${LXC_ID} created" + +# ── Install Docker ──────────────────────────────────────────────────────────── +msg_info "Installing Docker" +pct exec "$LXC_ID" -- bash -s &>/dev/null <<'DOCKER' +export DEBIAN_FRONTEND=noninteractive +apt-get update -qq && apt-get install -y -qq ca-certificates curl gnupg git openssh-server +install -m 0755 -d /etc/apt/keyrings +curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg +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 && apt-get install -y -qq docker-ce docker-ce-cli containerd.io docker-compose-plugin +systemctl enable --now docker ssh +DOCKER +msg_ok "Docker installed" + +# ── Install management SSH key ──────────────────────────────────────────────── +if [[ -n "$MGMT_PUBKEY" ]]; then + pct exec "$LXC_ID" -- bash -c " + mkdir -p /root/.ssh && chmod 700 /root/.ssh + echo '${MGMT_PUBKEY}' >> /root/.ssh/authorized_keys + chmod 600 /root/.ssh/authorized_keys + " &>/dev/null + msg_ok "Management SSH key installed" +fi + +# ── Create database ─────────────────────────────────────────────────────────── +if [[ "$APP_DB" == "yes" ]]; then + msg_info "Creating database ${APP_DB_NAME}" + # Run SQL via postgres LXC + pct exec 100 -- bash -c " + docker exec hnf-postgres psql -U postgres -c \ + \"CREATE USER ${APP_DB_USER} WITH PASSWORD '${APP_DB_PASS}';\" 2>/dev/null || true + docker exec hnf-postgres psql -U postgres -c \ + \"CREATE DATABASE ${APP_DB_NAME} OWNER ${APP_DB_USER};\" 2>/dev/null || true + docker exec hnf-postgres psql -U postgres -d ${APP_DB_NAME} -c \ + \"GRANT ALL ON SCHEMA public TO ${APP_DB_USER};\" 2>/dev/null || true + " &>/dev/null + msg_ok "Database ${APP_DB_NAME} created" + + # Append DB creds to credentials file + cat >> /root/hnf-credentials.txt </dev/null +else + tmp=$(mktemp /tmp/hnf-app-XXXX.tar.gz) + tar czf "$tmp" -C "$(dirname "$LOCAL_SRC")" "$(basename "$LOCAL_SRC")" 2>/dev/null + pct push "$LXC_ID" "$tmp" /tmp/hnf-app.tar.gz 2>/dev/null + pct exec "$LXC_ID" -- bash -c " + mkdir -p /opt && tar xzf /tmp/hnf-app.tar.gz -C /opt + mv /opt/$(basename "$LOCAL_SRC") /opt/${APP_NAME} 2>/dev/null || true + rm -f /tmp/hnf-app.tar.gz + " &>/dev/null + rm -f "$tmp" +fi +msg_ok "App files deployed to /opt/${APP_NAME}" + +# ── Write .env ──────────────────────────────────────────────────────────────── +msg_info "Writing .env" +ENV_TMP=$(mktemp /tmp/hnf-env-XXXX) +{ + echo "NODE_ENV=production" + echo "APP_SLUG=${APP_NAME}" + echo "CENTRAL_AUTH_SECRET=${CENTRAL_AUTH_SECRET:-REPLACE_ME}" + echo "OFFICE_IP_CHECK=${OFFICE_IP_CHECK:-disabled}" + if [[ "$APP_DB" == "yes" ]]; then + echo "DATABASE_URL=postgresql://${APP_DB_USER}:${APP_DB_PASS}@10.10.10.100:5432/${APP_DB_NAME}" + fi + [[ -n "$ENV_EXTRA" ]] && echo "$ENV_EXTRA" +} > "$ENV_TMP" +pct push "$LXC_ID" "$ENV_TMP" "/opt/${APP_NAME}/.env" 2>/dev/null +rm -f "$ENV_TMP" +msg_ok ".env written" + +# ── Register app in auth service ────────────────────────────────────────────── +msg_warn "Remember to register '${APP_NAME}' in the auth service DB:" +printf " docker exec hnf-auth-1 node -e \"\n" +printf " const db = require('./src/db.js');\n" +printf " db.query(\\\"INSERT INTO apps (slug,name,base_path) VALUES ('%s','%s','%s') ON CONFLICT DO NOTHING\\\");\n" \ + "$APP_NAME" "$APP_NAME" "$APP_PATH" +printf " \"\n\n" + +# ── NPM route reminder ──────────────────────────────────────────────────────── +NPM_LAN="${NPM_LAN_IP:-}" + +printf "\n${GN}── Summary ──────────────────────────────────────────────────${CL}\n" +cat < ← only container with a LAN IP + │ — single SSL cert + │ + └── Internal network (vmbr1: 10.10.10.0/24) — SAME at every site + ├── 10.10.10.100 PostgreSQL :5432 (internal only) + ├── 10.10.10.101 Auth service :3001 + ├── 10.10.10.102 Portal :3000 + ├── 10.10.10.105 Management :3002 (Uptime Kuma → /monitor/) + │ :9000 (Forgejo webhook, internal only) + ├── 10.10.10.110 Kitchen Flash :3080 + ├── 10.10.10.114 Housekeeping :3014 + └── 10.10.10.1xx (future apps — same IPs at all sites) +``` + +**NPM LXC** is the only container with a LAN IP. All others are on the internal `vmbr1` +bridge — invisible from the hotel LAN. NPM proxies paths to internal LXC IPs. + +**External Forgejo** (on developer's own server) is the source of truth for all app repos. +The management container's update service receives webhooks from it and deploys to app LXCs. + +- All apps share a single PostgreSQL instance (LXC .100) — each app gets its own database. +- Auth is enforced on each app independently via a shared httpOnly cookie (`hnf_session`). +- The portal shell loads app UIs in `