Initial commit: stack
This commit is contained in:
commit
fe16a07dd7
11 changed files with 2058 additions and 0 deletions
700
docs/APP-INTEGRATION-GUIDE.md
Normal file
700
docs/APP-INTEGRATION-GUIDE.md
Normal 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`.
|
||||
Loading…
Add table
Add a link
Reference in a new issue