721 lines
24 KiB
Markdown
721 lines
24 KiB
Markdown
# 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_IP_CHECK` | `10.4.0.0/22,auto` | Onsite matchers (IP/CIDR/DDNS/`auto`/`disabled`) for offsite 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.
|
|
|
|
### Offsite restriction in an app (shared `ip-check.js`)
|
|
|
|
Each app enforces the offsite rule locally for users without `offsite_allowed`.
|
|
Copy `ip-check.js` (from the `auth` or `noticeboard` repo) into the app and call
|
|
`isOnsite(clientIP)`:
|
|
|
|
```js
|
|
import { isOnsite } from './ip-check.js'
|
|
// ...after verifying the cookie:
|
|
if (!payload.offsite_allowed) {
|
|
const clientIP = request.headers['x-real-ip'] || request.ip
|
|
if (!(await isOnsite(clientIP))) return reply.status(403).send({ error: 'Access restricted to site network' })
|
|
}
|
|
```
|
|
|
|
`OFFICE_IP_CHECK` is a comma-separated list; a request is onsite if ANY matcher
|
|
matches. Each matcher: an IP, a CIDR, a DDNS hostname, or `auto` (self-detect the
|
|
site's public IP via an external service — no DDNS client needed). Recommended
|
|
for a dynamic public IP: `10.4.0.0/22,auto` (LAN clients match the CIDR;
|
|
public-IP/hairpin clients match `auto`). `disabled` turns the check off.
|
|
|
|
---
|
|
|
|
## 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`.
|