Initial commit: stack

This commit is contained in:
jtricerolph 2026-07-01 12:09:54 +00:00
commit fe16a07dd7
11 changed files with 2058 additions and 0 deletions

45
.gitignore vendored Normal file
View file

@ -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

105
README.md Normal file
View file

@ -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.

240
add-app.sh Executable file
View file

@ -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 <<EOF
# ${APP_NAME} DB
${APP_NAME^^}_DB_PASS=${APP_DB_PASS}
EOF
fi
# ── Deploy app files ──────────────────────────────────────────────────────────
msg_info "Deploying ${APP_NAME}"
if [[ "$USE_FORGEJO" == "true" ]]; then
# Embed token for private repos (also lets the updater pull on webhook)
if [[ -n "${FORGEJO_TOKEN:-}" && "$FORGEJO_REPO" == https://* ]]; then
FORGEJO_REPO="${FORGEJO_REPO/https:\/\//https://oauth2:${FORGEJO_TOKEN}@}"
fi
pct exec "$LXC_ID" -- bash -c "
git clone -q '${FORGEJO_REPO}' /opt/${APP_NAME}
" &>/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:-<npm-lan-ip>}"
printf "\n${GN}── Summary ──────────────────────────────────────────────────${CL}\n"
cat <<SUMMARY
App: ${APP_NAME}
LXC: ${LXC_ID} at 10.10.10.${LAST_OCTET}
Port: ${APP_PORT}
Path: ${APP_PATH}
Next steps:
1. cd /opt/${APP_NAME} && docker compose up -d --build (in LXC ${LXC_ID})
2. Add NPM custom location in admin UI:
Path: ${APP_PATH}
Forward: http://10.10.10.${LAST_OCTET}:${APP_PORT}
3. Add to Uptime Kuma:
http://10.10.10.${LAST_OCTET}:${APP_PORT}${APP_PATH}health
4. Add Forgejo webhook:
http://10.10.10.105:9000/webhook (secret in /root/hnf-credentials.txt)
5. Add deploy entry to the management repo: updater/deploy-map.js:
'${APP_NAME}': { host: '10.10.10.${LAST_OCTET}', path: '/opt/${APP_NAME}' }
SUMMARY

View file

@ -0,0 +1,700 @@
# App Integration Guide — HNF Stack
This document is the reference for porting existing apps into the HNF Proxmox stack.
Each app gets its own porting session — read this first before starting any port.
---
## Multi-Site Design Principle
This stack is designed to deploy identically across multiple hotel Proxmox hosts.
The internal container network (`10.10.10.0/24`) is the same at every site — it is
isolated inside each Proxmox host and has no conflict with the hotel's own LAN subnet.
**All docker-compose files, DATABASE_URLs, and internal service references use
`10.10.10.x` addresses and are therefore identical across all sites.**
Only these values differ per site, set via env vars at provision time:
| Env var | Example (HNF) | Notes |
|---------|--------------|-------|
| `DOMAIN` | `manage.hotelnumberfour.com` | Per-site domain for NPM + auth cookie |
| `OFFICE_PUBLIC_IP` | `x.x.x.x` | Site WAN IP for offsite access restriction |
| `LAN_SUBNET` | `10.4.0.0/22` | Used when assigning NPM LXC's LAN IP |
**Forgejo deploy webhooks**: each hotel's management container registers its own webhook
in the shared Forgejo repo. A push to `main` fires to all registered hotels simultaneously
— all sites update in parallel. To stage a rollout, temporarily disable a site's webhook.
---
## Stack Overview
```
Hotel LAN (any subnet — e.g. 10.4.0.0/22 at HNF, 192.168.x.x elsewhere)
└── LXC: NPM <LAN IP from site pool> ← only container with a LAN IP
<DOMAIN> — 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 `<iframe>` elements — same origin, no CORS issues.
---
## Central Auth Service
**Base URL (internal)**: `http://10.10.10.101:3001` (same at all sites)
**Base URL (via NPM)**: `https://<DOMAIN>/api/auth` (site-specific, e.g. `manage.hotelnumberfour.com`)
### Endpoints
| Method | Path | Description |
|--------|------|-------------|
| POST | `/api/auth/login` | Email + password → sets `hnf_session` httpOnly cookie |
| POST | `/api/auth/logout` | Clears cookie |
| GET | `/api/auth/me` | Returns user profile + `apps[]` permission list |
| GET | `/api/auth/verify?app=<slug>` | Validate cookie + check app permission + IP restriction |
### `/api/auth/verify` Response
```json
// 200 OK — proceed
{
"user_id": 1,
"email": "jane@hotelnumberfour.com",
"name": "Jane Smith",
"is_admin": false,
"app": "kitchen"
}
// 401 — no valid session cookie
// 403 — valid session but no permission for this app, OR offsite restriction
```
### Cookie Details
- **Name**: `hnf_session`
- **Domain**: `.manage.hotelnumberfour.com` (leading dot = all subpaths)
- **Flags**: `httpOnly`, `Secure`, `SameSite=Lax`
- **Payload**: `{ sub: "user@email.com", apps: ["kitchen","hk"], offsite_allowed: true, exp: ... }`
- **Algorithm**: HS256, secret is `CENTRAL_AUTH_SECRET` env var
### JWT Payload Structure
```json
{
"sub": "user@hotelnumberfour.com",
"name": "Jane Smith",
"apps": ["kitchen", "hk", "cashup"],
"offsite_allowed": true,
"iat": 1234567890,
"exp": 1234654290
}
```
---
## Shared PostgreSQL
**Host (internal LAN)**: `10.10.10.100:5432`
**Per-app connection string pattern**: `postgresql://<app>:<password>@10.10.10.100:5432/<app>_db`
Each app gets:
- Its own database (e.g. `kitchen_db`, `cashup_db`, `hk_db`)
- Its own postgres user with access only to that database
- Its own schema within that database (matching existing schema if migrating)
**To migrate an existing app DB**: export from app's current postgres, import into shared PG.
Schema init SQL files live in `infrastructure/postgres/init/<app>.sql` in this repo.
---
## Per-App Integration Checklist
### 1. Base Path Configuration
Every app must serve itself under its path prefix (e.g. `/kitchen`). NPM strips nothing — it
proxies the full path to the app's port. The app must handle the prefix.
**React/Vite apps (like Kitchen Flash)**:
`vite.config.ts`:
```ts
export default defineConfig({
base: '/kitchen/', // <-- add this
plugins: [react()],
})
```
`src/App.tsx` (or wherever BrowserRouter is):
```tsx
<BrowserRouter basename="/kitchen">
```
Any hardcoded API calls must use a base URL from an env var:
```
VITE_API_BASE=/kitchen
```
**Python/FastAPI backends**:
FastAPI doesn't need changes — the frontend nginx handles path stripping.
Update `nginx.conf` (see section below).
**Next.js apps**:
`next.config.js`:
```js
module.exports = { basePath: '/hk' }
```
### 2. Nginx Config (for apps with their own nginx frontend)
Replace the `location /` block with a path-aware version:
```nginx
# In the app's nginx.conf — replace root location block
location /kitchen/ {
alias /usr/share/nginx/html/;
try_files $uri $uri/ /kitchen/index.html;
}
location /kitchen/api/ {
proxy_pass http://backend:8000/api/;
# ... existing proxy headers ...
}
location /kitchen/auth/ {
proxy_pass http://backend:8000/auth/;
# ... existing proxy headers ...
}
```
### 3. Auth Integration — Python/FastAPI Apps
Add to `backend/auth/jwt.py` (before the existing Bearer check):
```python
from fastapi import Request, Cookie
from typing import Optional
import os
CENTRAL_AUTH_SECRET = os.getenv("CENTRAL_AUTH_SECRET")
APP_SLUG = os.getenv("APP_SLUG", "kitchen") # set per-app in docker-compose
async def get_current_user(
request: Request,
credentials: Optional[HTTPAuthorizationCredentials] = Depends(HTTPBearer(auto_error=False)),
db: AsyncSession = Depends(get_db)
) -> User:
# 1. Try central auth cookie first
central_token = request.cookies.get("hnf_session")
if central_token and CENTRAL_AUTH_SECRET:
try:
payload = jwt.decode(central_token, CENTRAL_AUTH_SECRET, algorithms=["HS256"])
email = payload.get("sub")
if email and APP_SLUG in payload.get("apps", []):
# Auto-create or find local user by email
result = await db.execute(select(User).where(User.email == email))
user = result.scalar_one_or_none()
if not user:
user = User(
email=email,
name=payload.get("name", email),
password_hash="", # no local password for SSO users
kitchen_id=1, # adapt per app
is_admin=False,
is_active=True
)
db.add(user)
await db.commit()
await db.refresh(user)
if user.is_active:
return user
except Exception:
pass # fall through to Bearer check
# 2. Fall back to existing Bearer token (backwards compat)
if credentials:
token = credentials.credentials
user = await get_current_user_from_token(token, db)
if user:
return user
raise HTTPException(status_code=401, detail="Not authenticated")
```
Add to `docker-compose.yml` backend environment:
```yaml
environment:
- CENTRAL_AUTH_SECRET=${CENTRAL_AUTH_SECRET}
- APP_SLUG=kitchen
- DATABASE_URL=postgresql://kitchen:${KITCHEN_DB_PASS}@10.10.10.100:5432/kitchen_db
```
Remove the `db:` service from docker-compose (now uses shared PG).
### 4. Auth Integration — Node/Express or Fastify Apps
```js
// middleware/central-auth.js
const jwt = require('jsonwebtoken')
function centralAuth(appSlug) {
return (req, res, next) => {
const token = req.cookies?.hnf_session
if (!token) return res.status(401).json({ error: 'Not authenticated' })
try {
const payload = jwt.verify(token, process.env.CENTRAL_AUTH_SECRET)
if (!payload.apps?.includes(appSlug)) {
return res.status(403).json({ error: 'No permission for this app' })
}
req.user = { email: payload.sub, name: payload.name }
next()
} catch {
res.status(401).json({ error: 'Invalid session' })
}
}
}
```
### 5. Health Endpoint
Every app **must** expose `GET /health` returning `{ "status": "healthy" }` with HTTP 200.
This is what Uptime Kuma polls. For NPM routing it must be at the prefixed path:
`GET /kitchen/health` should return 200.
Add to nginx.conf:
```nginx
location /kitchen/health {
proxy_pass http://backend:8000/health;
}
```
### 6. Docker Compose Template
```yaml
services:
backend:
build: ./backend
environment:
- DATABASE_URL=postgresql://appname:${DB_PASS}@10.10.10.100:5432/appname_db
- CENTRAL_AUTH_SECRET=${CENTRAL_AUTH_SECRET}
- APP_SLUG=appname
ports:
- "8000:8000" # internal only, NPM hits the frontend port
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8000/health || exit 1"]
interval: 10s
retries: 5
restart: unless-stopped
frontend:
build: ./frontend
ports:
- "3080:80" # this port is what NPM proxies to
depends_on:
backend:
condition: service_healthy
restart: unless-stopped
```
### 7. Environment Variables (`.env` on each LXC)
```bash
# Required on every app LXC
CENTRAL_AUTH_SECRET=<shared secret from auth service same value on all LXCs>
DB_PASS=<app-specific postgres password>
# App-specific
APP_SLUG=kitchen # or cashup, hk, etc.
```
The `CENTRAL_AUTH_SECRET` value is generated once during auth service setup and copied
to all app LXCs. Store it in the ops notes / password manager.
---
## PWA Multi-Install Architecture
Each app in the stack is independently installable as a PWA while still running through
the same auth and backend infrastructure. This lets different staff roles have a focused
home-screen app without exposing anything directly.
```
Manager → installs manage.hotelnumberfour.com → "HNF Manage" (full portal)
Housekeeper → installs manage.hotelnumberfour.com/hk/ → "Housekeeping" (HK only)
Kitchen staff → installs manage.hotelnumberfour.com/kitchen/ → "Kitchen Flash"
```
All three use the same `hnf_session` cookie and central auth. The distinction is purely
in which `manifest.json` the browser fetches when the user chooses "Add to Home Screen".
### Per-App Manifest
Each app serves its own `manifest.json` at its root path. Key fields:
```json
{
"name": "Kitchen Flash",
"short_name": "Kitchen",
"start_url": "/kitchen/",
"scope": "/",
"display": "standalone",
"theme_color": "#e85d04",
"background_color": "#1a1a2e",
"icons": [
{ "src": "/kitchen/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/kitchen/icons/icon-512.png", "sizes": "512x512", "type": "image/png" }
]
}
```
**Critical**: `scope` must be `"/"` (not `"/kitchen/"`). This keeps the login flow and
any cross-app navigation inside the PWA context rather than opening the browser.
**`start_url`** controls where the app opens — this is the differentiator between installs.
Each app should have distinct `theme_color` and icons so installed apps are visually
distinguishable on the home screen.
Suggested colour scheme:
| App | theme_color |
|-----|------------|
| Portal / HNF Manage | `#1e3a5f` (navy) |
| Kitchen Flash | `#e85d04` (orange) |
| Housekeeping | `#2d6a4f` (green) |
| Cashup | `#6b2d8b` (purple) |
| Forecasting | `#0077b6` (blue) |
### Auth Within PWA Scope (no page-navigation login)
When a sub-app PWA opens and no session cookie exists, we cannot redirect to `/login`
because that's outside the `start_url` path — in practice with `scope: "/"` it's fine,
but the cleaner pattern is in-app auth so the experience stays seamless:
**React auth wrapper pattern** (add to every app's frontend):
```tsx
// src/components/AuthGate.tsx
import { useEffect, useState } from 'react'
export function AuthGate({ children }: { children: React.ReactNode }) {
const [state, setState] = useState<'checking' | 'authed' | 'login'>('checking')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
useEffect(() => {
fetch('/api/auth/verify?app=APPSLUG', { credentials: 'include' })
.then(r => setState(r.ok ? 'authed' : 'login'))
.catch(() => setState('login'))
}, [])
async function login(e: React.FormEvent) {
e.preventDefault()
const res = await fetch('/api/auth/login', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
})
if (res.ok) {
// Verify permission for this specific app
const verify = await fetch('/api/auth/verify?app=APPSLUG', { credentials: 'include' })
if (verify.ok) setState('authed')
else setError("You don't have access to this app.")
} else {
setError('Invalid email or password')
}
}
if (state === 'checking') return <div className="auth-loading">Loading…</div>
if (state === 'login') return (
<div className="auth-screen">
<img src="/kitchen/icons/icon-192.png" alt="App icon" />
<h1>Kitchen Flash</h1>
<form onSubmit={login}>
<input type="email" value={email} onChange={e => setEmail(e.target.value)}
placeholder="Email" required />
<input type="password" value={password} onChange={e => setPassword(e.target.value)}
placeholder="Password" required />
{error && <p className="error">{error}</p>}
<button type="submit">Sign in</button>
</form>
</div>
)
return <>{children}</>
}
```
Wrap the app root: `<AuthGate><App /></AuthGate>`
`fetch` calls are XHR — they don't navigate the page, so the PWA scope is never broken.
The cookie is set domain-wide (`manage.hotelnumberfour.com`), so once the housekeeper
logs in via the HK PWA, they won't need to log in again if they open another app.
### Portal "Install" Shortcuts
The portal dashboard shows each permitted app as a tile. Each tile has a secondary
"Install" action that navigates to `/<app>/?install=1`.
Each app detects this query param and triggers the install prompt:
```tsx
// In app's main entry (e.g. main.tsx or App.tsx)
useEffect(() => {
const params = new URLSearchParams(window.location.search)
if (params.get('install') === '1') {
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault()
;(e as any).prompt() // trigger install banner immediately
}, { once: true })
}
}, [])
```
The portal tile install button links to the app URL with `?install=1`. On mobile, this
triggers the browser's "Add to Home Screen" flow for that specific app manifest.
Portal can also generate a QR code or shareable install link per app for managers to
distribute to relevant staff.
### Three Install Tiers
| Role | Install URL | PWA Name | Access |
|------|-------------|----------|--------|
| Manager | `manage.hotelnumberfour.com` | HNF Manage | All permitted apps in portal |
| Dept. head | `manage.hotelnumberfour.com/kitchen` | Kitchen Flash | Kitchen only, full UI |
| Operative | `manage.hotelnumberfour.com/hk` | Housekeeping | HK tasks only |
All tiers use the same auth cookie — a user who has both Kitchen and HK permissions
can install both and they'll both work from the same session.
### Service Worker Scope Considerations
Each app's service worker must be registered with its path as scope to avoid
conflicts between apps:
```js
// In kitchen app's sw registration
navigator.serviceWorker.register('/kitchen/sw.js', { scope: '/kitchen/' })
```
The portal registers its service worker at `/sw.js` with default scope `/`. This
caches the portal shell. App service workers cache their own assets independently.
---
## WordPress Plugin → Standalone App
For plugins like the housekeeping tools:
1. **Identify the data**: Find all `$wpdb->query`, `$wpdb->get_results`, custom table creates in
the activator class. These become the app's DB schema.
2. **Identify the API surface**: Find all `wp_ajax_*` hooks in the AJAX class. These become REST
endpoints in the new backend.
3. **Identify the frontend**: PHP views + JS files in `/public/js/` describe the UI. Rewrite as
React components.
4. **Newbook integration**: If the plugin uses Newbook API, the integration logic is usually in
a `class-hhc-newbook-api.php` or similar. Port the HTTP calls to Python requests or Node fetch.
5. **Auth**: Plugins depend on WordPress user auth. In the new stack, auth comes from the central
`hnf_session` cookie — no WordPress needed.
Reference files:
- Hour calculator plugin: `/home/jtr/laptop-archive/hotel-housekeeping-hour-calculator/`
- Housekeeping PWA plugin: `/home/jtr/laptop-archive/housekeeping-pwa-app/`
---
## Management Container
**LXC 105** runs three services:
| Service | Port | Role |
|---------|------|------|
| Uptime Kuma | 3002 | Health monitoring (exposed via NPM at `/monitor/`) |
| Update service | 9000 | Forgejo webhook receiver (internal network only — never via NPM) |
| Backup service | — | Cron-based pg_dump + volume snapshots |
---
### App Updates via Forgejo Webhooks
Each app repo on your Forgejo server has a webhook pointing at the management container:
```
Webhook URL: http://10.10.10.105:9000/webhook
Secret: <shared secret set in management container .env>
Events: Push (to main branch only)
```
The update service maps incoming repo names to target LXC IPs and SSH commands:
```js
// management repo: updater/deploy-map.js
module.exports = {
'kitchen-flash': { ip: '10.10.10.110', path: '/opt/kitchen' },
'housekeeping': { ip: '10.10.10.114', path: '/opt/hk' },
'cashup': { ip: '10.10.10.111', path: '/opt/cashup' },
'hnf-portal': { ip: '10.10.10.102', path: '/opt/portal' },
'hnf-auth': { ip: '10.10.10.101', path: '/opt/auth' },
}
```
On receiving a valid webhook:
1. Validate Forgejo HMAC signature against shared secret
2. Look up repo name in deploy map
3. SSH to target LXC: `cd <path> && git pull && docker compose up -d --build`
4. Poll `http://<ip>:<port>/health` every 5s for up to 60s
5. Log result (success/fail) — optionally notify via portal admin or webhook back to Forgejo commit status
Each app LXC must have the management container's SSH public key in `~/.ssh/authorized_keys`.
The management container's SSH key is generated at provisioning time and distributed to all LXCs.
**Per-app Forgejo repo setup**: Each app lives in its own repo on your Forgejo server.
The `main` branch is production. The app LXC clones it at provision time; updates pull from it.
```bash
# On app LXC at provision time
git clone https://forgejo.yourserver.com/hnf/kitchen-flash.git /opt/kitchen
```
---
### Backup Service
Runs as a cron container (`docker-compose` service with `restart: unless-stopped` and cron inside).
**Schedule**:
- Daily at 02:00: `pg_dump` each database → compressed → rotate (keep 7 days)
- Weekly Sunday at 03:00: full `pg_dumpall` + data volume snapshot → rotate (keep 4 weeks)
- After each backup: rsync to remote destination
**PostgreSQL backup** (per database):
```bash
PGPASSWORD=$PG_PASS pg_dump -h 10.10.10.100 -U <app_user> <db_name> \
| gzip > /backups/postgres/<db_name>_$(date +%Y%m%d).sql.gz
```
**Volume backup** (for apps with persistent file data, e.g. kitchen invoice PDFs):
```bash
docker run --rm \
-v kitchen_invoice_data:/source:ro \
-v /backups/volumes:/backup \
alpine tar czf /backup/kitchen_invoices_$(date +%Y%m%d).tar.gz -C /source .
```
**Rsync to remote** (your own server, same place as Forgejo):
```bash
rsync -az --delete /backups/ user@yourserver.com:/backups/hnf-proxmox/
```
**Retention cleanup** (run after each backup cycle):
```bash
find /backups/postgres -name "*.gz" -mtime +7 -delete # daily: keep 7
find /backups/postgres -name "*_weekly_*.gz" -mtime +28 -delete # weekly: keep 4
```
**Backup `.env`** on management LXC:
```bash
PG_PASS=<postgres superuser password>
BACKUP_REMOTE_HOST=user@yourserver.com
BACKUP_REMOTE_PATH=/backups/hnf-proxmox
BACKUP_DATABASES=kitchen_db cashup_db hk_db forecast_db auth_db
BACKUP_VOLUMES=kitchen_invoice_data # space-separated docker volume names
```
**Backup status** is reported to Uptime Kuma via a heartbeat push URL — if the backup
script fails to complete, Kuma marks it down. Add a monitor of type "Push" in Kuma and
paste the push URL into the backup script as the last step.
---
## NPM LXC Config
NPM runs as a dual-homed LXC container — the only container with a LAN IP.
**Proxmox LXC network config** (in Proxmox UI or `/etc/pve/lxc/<id>.conf`):
```
net0: name=eth0,bridge=vmbr0,ip=10.4.X.X/22,gw=10.4.0.1 # LAN-facing — assign static IP from your pool
net1: name=eth1,bridge=vmbr1,ip=10.10.10.2/24 # internal
```
**NPM proxy host config** (one entry per app, all on same domain):
| Location | Forward to | Port | Notes |
|----------|-----------|------|-------|
| `/` | `10.10.10.102` | 3000 | Portal |
| `/api/auth/` | `10.10.10.101` | 3001 | Auth service |
| `/kitchen/` | `10.10.10.110` | 3080 | Kitchen (enable WS support — uses SSE for KDS) |
| `/hk/` | `10.10.10.114` | 3014 | Housekeeping |
| `/monitor/` | `10.10.10.105` | 3002 | Uptime Kuma (restrict to admin users via auth) |
All under domain `manage.hotelnumberfour.com`. Single Let's Encrypt cert via NPM's built-in ACME.
**Required NPM custom nginx snippet** (add to each proxy host's Advanced tab):
```nginx
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
```
The `X-Real-IP` header is what the auth service uses for offsite access restriction.
---
## Portal Registration
When a new app is ported and running, register it in the auth service DB:
```sql
INSERT INTO auth.apps (slug, name, description, base_path, port)
VALUES ('kitchen', 'Kitchen Flash', 'Invoice processing and GP tracking', '/kitchen', 3080);
```
Then grant access to users via the portal admin UI (or directly in the DB during early setup):
```sql
INSERT INTO auth.user_app_perms (user_id, app_id)
SELECT u.id, a.id FROM auth.users u, auth.apps a
WHERE u.email = 'jane@hotelnumberfour.com' AND a.slug = 'kitchen';
```
---
## LXC Provisioning
Each app LXC is provisioned with:
```bash
# Run infrastructure/lxc-templates/provision.sh <lxc-id> <ip> <app-name>
./infrastructure/lxc-templates/provision.sh 110 10.10.10.110 kitchen
```
This installs: Ubuntu 22.04, Docker, Docker Compose, copies `.env` template.
Then `git clone` or `rsync` the app directory and `docker-compose up -d`.

View file

@ -0,0 +1,15 @@
services:
npm:
image: jc21/nginx-proxy-manager:latest
ports:
- "80:80"
- "443:443"
- "81:81" # admin UI — restrict access to LAN only via firewall
volumes:
- npm_data:/data
- npm_letsencrypt:/etc/letsencrypt
restart: unless-stopped
volumes:
npm_data:
npm_letsencrypt:

View file

@ -0,0 +1,48 @@
#!/bin/bash
# Provision the NPM LXC — dual-homed (LAN + internal bridge)
# Usage: ./provision-npm.sh <lxc-id> <lan-ip> <lan-gateway>
# Example: ./provision-npm.sh 103 10.4.0.50 10.4.0.1
set -e
LXC_ID=$1
LAN_IP=$2
LAN_GW=$3
if [ -z "$LXC_ID" ] || [ -z "$LAN_IP" ] || [ -z "$LAN_GW" ]; then
echo "Usage: $0 <lxc-id> <lan-ip> <lan-gateway>"
exit 1
fi
echo "==> Creating NPM LXC $LXC_ID (dual-homed)"
pct create "$LXC_ID" local:vztmpl/ubuntu-22.04-standard_22.04-1_amd64.tar.zst \
--hostname "hnf-npm" \
--memory 512 \
--cores 1 \
--rootfs local-lvm:8 \
--net0 name=eth0,bridge=vmbr0,ip="${LAN_IP}/22",gw="${LAN_GW}" \
--net1 name=eth1,bridge=vmbr1,ip="10.10.10.2/24" \
--features nesting=1 \
--unprivileged 0 \
--start 1
sleep 5
pct exec "$LXC_ID" -- bash -c "
apt-get update -qq
apt-get install -y -qq ca-certificates curl gnupg
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 docker
"
echo ""
echo "==> NPM LXC ready"
echo " LAN: $LAN_IP (vmbr0)"
echo " Internal: 10.10.10.2 (vmbr1)"
echo " Next: deploy Nginx Proxy Manager via docker compose"
echo " NPM admin UI will be at http://$LAN_IP:81"

View file

@ -0,0 +1,66 @@
#!/bin/bash
# Provision an app LXC on the internal network (10.10.10.0/24)
# Usage: ./provision.sh <lxc-id> <last-octet> <app-name> <git-repo-url>
# Example: ./provision.sh 110 110 kitchen https://forgejo.yourserver.com/hnf/hnf-kitchen.git
set -e
LXC_ID=$1
OCTET=$2
APP=$3
REPO=$4
IP="10.10.10.${OCTET}"
if [ -z "$LXC_ID" ] || [ -z "$OCTET" ] || [ -z "$APP" ]; then
echo "Usage: $0 <lxc-id> <ip-octet> <app-name> [git-repo-url]"
exit 1
fi
echo "==> Creating LXC $LXC_ID: $APP at $IP"
# Create LXC (Ubuntu 22.04, no public template — adjust storage pool as needed)
pct create "$LXC_ID" local:vztmpl/ubuntu-22.04-standard_22.04-1_amd64.tar.zst \
--hostname "hnf-$APP" \
--memory 512 \
--cores 1 \
--rootfs local-lvm:8 \
--net0 name=eth0,bridge=vmbr1,ip="${IP}/24",gw=10.10.10.1 \
--features nesting=1 \
--unprivileged 0 \
--start 1
sleep 5
echo "==> LXC started, installing Docker..."
pct exec "$LXC_ID" -- bash -c "
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 docker
mkdir -p /opt/$APP
"
# Copy management container's SSH public key for update webhooks
if [ -f /root/.ssh/management_deploy.pub ]; then
pct exec "$LXC_ID" -- bash -c "
mkdir -p /root/.ssh
echo '$(cat /root/.ssh/management_deploy.pub)' >> /root/.ssh/authorized_keys
chmod 600 /root/.ssh/authorized_keys
"
echo "==> SSH key installed"
fi
# Clone app repo if provided
if [ -n "$REPO" ]; then
pct exec "$LXC_ID" -- bash -c "git clone '$REPO' /opt/$APP"
echo "==> Repo cloned to /opt/$APP"
fi
echo ""
echo "==> LXC $LXC_ID ($APP) ready at $IP"
echo " SSH: pct enter $LXC_ID"
echo " Next: copy .env, then: cd /opt/$APP && docker compose up -d"

View file

@ -0,0 +1,19 @@
services:
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: 10
restart: unless-stopped
volumes:
pg_data:

View file

@ -0,0 +1,4 @@
CREATE USER auth WITH PASSWORD 'CHANGE_ME';
CREATE DATABASE auth_db OWNER auth;
\c auth_db
GRANT ALL ON SCHEMA public TO auth;

View file

@ -0,0 +1,4 @@
CREATE USER noticeboard WITH PASSWORD 'CHANGE_ME';
CREATE DATABASE noticeboard_db OWNER noticeboard;
\c noticeboard_db
GRANT ALL ON SCHEMA public TO noticeboard;

812
install-stack.sh Executable file
View file

@ -0,0 +1,812 @@
#!/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