From f1200425af2d73f8b02bf455483d8d828be101ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Br=C3=BCckner?= Date: Mon, 8 Jun 2026 08:45:24 +0200 Subject: [PATCH] refactor(caddy): remove redundant GhostGrid domain fields, keep only custom routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit caddy_prod_domain and caddy_dev_domain are already handled by the Proxmox deploy process. The Caddy integration is a generic TLS proxy for additional services (Semaphore, Netbox, etc.) — the custom routes list is the sole mechanism. --- ARCHITECTURE.md | 810 ++++++++++++++++++++++++++++++++++++ server-db.ts | 184 ++++---- server.ts | 92 +++- src/components/Settings.tsx | 222 +++++++++- 4 files changed, 1214 insertions(+), 94 deletions(-) create mode 100644 ARCHITECTURE.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..bcdf2aa --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,810 @@ +# GhostGrid +## Architecture Reference + +**Version:** 1.1 +**Date:** June 8, 2026 +**Status:** Living document — single source of truth for the codebase + +> Use this document as the starting context for any future task on GhostGrid. It describes the whole application: purpose, stack, file layout, data model, REST API, frontend structure, integrations, background jobs, security, and deployment. + +### Revision History + +| Version | Date | Changes | +|---------|------|---------| +| 1.2 | Jun 8, 2026 | Removed `caddy_prod_domain` / `caddy_dev_domain` settings; Caddy now routes only custom entries | +| 1.1 | Jun 8, 2026 | Dropped the migration layer (fresh-install schema); renamed the `caddy_routes` table to `caddy` | +| 1.0 | Jun 8, 2026 | Initial architecture reference generated from the codebase | + +--- + +## 1. Executive Summary + +**GhostGrid** is an internal, offline-capable **network-lab and device-inventory tool** for managing hardware lab environments. Teams use it to keep a device inventory with live status, define lab templates (devices + topology), book labs for a time window, and automatically run Ansible playbooks at booking start/end. It pulls live device status from CheckMK and can manage Caddy reverse-proxy routes and Microsoft Entra ID SSO from its own UI. + +### Key Design Decisions + +| Aspect | Choice | Rationale | +|--------|--------|-----------| +| Scope | Single-tenant internal tool | Small team / lab operations, not multi-tenant SaaS | +| Process model | **One Node.js process** serving API + frontend | Simple to deploy, no orchestration needed | +| Backend | Express 4 + TypeScript | Minimal, well-understood, fast to iterate | +| Frontend | React 19 + Vite 6 | Modern SPA, no router dependency (tab state) | +| Database | **SQLite** (`better-sqlite3`, WAL) | Zero-ops, single file, synchronous, perfect for air-gapped LAN | +| Styling | Tailwind CSS v4 | Utility-first, dark/light theme via class toggle | +| Auth | Local JWT + optional Azure Entra ID OAuth | Self-contained, SSO optional | +| Offline | Fonts bundled via `@fontsource` | No CDN / external runtime assets | +| Integrations | CheckMK, Ansible Semaphore, Caddy | All configured at runtime in the Settings UI (stored in DB) | +| Deployment | Proxmox LXC + systemd, two instances | Manual `git pull && build && restart` model | + +**Core constraint:** runs **fully offline**. No external code, assets, or CDN resources are loaded at runtime. + +--- + +## 2. System Architecture + +### 2.1 High-Level Architecture + +``` ++-----------------------------------------------------------------------------+ +| GHOSTGRID PLATFORM | ++-----------------------------------------------------------------------------+ +| +---------------------------------------------------------------------+ | +| | PRESENTATION LAYER | | +| | +-----------------------------+ +----------------------------+ | | +| | | React 19 SPA (Vite) | | Browser localStorage | | | +| | | - Tab-based navigation | | - ghostgrid_token (JWT) | | | +| | | - Tailwind dark/light | | - ghostgrid_user | | | +| | +-----------------------------+ +----------------------------+ | | +| +---------------------------------------------------------------------+ | +| | authFetch > Bearer | +| +---------------------------------------------------------------------+ | +| | APPLICATION LAYER (server.ts) | | +| | Single Express process — serves API + frontend | | +| | +-----------+ +-----------+ +-----------+ +-----------+ +--------+ | | +| | | Auth | | Devices | | Labs | | Bookings | | Logs | | | +| | | (JWT/MSAL)| | CRUD | | CRUD | | CRUD | | | | | +| | +-----------+ +-----------+ +-----------+ +-----------+ +--------+ | | +| | +-----------+ +-----------+ +-----------+ +-----------+ | | +| | | Users | | Links | | Settings | | Caddy | | | +| | +-----------+ +-----------+ +-----------+ +-----------+ | | +| | +---------------------------------------------------------------+ | | +| | | Background jobs (self-rescheduling setTimeout loops) | | | +| | | - CheckMK status sync (default 60s) | | | +| | | - Semaphore setup/teardown trigger (30s) | | | +| | +---------------------------------------------------------------+ | | +| +---------------------------------------------------------------------+ | +| | | +| +---------------------------------------------------------------------+ | +| | DATA LAYER (server-db.ts) | | +| | +-------------------------------------------------------------+ | | +| | | SQLite — ghostgrid.db (better-sqlite3, WAL) | | | +| | | users · devices · labs · bookings · logs · links | | | +| | | settings · caddy | | | +| | +-------------------------------------------------------------+ | | +| +---------------------------------------------------------------------+ | ++-----------------------------------------------------------------------------+ +| EXTERNAL INTEGRATIONS | +| +-------------+ +------------------+ +-----------+ +----------------+ | +| | CheckMK | | Ansible Semaphore| | Caddy | | Microsoft | | +| | REST API | | REST API | | Admin API | | Entra ID (MSAL)| | +| | (status) | | (playbook tasks) | | (/load) | | (OAuth 2.0) | | +| +-------------+ +------------------+ +-----------+ +----------------+ | ++-----------------------------------------------------------------------------+ +``` + +### 2.2 Component Breakdown + +#### 2.2.1 Presentation Layer + +| Component | Technology | Purpose | +|-----------|------------|---------| +| Web UI | React 19 + TypeScript | Dashboard, booking calendar, inventory, topology, settings | +| Build/dev server | Vite 6 | Bundles the SPA; mounted as Express middleware in dev | +| Session store | Browser `localStorage` | Persists JWT + user between reloads | + +#### 2.2.2 Application Layer (`server.ts` — single process) + +| Route group | Responsibility | +|---------|----------------| +| Auth | Local register/login (JWT), Azure Entra ID OAuth, `/me`, public `/config` | +| Devices | Inventory CRUD; delete also scrubs the device from labs | +| Labs | Lab-template CRUD; `deviceIds`/`topology` stored as JSON | +| Bookings | Reservation CRUD; cancellation can trigger Semaphore teardown | +| Logs | Audit/maintenance journal (read + manual create) | +| Users | Team list, edit, delete (self/last-user guarded) | +| Links | Shared quick-links dashboard CRUD | +| Settings | Integration config (Azure, CheckMK, Semaphore, Caddy); secrets masked | +| CheckMK | Manual sync trigger | +| Semaphore | Template-list proxy, manual setup/teardown trigger | +| Caddy | Status, route CRUD, Caddyfile push | +| Background jobs | CheckMK sync loop + Semaphore trigger loop | +| Static serving | Vite middleware (dev) / static `dist/` + SPA fallback (prod) | + +#### 2.2.3 Data Layer (`server-db.ts`) + +| Component | Technology | Purpose | +|-----------|------------|---------| +| Database | SQLite via `better-sqlite3` | Single file `ghostgrid.db`, WAL journal mode, synchronous queries | +| Schema | Idempotent `CREATE TABLE IF NOT EXISTS` | 8 tables defined in full and created on boot (fresh-install model, no migrations) | +| Settings store | key/value `settings` table | Runtime config for all integrations, seeded with `INSERT OR IGNORE` | + +--- + +## 3. Technology Stack + +### 3.1 Backend Stack + +``` +Node.js 20 LTS (TypeScript ~5.8, ES modules) ++-- Web Framework +| +-- express 4.21 (HTTP server, routing, JSON middleware) +| +-- vite 6 (createServer) (dev middleware mode, SPA) ++-- Auth & Security +| +-- jsonwebtoken 9 (JWT sign/verify, 24h expiry) +| +-- bcryptjs 2.4 (password hashing, cost 10) +| +-- @azure/msal-node 5 (Entra ID OAuth 2.0 auth-code flow) ++-- Data Access +| +-- better-sqlite3 12 (synchronous SQLite driver, WAL) ++-- Utilities +| +-- dotenv 17 (.env loading) +| +-- (global fetch) (CheckMK / Semaphore / Caddy HTTP calls) ++-- Build / Run + +-- tsx 4 (dev: run server.ts directly) + +-- esbuild 0.25 (bundle server > dist/server.cjs) +``` + +### 3.2 Frontend Stack + +``` +React 19 + TypeScript ++-- State Management +| +-- React hooks only (useState/useEffect in App.tsx — no Redux/Zustand) +| +-- localStorage (token + user persistence via src/lib/auth.ts) ++-- UI / Styling +| +-- tailwindcss 4 (@tailwindcss/vite plugin) +| +-- lucide-react 0.546 (icon set) ++-- Fonts (self-hosted, offline) +| +-- @fontsource/inter +| +-- @fontsource/jetbrains-mono ++-- Networking +| +-- fetch + authFetch() (thin wrapper injecting Authorization header) ++-- Build Tools + +-- vite 6 + @vitejs/plugin-react +``` + +### 3.3 Data Layer + +``` +SQLite (single file: ghostgrid.db, WAL mode) ++-- users (local + Azure-provisioned accounts) ++-- devices (inventory + CheckMK-synced status) ++-- labs (templates: deviceIds[] + topology[] as JSON) ++-- bookings (reservations + Ansible trigger flags/jobs) ++-- logs (audit/maintenance journal) ++-- links (shared quick-links dashboard) ++-- settings (key/value runtime config for integrations) ++-- caddy (custom reverse-proxy routes) +``` + +### 3.4 Infrastructure + +``` +Deployment ++-- Proxmox LXC (Debian 12, unprivileged) ++-- systemd services (ghostgrid + ghostgrid-dev) ++-- Two parallel instances (main:3000, dev:3001), separate DBs ++-- deploy/proxmox-ghostgrid.sh (one-shot installer) ++-- deploy/deploy.sh (git pull + build + restart) + +Networking (optional, managed in-app) ++-- Caddy reverse proxy (local_certs, tls internal) ++-- Caddyfile pushed to Caddy Admin API /load +``` + +--- + +## 4. Database Schema Design + +SQLite file `ghostgrid.db` in `process.cwd()`, opened with `journal_mode = WAL`. Every table is defined in full and created idempotently on boot (`CREATE TABLE IF NOT EXISTS`) — the app assumes a fresh install, so there is no migration layer. + +### 4.1 Schema (as created in `server-db.ts`) + +```sql +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'User', + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL -- bcrypt; '' for Azure-provisioned users +); + +CREATE TABLE IF NOT EXISTS devices ( + id TEXT PRIMARY KEY, + hostname TEXT NOT NULL, + ip TEXT NOT NULL, + location TEXT NOT NULL, + notes TEXT, + type TEXT NOT NULL, -- Switch | Firewall | Access-Point | Controller | custom + status TEXT NOT NULL, -- online | offline | unknown + emergencySheet TEXT NOT NULL, -- markdown + lastCheckedAt TEXT, + checkMkUrl TEXT NOT NULL DEFAULT '', + cmkHostname TEXT NOT NULL DEFAULT '' +); + +CREATE TABLE IF NOT EXISTS labs ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT NOT NULL, + contactPerson TEXT NOT NULL, + location TEXT NOT NULL, + deviceIds TEXT NOT NULL, -- JSON string: string[] + topology TEXT NOT NULL, -- JSON string: TopologyLink[] + semaphoreSetupTemplateId TEXT NOT NULL DEFAULT '', + semaphoreTeardownTemplateId TEXT NOT NULL DEFAULT '' +); + +CREATE TABLE IF NOT EXISTS bookings ( + id TEXT PRIMARY KEY, + labId TEXT NOT NULL, + userId TEXT NOT NULL, + startDateTime TEXT NOT NULL, + endDateTime TEXT NOT NULL, + notes TEXT, + status TEXT NOT NULL, -- active | upcoming | completed | cancelled + notified INTEGER NOT NULL DEFAULT 0, + emailSent INTEGER NOT NULL DEFAULT 0, + ansibleSetupTriggered INTEGER NOT NULL DEFAULT 0, + ansibleTeardownTriggered INTEGER NOT NULL DEFAULT 0, + ansibleSetupJobId TEXT NOT NULL DEFAULT '', + ansibleTeardownJobId TEXT NOT NULL DEFAULT '' +); + +CREATE TABLE IF NOT EXISTS logs ( + id TEXT PRIMARY KEY, + timestamp TEXT NOT NULL, + type TEXT NOT NULL, -- maintenance | booking | status | system + message TEXT NOT NULL, + deviceId TEXT, + userId TEXT +); + +CREATE TABLE IF NOT EXISTS links ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + url TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + category TEXT NOT NULL DEFAULT '', + color TEXT NOT NULL DEFAULT 'emerald', + createdBy TEXT, + createdAt TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS caddy ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + hostname TEXT NOT NULL, + upstream TEXT NOT NULL, + tls INTEGER NOT NULL DEFAULT 1, + compress INTEGER NOT NULL DEFAULT 1, + created_at TEXT DEFAULT (datetime('now')) +); +``` + +### 4.2 Data-Model Notes + +| Topic | Detail | +|-------|--------| +| IDs | App-generated strings: `` `${prefix}-${Date.now()}-${rand}` `` (`dev-…`, `lab-…`, `book-…`, `log-…`, `u-…`, `link-…`); `caddy` uses an autoincrement integer | +| JSON columns | `labs.deviceIds` and `labs.topology` are JSON strings, parsed in the API layer | +| Booleans | Booking flags (`notified`, `emailSent`, `ansible*Triggered`) are `INTEGER` 0/1, mapped to JS booleans on read | +| Cascade | No FK cascades; referential cleanup is done in code (e.g. deleting a device scrubs it from every lab's `deviceIds`/`topology`) | +| Schema changes | Fresh-install model — edit the `CREATE TABLE` block in `server-db.ts` directly; there is no migration helper | + +### 4.3 Settings (key/value config) + +Seeded with `INSERT OR IGNORE` (defaults below). Secret keys are never returned raw — `maskSettings()` replaces them with the `__SET__` sentinel. + +| Group | Keys (default) | +|-------|----------------| +| Azure | `azure_enabled`(false), `azure_client_id`, `azure_tenant_id`, `azure_client_secret`🔒, `azure_redirect_uri`, `azure_allowed_group` | +| CheckMK | `checkmk_enabled`(false), `checkmk_api_url`, `checkmk_api_user`(automation), `checkmk_api_secret`🔒, `checkmk_sync_interval_ms`(60000) | +| Semaphore | `semaphore_enabled`(false), `semaphore_api_url`, `semaphore_api_token`🔒, `semaphore_project_id` | +| Caddy | `caddy_enabled`(false), `caddy_admin_url`(http://localhost:2019) | + +🔒 = in `SECRET_KEYS`, masked on read, only updated when a non-`__SET__` value is sent. + +--- + +## 5. API Design + +All `/api/*` routes return JSON. Every route except the public auth/config endpoints requires `requireAuth` (JWT bearer). + +### 5.1 REST API Structure + +``` +/api ++-- /auth +| +-- POST /register # Create local account > { token, user } [public] +| +-- POST /login # Authenticate > { token, user } [public] +| +-- GET /me # Current user from token [auth] +| +-- GET /config # { azureEnabled, effectiveRedirectUri, [public] +| | # checkmkEnabled, checkmkBaseUrl } +| +-- GET /azure # Start Azure OAuth (redirect to Microsoft) [public] +| +-- GET /azure/callback # OAuth callback > redirect /?token=… [public] +| ++-- /settings +| +-- GET / # All settings (secrets masked as __SET__) [auth] +| +-- PUT / # Update allow-listed keys; re-push Caddy [auth] +| ++-- /users +| +-- GET / # List users [auth] +| +-- PUT /{id} # Update name/email (dupe-email guarded) [auth] +| +-- DELETE /{id} # Delete (not self, not last user) [auth] +| ++-- /devices +| +-- GET / # List devices [auth] +| +-- POST / # Create device (+ maintenance log) [auth] +| +-- PUT /{id} # Update device (+ maintenance log) [auth] +| +-- DELETE /{id} # Delete + scrub from all labs [auth] +| ++-- /labs +| +-- GET / # List labs (parses deviceIds/topology JSON) [auth] +| +-- POST / # Create lab [auth] +| +-- PUT /{id} # Update lab [auth] +| +-- DELETE /{id} # Delete + cancel upcoming bookings [auth] +| ++-- /bookings +| +-- GET / # List bookings (int flags > booleans) [auth] +| +-- POST / # Create booking (+ log, alertGenerated) [auth] +| +-- PUT /{id} # Update status; cancel>teardown trigger [auth] +| +-- DELETE /{id} # Delete booking [auth] +| ++-- /logs +| +-- GET / # All logs, newest first [auth] +| +-- POST / # Manual log entry [auth] +| ++-- /links +| +-- GET / # Quick links (ordered category, title) [auth] +| +-- POST / # Create link [auth] +| +-- PUT /{id} # Update link [auth] +| +-- DELETE /{id} # Delete link [auth] +| ++-- /checkmk +| +-- POST /sync # Trigger CheckMK status sync now [auth] +| ++-- /semaphore +| +-- GET /templates # Proxy Semaphore task-template list [auth] +| +-- POST /trigger/{bookingId} # Manual setup|teardown for a booking [auth] +| ++-- /caddy + +-- GET /status # Caddy admin API reachable? [auth] + +-- GET /routes # { system, custom } routes [auth] + +-- POST /routes # Add custom route + push config [auth] + +-- DELETE /routes/{id} # Remove custom route + push config [auth] +``` + +### 5.2 Authentication & Authorization + +``` +Auth model ++-- Token: JWT (HS256, secret = JWT_SECRET), payload { userId, email }, expiry 24h ++-- Transport: Authorization: Bearer (no cookies > no CSRF surface) ++-- Storage: browser localStorage (ghostgrid_token, ghostgrid_user) ++-- Middleware +| +-- requireAuth — verifies JWT, sets req.user; applied to all data routes +| +-- requireAdmin — checks users.role === 'admin' ⚠ DEFINED BUT NOT WIRED ++-- Roles: role column defaults to 'User'; no route currently enforces admin +``` + +**Local flow:** `register` (bcrypt hash, role `User`) / `login` (bcrypt compare) > issue JWT > client stores token + user. + +**Azure Entra ID (OAuth 2.0 auth-code flow):** + +``` +1. GET /api/auth/config > frontend learns azureEnabled, shows SSO button +2. GET /api/auth/azure > MSAL getAuthCodeUrl > 302 to Microsoft +3. GET /api/auth/azure/callback> acquireTokenByCode + > optional azure_allowed_group membership check + > upsert user (auto-provision, empty password) + > 302 /?token= +4. App.tsx reads ?token= / ?auth_error=, verifies via /api/auth/me, persists +``` + +--- + +## 6. Integrations & Background Jobs + +All three integrations are configured at runtime via the Settings UI (stored in the `settings` table). The background loops re-read settings each cycle, so CheckMK interval changes take effect without a restart. + +### 6.1 CheckMK — Device Status Sync + +``` +Loop: scheduleSync() > syncCheckMkStatuses() > setTimeout(checkmk_sync_interval_ms) + (default 60s; skipped entirely if checkmk_enabled !== 'true') + +Auth header: Authorization: Bearer + +Step 1 GET /domain-types/host_config/collections/all + > build IP > hostname map (checks attributes + effective_attributes) +Step 2 for each device: + - no CheckMK host for its IP > status 'unknown' + - GET /objects/host/{name}?columns=state… + state 0 > online + state 1 | 2 > offline + else > unknown + - update devices.status, lastCheckedAt, cmkHostname + - on change: write a 'status' log +Summary log per cycle: " online, offline, unknown" +HTTP hints: 401/403/404 mapped to actionable messages (checkmkHttpHint) +``` + +### 6.2 Ansible Semaphore — Playbook Automation + +``` +Loop: scheduleSemaphoreCheck() > checkAndTriggerAnsibleTasks() > setTimeout(30s) + (skipped if semaphore_enabled !== 'true') + +Setup bookings WHERE startDateTime <= now AND ansibleSetupTriggered=0 + AND status != 'cancelled' > trigger lab.semaphoreSetupTemplateId +Teardown bookings WHERE endDateTime <= now AND ansibleTeardownTriggered=0 + AND status != 'cancelled' > trigger lab.semaphoreTeardownTemplateId + (also triggered immediately when a started booking is cancelled) + +triggerSemaphoreTask(templateId, extraVars): + POST {apiUrl}/api/project/{projectId}/tasks + body { template_id, environment: JSON.stringify(extraVars) } + extraVars = { booking_id, lab_name, user_id, start_time, end_time } + > store returned job id on booking; log success/failure + (a booking with no template id is marked triggered > not retried) + +Manual: POST /api/semaphore/trigger/{bookingId} body { type: 'setup'|'teardown' } + GET /api/semaphore/templates (proxy for UI dropdowns) +``` + +### 6.3 Caddy — Reverse Proxy + +``` +buildCaddyfile(): + { local_certs } # global block + per custom route { [encode] [tls internal] reverse_proxy } + +pushCaddyConfig(): POST /load (Content-Type: text/caddyfile) + called on startup, after settings save, after route add/delete + (failures logged as warnings, non-fatal; skipped if caddy_enabled !== 'true') +``` + +--- + +## 7. Frontend Architecture + +### 7.1 Application Structure + +``` +src/ ++-- main.tsx # React entry: imports fonts + index.css, renders ++-- App.tsx # Stateful root: auth gate, data loading, tab routing, all handlers ++-- index.css # Tailwind + theme tokens ++-- types.ts # Shared interfaces (Device, LabTemplate, Booking, …) — see §8 ++-- vite-env.d.ts ++-- lib/ +| +-- auth.ts # localStorage token/user, authFetch() wrapper, session helpers ++-- components/ + +-- Header.tsx # Top bar; exports GhostGridLogo; notifications; theme/logout + +-- Dashboard.tsx # Active/upcoming bookings + quick-links widget + +-- BookingCalendar.tsx # Day-offset grid; create/cancel; conflict + online checks + +-- BookingDetailsModal.tsx # Booking detail; manual Semaphore trigger; cancel/delete + +-- DeviceInventory.tsx # List/detail; CRUD; markdown emergency sheet; CheckMK link + +-- LabTemplates.tsx # Lab CRUD + topology editor; embeds TopologyPanel + +-- TopologyPanel.tsx # Pure SVG (800x400) node/link renderer + +-- Logbook.tsx # Sorted/filtered log list + manual entry + +-- LinkDashboard.tsx # Quick-link CRUD; 6 accent colors; category grouping + +-- UserDirectory.tsx # Team list; avatar colors; edit/delete modal + +-- LoginPage.tsx # Local login + Azure SSO button (if enabled) + +-- RegisterPage.tsx # Self-registration form + +-- Settings.tsx # Integration config cards (Azure, CheckMK, Semaphore, Caddy) +``` + +### 7.2 State & Data Flow + +`App.tsx` is the single stateful root (no router, no global store): + +``` ++-- Auth state: currentUser (from localStorage), authView (login|register), authChecked ++-- App data: users, devices, labs, bookings, logs, links (loaded in one Promise.all) ++-- UI state: activeTab, navCollapsed*, theme* (dark|light), notifications, +| selectedBookingForDetails, inventoryHighlightDevice, checkmk{Enabled,BaseUrl} ++-- Effects: +| +-- Startup token verify + OAuth ?token=/?auth_error= handling +| +-- Load data on login +| +-- Poll GET /api/devices every 30s (surface CheckMK-driven status changes) +| +-- Booking reminder check every 60s (fires once per upcoming booking ≤30min away) ++-- Handlers: handleAdd/Update/Delete* for bookings, devices, labs, links, users + + handleAddLogManually — call API via authFetch, update local state, + most then refetch /api/logs + +(* persisted to localStorage) +``` + +**Navigation** is a plain `activeTab` switch. Groups: Dashboard / Lab Management (Booking, Inventory, Topology) / Resources (Quick Links, Team) / Audit (Logbook) / System (Settings). + +### 7.3 Key UI Components + +``` +Dashboard ++-- Active / upcoming booking cards ++-- Quick-links widget ++-- Navigation shortcuts (to calendar, devices, labs, links) + +Booking Calendar ++-- Day-offset grid ++-- Create booking with conflict detection + device-online validation ++-- (devices in 'unknown' status are not bookable when CheckMK enabled) + +Device Inventory ++-- Searchable list + detail panel ++-- CRUD; class presets (Switch/Firewall/Access-Point/Controller) + free-form ++-- Markdown emergency sheet; optional CheckMK deep-link + +Lab Templates + Topology ++-- Lab CRUD; Semaphore setup/teardown template selection ++-- Topology link editor (fromDevice > toDevice, link type) ++-- TopologyPanel: SVG layout by node count (1 / 2 / 3 / circular) + +Settings ++-- Microsoft Entra ID (OAuth SSO, redirect-URI helper, allowed group) ++-- CheckMK (API URL/user/secret, sync interval, "Run sync now") ++-- Ansible Semaphore (API URL/token/project, "Test connection") ++-- Caddy (admin URL, custom route management) ++-- Secret inputs use the __SET__ sentinel (blank = keep existing) +``` + +--- + +## 8. Shared Types (`src/types.ts`) + +The single contract between frontend and backend — imported by **both** `server.ts` and the React components. + +| Type | Notes | +|------|-------| +| `DeviceType` | `'Switch' \| 'Access-Point' \| 'Firewall' \| 'Controller' \| (string & {})` — presets + free-form | +| `Device` | `status: 'online' \| 'offline' \| 'unknown'`; `emergencySheet` markdown; optional `cmkHostname`, `lastCheckedAt` | +| `TopologyLink` | `{ fromDevice, toDevice, type }` (e.g. `LACP-Trunk`, `Uplink`, `OOB-Management`) | +| `LabTemplate` | `deviceIds: string[]`, `topology: TopologyLink[]`, optional Semaphore template IDs | +| `Booking` | `status: 'active' \| 'upcoming' \| 'completed' \| 'cancelled'`; ansible trigger flags + job IDs | +| `LogEntry` | `type: 'maintenance' \| 'booking' \| 'status' \| 'system'` | +| `User` | `{ id, name, role, email }` (never password on the client) | +| `QuickLink` | `{ id, title, url, description, category, color, createdBy?, createdAt }` | + +--- + +## 9. Deployment Architecture + +### 9.1 Process & Build Model + +``` +Dev : npm run dev > tsx server.ts + Express + Vite middleware (HMR) on :3000 + +Build : npm run build + vite build > dist/ (frontend) + esbuild server.ts --bundle --platform=node --format=cjs + --packages=external > dist/server.cjs + +Prod : NODE_ENV=production node dist/server.cjs + Express serves static dist/ + SPA fallback (GET * > dist/index.html) +``` + +### 9.2 Proxmox LXC + systemd (two instances) + +``` ++----------------------- Proxmox LXC (Debian 12) -----------------------+ +| | +| Production Staging | +| +-------------------------+ +-----------------------------+ | +| | branch : main | | branch : dev | | +| | dir : /opt/ghostgrid| | dir : /opt/ghostgrid-dev | | +| | port : 3000 | | port : 3001 | | +| | service : ghostgrid | | service : ghostgrid-dev | | +| | db : ghostgrid.db | | db : ghostgrid.db (own) | | +| | .env : own JWT_SECRET| | .env : own JWT_SECRET | | +| +-------------------------+ +-----------------------------+ | +| | +| Both exposed directly on the LAN (no reverse proxy / TLS by default; | +| the in-app Caddy feature can add this). | ++-----------------------------------------------------------------------+ + +Install : deploy/proxmox-ghostgrid.sh (creates LXC, Node 20, clones both + branches, builds, configures services) +Update : deploy/deploy.sh (git pull + npm run build + systemctl restart; + defaults to main) +Backup : ghostgrid.db + ghostgrid.db-wal + ghostgrid.db-shm +``` + +### 9.3 Environment Variables + +| Var | Default | Purpose | +|-----|---------|---------| +| `JWT_SECRET` | insecure fallback | Sign/verify JWTs — **must be set in production** | +| `APP_URL` | `http://localhost:` | Base URL for deriving the Azure redirect URI | +| `PORT` | `3000` | HTTP listen port | +| `NODE_ENV` | — | `production` switches to static `dist/` serving | +| `CHECKMK_API_URL` / `CHECKMK_API_USER` / `CHECKMK_API_SECRET` | — | Fallbacks if not set in the Settings UI | + +--- + +## 10. Security Architecture + +``` ++-------------------------------------------------------------+ +| SECURITY OVERVIEW | ++-------------------------------------------------------------+ +| Authentication | +| +-- Local users: bcrypt password hashing (cost 10) | +| +-- JWT (HS256), 24h expiry, no refresh tokens | +| +-- Token in Authorization header (not cookies) | +| +-- Optional Azure Entra ID SSO (MSAL), group restriction | ++-------------------------------------------------------------+ +| Authorization | +| +-- requireAuth on all data routes | +| +-- role column ('User'/'admin') exists | +| +-- ⚠ requireAdmin defined but NOT applied — any | +| authenticated user can read/write settings + users | ++-------------------------------------------------------------+ +| Secret Handling | +| +-- Integration secrets stored in settings table | +| +-- Masked as __SET__ on read (SECRET_KEYS) | +| +-- Only overwritten when a non-sentinel value is sent | ++-------------------------------------------------------------+ +| Notable gaps / accepted risks | +| +-- JWT_SECRET has an insecure fallback if unset | +| +-- POST /api/bookings trusts client-supplied userId | +| (does not force req.user.userId) | +| +-- No rate limiting on auth endpoints | +| +-- Secrets at rest are plaintext in SQLite (file perms | +| are the protection boundary) | ++-------------------------------------------------------------+ +``` + +--- + +## 11. Project Structure + +``` +GhostGrid/ ++-- server.ts # Express app: all routes, auth, integrations, background jobs ++-- server-db.ts # SQLite connection, full schema, settings/Caddy helpers ++-- index.html # Vite HTML entry (#root > src/main.tsx) ++-- vite.config.ts # Vite + React + Tailwind; '@' alias > repo root ++-- tsconfig.json # noEmit, react-jsx, bundler resolution ++-- package.json # scripts + deps (package name "react-example" is vestigial) ++-- .env.example # JWT_SECRET, APP_URL ++-- metadata.json # app name/description metadata ++-- README.md # user-facing overview ++-- DEPLOY.md # Proxmox LXC / systemd deployment guide ++-- ARCHITECTURE.md # ← this file ++-- deploy/ +| +-- deploy.sh # git pull + build + systemctl restart (arg: branch) +| +-- ghostgrid.service # systemd unit — production (main, :3000) +| +-- ghostgrid-dev.service # systemd unit — staging (dev, :3001) +| +-- proxmox-ghostgrid.sh # one-shot LXC installer (Proxmox VE helper-script style) ++-- src/ + +-- main.tsx # React entry + +-- App.tsx # root component (state, routing, handlers) + +-- index.css # Tailwind + theme + +-- types.ts # shared TS interfaces + +-- vite-env.d.ts + +-- lib/auth.ts # token storage + authFetch + +-- components/ # 14 components (see §7.1) + +# Runtime artifacts (gitignored): +# ghostgrid.db, ghostgrid.db-wal, ghostgrid.db-shm, dist/, node_modules/ +``` + +--- + +## 12. Key Technical Decisions Summary + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| **Process model** | Single Express process serves API + SPA | No orchestration; trivial LXC deployment | +| **Database** | SQLite (`better-sqlite3`, synchronous, WAL) | Zero-ops, single file, ideal for LAN/air-gap | +| **Auth** | JWT in `localStorage` + optional Azure MSAL | Self-contained; SSO optional, header-based (no CSRF) | +| **Frontend state** | React hooks only (no Redux/Zustand/router) | App is small; one stateful root is enough | +| **Runtime config** | Integration settings in DB, edited in Settings UI | No redeploy to change CheckMK/Semaphore/Caddy/Azure | +| **Background jobs** | Self-rescheduling `setTimeout` loops | Picks up settings changes each cycle; no cron/queue dep | +| **Styling** | Tailwind v4 + class-based dark/light | Utility-first, theme toggled on `` | +| **Offline** | Fonts bundled via `@fontsource` | No CDN / external runtime fetches | +| **Build** | Vite (frontend) + esbuild (server bundle) | Fast, single `dist/server.cjs` output | + +--- + +## 13. Operational Notes & Risk Mitigation + +| Risk / Concern | Mitigation / Status | +|----------------|---------------------| +| Missing `JWT_SECRET` in prod | Documented in `.env.example`/DEPLOY.md; **set per instance** (installer generates a random one) | +| No admin RBAC enforced | `requireAdmin` exists — wire it onto `/api/settings` and `/api/users` if stricter control is needed | +| Client-supplied `userId` on booking create | Force `req.user.userId` server-side if spoofing is a concern | +| CheckMK/Semaphore outage | Integration loops catch errors, log them, and retry next cycle; non-fatal | +| Caddy admin API unreachable | `pushCaddyConfig()` failures are logged as warnings; routes apply when Caddy starts | +| Data loss | Back up `ghostgrid.db` + `-wal`/`-shm`; each instance has its own DB | +| Schema evolution | Edit the `CREATE TABLE` block in `server-db.ts` (fresh-install model, no migrations); new settings need seed + allow-list (+ `SECRET_KEYS` if secret) | + +--- + +## 14. Dependencies and Libraries + +### 14.1 Backend (`dependencies`) + +``` +@azure/msal-node ^5.2.2 # Entra ID OAuth +@fontsource/inter ^5.2.8 # self-hosted font (used by frontend build) +@fontsource/jetbrains-mono ^5.2.8 +@tailwindcss/vite ^4.1.14 # Tailwind v4 Vite plugin +@vitejs/plugin-react ^5.0.4 +bcryptjs ^2.4.3 # password hashing +better-sqlite3 ^12.10.0 # SQLite driver +dotenv ^17.2.3 # .env loading +express ^4.21.2 # HTTP server +jsonwebtoken ^9.0.2 # JWT +lucide-react ^0.546.0 # icons +react / react-dom ^19.0.1 +vite ^6.2.3 +``` + +### 14.2 Dev / Build (`devDependencies`) + +``` +@types/bcryptjs, @types/better-sqlite3, @types/express, +@types/jsonwebtoken, @types/node # type definitions +esbuild ^0.25.0 # bundle server.ts > dist/server.cjs +tailwindcss ^4.1.14 +tsx ^4.21.0 # run TS directly in dev +typescript ~5.8.2 +``` + +### 14.3 npm Scripts + +| Script | Command | Purpose | +|--------|---------|---------| +| `dev` | `tsx server.ts` | Dev server (Express + Vite middleware) on :3000 | +| `build` | `vite build && esbuild server.ts … --outfile=dist/server.cjs` | Build frontend + bundle server | +| `start` | `node dist/server.cjs` | Run production build (`NODE_ENV=production`) | +| `clean` | `rm -rf dist server.js` | Remove build artifacts | +| `lint` | `tsc --noEmit` | Type check | + +--- + +## 15. Quick Mental Model (for future prompts) + +``` +Browser (React SPA, localStorage JWT) + │ authFetch > Authorization: Bearer + ▼ +Express (server.ts) ──► better-sqlite3 (ghostgrid.db, WAL) + ├─ /api/auth/* (JWT local + Azure MSAL) + ├─ /api/{devices,labs,bookings,logs,links,users,settings} + ├─ /api/checkmk/* ── background: ~60s status sync + ├─ /api/semaphore/* ── background: 30s setup/teardown trigger + └─ /api/caddy/* ── pushes Caddyfile to Caddy admin API + │ + └─ serves frontend: Vite middleware (dev) / static dist/ (prod) +``` + +**Invariants to remember when editing:** +- Frontend and backend share `src/types.ts` — change both sides together. +- `labs.deviceIds` / `labs.topology` are JSON strings in SQLite, parsed in the API. +- Booking boolean flags are 0/1 integers in SQLite, mapped on read. +- A new settings key must be: **seeded** in `server-db.ts`, **allow-listed** in `PUT /api/settings`, and (if secret) added to `SECRET_KEYS`. +- Schema changes go straight into the `CREATE TABLE` block in `server-db.ts` — fresh-install model, no migration helper. +- All user-facing strings are in **English**. + +--- + +*Generated from the GhostGrid codebase. Keep this document in sync when the data model, API surface, or integrations change.* diff --git a/server-db.ts b/server-db.ts index aeffead..2d92be1 100644 --- a/server-db.ts +++ b/server-db.ts @@ -10,112 +10,114 @@ db.pragma('journal_mode = WAL'); db.exec(` CREATE TABLE IF NOT EXISTS users ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - role TEXT NOT NULL DEFAULT 'User', - email TEXT NOT NULL UNIQUE, + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'User', + email TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS devices ( - id TEXT PRIMARY KEY, - hostname TEXT NOT NULL, - ip TEXT NOT NULL, - location TEXT NOT NULL, - notes TEXT, - type TEXT NOT NULL, - status TEXT NOT NULL, + id TEXT PRIMARY KEY, + hostname TEXT NOT NULL, + ip TEXT NOT NULL, + location TEXT NOT NULL, + notes TEXT, + type TEXT NOT NULL, + status TEXT NOT NULL, emergencySheet TEXT NOT NULL, - lastCheckedAt TEXT + lastCheckedAt TEXT, + checkMkUrl TEXT NOT NULL DEFAULT '', + cmkHostname TEXT NOT NULL DEFAULT '' ); CREATE TABLE IF NOT EXISTS labs ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - description TEXT NOT NULL, - contactPerson TEXT NOT NULL, - location TEXT NOT NULL, - deviceIds TEXT NOT NULL, - topology TEXT NOT NULL + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT NOT NULL, + contactPerson TEXT NOT NULL, + location TEXT NOT NULL, + deviceIds TEXT NOT NULL, + topology TEXT NOT NULL, + semaphoreSetupTemplateId TEXT NOT NULL DEFAULT '', + semaphoreTeardownTemplateId TEXT NOT NULL DEFAULT '' ); CREATE TABLE IF NOT EXISTS bookings ( - id TEXT PRIMARY KEY, - labId TEXT NOT NULL, - userId TEXT NOT NULL, - startDateTime TEXT NOT NULL, - endDateTime TEXT NOT NULL, - notes TEXT, - status TEXT NOT NULL, - notified INTEGER DEFAULT 0, - emailSent INTEGER DEFAULT 0 + id TEXT PRIMARY KEY, + labId TEXT NOT NULL, + userId TEXT NOT NULL, + startDateTime TEXT NOT NULL, + endDateTime TEXT NOT NULL, + notes TEXT, + status TEXT NOT NULL, + notified INTEGER NOT NULL DEFAULT 0, + emailSent INTEGER NOT NULL DEFAULT 0, + ansibleSetupTriggered INTEGER NOT NULL DEFAULT 0, + ansibleTeardownTriggered INTEGER NOT NULL DEFAULT 0, + ansibleSetupJobId TEXT NOT NULL DEFAULT '', + ansibleTeardownJobId TEXT NOT NULL DEFAULT '' ); CREATE TABLE IF NOT EXISTS logs ( - id TEXT PRIMARY KEY, + id TEXT PRIMARY KEY, timestamp TEXT NOT NULL, - type TEXT NOT NULL, - message TEXT NOT NULL, - deviceId TEXT, - userId TEXT + type TEXT NOT NULL, + message TEXT NOT NULL, + deviceId TEXT, + userId TEXT ); CREATE TABLE IF NOT EXISTS links ( - id TEXT PRIMARY KEY, - title TEXT NOT NULL, - url TEXT NOT NULL, + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + url TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', - category TEXT NOT NULL DEFAULT '', - color TEXT NOT NULL DEFAULT 'emerald', - createdBy TEXT, - createdAt TEXT NOT NULL + category TEXT NOT NULL DEFAULT '', + color TEXT NOT NULL DEFAULT 'emerald', + createdBy TEXT, + createdAt TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS settings ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL, + key TEXT PRIMARY KEY, + value TEXT NOT NULL, updated_at TEXT DEFAULT (datetime('now')) ); + + CREATE TABLE IF NOT EXISTS caddy ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + hostname TEXT NOT NULL, + upstream TEXT NOT NULL, + tls INTEGER NOT NULL DEFAULT 1, + compress INTEGER NOT NULL DEFAULT 1, + created_at TEXT DEFAULT (datetime('now')) + ); `); -// Lightweight migrations for columns added after the initial release. -// CREATE TABLE IF NOT EXISTS never alters an existing table, so add them by hand. -function ensureColumn(table: string, column: string, ddl: string) { - const cols = db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]; - if (!cols.some(c => c.name === column)) { - db.exec(`ALTER TABLE ${table} ADD COLUMN ${ddl}`); - } -} +// Seed default settings — INSERT OR IGNORE writes a key only if it is absent. +const DEFAULT_SETTINGS: Record = { + azure_enabled: 'false', + azure_client_id: '', + azure_tenant_id: '', + azure_client_secret: '', + azure_redirect_uri: '', + azure_allowed_group: '', + checkmk_enabled: 'false', + checkmk_api_url: '', + checkmk_api_user: 'automation', + checkmk_api_secret: '', + checkmk_sync_interval_ms: '60000', + semaphore_enabled: 'false', + semaphore_api_url: '', + semaphore_api_token: '', + semaphore_project_id: '', + caddy_enabled: 'false', + caddy_admin_url: 'http://localhost:2019', +}; -ensureColumn('devices', 'checkMkUrl', "checkMkUrl TEXT NOT NULL DEFAULT ''"); -ensureColumn('devices', 'cmkHostname', "cmkHostname TEXT NOT NULL DEFAULT ''"); -ensureColumn('labs', 'semaphoreSetupTemplateId', "semaphoreSetupTemplateId TEXT NOT NULL DEFAULT ''"); -ensureColumn('labs', 'semaphoreTeardownTemplateId', "semaphoreTeardownTemplateId TEXT NOT NULL DEFAULT ''"); -ensureColumn('bookings', 'ansibleSetupTriggered', 'ansibleSetupTriggered INTEGER NOT NULL DEFAULT 0'); -ensureColumn('bookings', 'ansibleTeardownTriggered', 'ansibleTeardownTriggered INTEGER NOT NULL DEFAULT 0'); -ensureColumn('bookings', 'ansibleSetupJobId', "ansibleSetupJobId TEXT NOT NULL DEFAULT ''"); -ensureColumn('bookings', 'ansibleTeardownJobId', "ansibleTeardownJobId TEXT NOT NULL DEFAULT ''"); - -// Seed default settings (INSERT OR IGNORE = only if key absent) -const _insertDefault = db.prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)'); -const _defaultSettings: [string, string][] = [ - ['azure_enabled', 'false'], - ['azure_client_id', ''], - ['azure_tenant_id', ''], - ['azure_client_secret', ''], - ['azure_redirect_uri', ''], - ['azure_allowed_group', ''], - ['checkmk_enabled', 'false'], - ['checkmk_api_url', ''], - ['checkmk_api_user', 'automation'], - ['checkmk_api_secret', ''], - ['checkmk_sync_interval_ms', '60000'], - ['semaphore_enabled', 'false'], - ['semaphore_api_url', ''], - ['semaphore_api_token', ''], - ['semaphore_project_id', ''], -]; -for (const [k, v] of _defaultSettings) _insertDefault.run(k, v); +const seedSetting = db.prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)'); +for (const [key, value] of Object.entries(DEFAULT_SETTINGS)) seedSetting.run(key, value); export function getSetting(key: string): string { const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key) as { value: string } | undefined; @@ -131,4 +133,28 @@ export function getAllSettings(): Record { return Object.fromEntries(rows.map(r => [r.key, r.value])); } +export interface CaddyRoute { + id: number; + hostname: string; + upstream: string; + tls: number; + compress: number; + created_at: string; +} + +export function getCaddyRoutes(): CaddyRoute[] { + return db.prepare('SELECT * FROM caddy ORDER BY id ASC').all() as CaddyRoute[]; +} + +export function addCaddyRoute(hostname: string, upstream: string, tls: boolean, compress: boolean): CaddyRoute { + const { lastInsertRowid } = db.prepare( + 'INSERT INTO caddy (hostname, upstream, tls, compress) VALUES (?, ?, ?, ?)' + ).run(hostname, upstream, tls ? 1 : 0, compress ? 1 : 0); + return db.prepare('SELECT * FROM caddy WHERE id = ?').get(lastInsertRowid) as CaddyRoute; +} + +export function deleteCaddyRoute(id: number): void { + db.prepare('DELETE FROM caddy WHERE id = ?').run(id); +} + export default db; diff --git a/server.ts b/server.ts index f327a0c..65f0f6e 100644 --- a/server.ts +++ b/server.ts @@ -5,7 +5,7 @@ import { createServer as createViteServer } from 'vite'; import bcrypt from 'bcryptjs'; import jwt from 'jsonwebtoken'; import { ConfidentialClientApplication } from '@azure/msal-node'; -import db, { getSetting, setSetting, getAllSettings } from './server-db'; +import db, { getSetting, setSetting, getAllSettings, getCaddyRoutes, addCaddyRoute, deleteCaddyRoute } from './server-db'; import { Device, LabTemplate, Booking, LogEntry, User, QuickLink } from './src/types'; const uid = (prefix: string) => `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; @@ -71,6 +71,37 @@ function maskSettings(raw: Record): Record { return out; } +function buildCaddyfile(): string { + const customRoutes = getCaddyRoutes(); + + const lines: string[] = ['{\n local_certs\n}', '']; + + for (const route of customRoutes) { + lines.push(`${route.hostname} {`); + if (route.compress) lines.push(' encode zstd gzip'); + if (route.tls) lines.push(' tls internal'); + lines.push(` reverse_proxy ${route.upstream}`); + lines.push('}', ''); + } + + return lines.join('\n'); +} + +async function pushCaddyConfig(): Promise { + if (getSetting('caddy_enabled') !== 'true') return; + const adminUrl = getSetting('caddy_admin_url') || 'http://localhost:2019'; + const body = buildCaddyfile(); + const res = await fetch(`${adminUrl}/load`, { + method: 'POST', + headers: { 'Content-Type': 'text/caddyfile' }, + body, + }); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`Caddy /load returned ${res.status}: ${text}`); + } +} + async function startServer() { const app = express(); const PORT = Number(process.env.PORT) || 3000; @@ -249,13 +280,15 @@ async function startServer() { const allowed = ['azure_enabled', 'azure_client_id', 'azure_tenant_id', 'azure_client_secret', 'azure_redirect_uri', 'azure_allowed_group', 'checkmk_enabled', 'checkmk_api_url', 'checkmk_api_user', 'checkmk_api_secret', 'checkmk_sync_interval_ms', - 'semaphore_enabled', 'semaphore_api_url', 'semaphore_api_token', 'semaphore_project_id']; + 'semaphore_enabled', 'semaphore_api_url', 'semaphore_api_token', 'semaphore_project_id', + 'caddy_enabled', 'caddy_admin_url']; const updates = req.body as Record; for (const key of allowed) { if (key in updates && updates[key] !== '__SET__') { setSetting(key, String(updates[key])); } } + pushCaddyConfig().catch(err => console.warn('[Caddy] Could not push config after settings save:', err.message)); res.json(maskSettings(getAllSettings())); } catch (err: any) { res.status(500).json({ error: err.message }); @@ -722,7 +755,7 @@ async function startServer() { // in Settings take effect on the next cycle without a server restart. // ------------------------------------------------------------- function checkmkHttpHint(status: number): string { - if (status === 401) return 'HTTP 401 Unauthorized - wrong automation user or secret (check Settings → CheckMK)'; + if (status === 401) return 'HTTP 401 Unauthorized - wrong automation user or secret (check Settings > CheckMK)'; if (status === 403) return 'HTTP 403 Forbidden - automation user lacks permission in CheckMK'; if (status === 404) return 'HTTP 404 Not Found - API URL incorrect or site name wrong'; return `HTTP ${status}`; @@ -746,7 +779,7 @@ async function startServer() { const authHeader = `Bearer ${CHECKMK_API_USER} ${CHECKMK_API_SECRET}`; const headers = { Authorization: authHeader, Accept: 'application/json' }; - // Step 1: build IP → hostname map from host configuration + // Step 1: build IP > hostname map from host configuration // Checks both attributes (explicitly set) and effective_attributes (inherited). let ipToHostname: Map; try { @@ -874,7 +907,7 @@ async function startServer() { const data = await res.json() as { id?: number }; const jobId = data?.id ?? null; db.prepare('INSERT INTO logs (id, timestamp, type, message) VALUES (?, ?, ?, ?)') - .run(uid('log'), now, 'booking', `Semaphore: triggered template #${templateId} → job #${jobId} (booking ${extraVars.booking_id}).`); + .run(uid('log'), now, 'booking', `Semaphore: triggered template #${templateId} > job #${jobId} (booking ${extraVars.booking_id}).`); return jobId; } catch (err: any) { const msg = `Semaphore trigger failed for template #${templateId} — ${err?.message ?? err}`; @@ -991,6 +1024,55 @@ async function startServer() { } }); + // ------------------------------------------------------------- + // CADDY API + // ------------------------------------------------------------- + app.get('/api/caddy/status', requireAuth, async (_req, res) => { + try { + const adminUrl = getSetting('caddy_admin_url') || 'http://localhost:2019'; + const r = await fetch(`${adminUrl}/config/`, { signal: AbortSignal.timeout(2000) }); + res.json({ available: r.ok }); + } catch { + res.json({ available: false }); + } + }); + + app.get('/api/caddy/routes', requireAuth, (_req, res) => { + try { + res.json({ system: [], custom: getCaddyRoutes() }); + } catch (err: any) { + res.status(500).json({ error: err.message }); + } + }); + + app.post('/api/caddy/routes', requireAuth, async (req, res) => { + try { + const { hostname, upstream, tls, compress } = req.body as { + hostname?: string; upstream?: string; tls?: boolean; compress?: boolean; + }; + if (!hostname || !upstream) return res.status(400).json({ error: 'hostname and upstream are required.' }); + const route = addCaddyRoute(hostname.trim(), upstream.trim(), tls !== false, compress !== false); + pushCaddyConfig().catch(err => console.warn('[Caddy] Could not push config after route add:', err.message)); + res.json(route); + } catch (err: any) { + res.status(500).json({ error: err.message }); + } + }); + + app.delete('/api/caddy/routes/:id', requireAuth, (req, res) => { + try { + const id = Number(req.params.id); + if (!id) return res.status(400).json({ error: 'Invalid route id.' }); + deleteCaddyRoute(id); + pushCaddyConfig().catch(err => console.warn('[Caddy] Could not push config after route delete:', err.message)); + res.status(204).send(); + } catch (err: any) { + res.status(500).json({ error: err.message }); + } + }); + + pushCaddyConfig().catch(err => console.warn('[Caddy] Could not push config on startup:', err.message)); + app.listen(PORT, '0.0.0.0', () => { console.log(`[Server] Core Server running at http://0.0.0.0:${PORT}`); }); diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index 56f03d5..8e8abf1 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -4,6 +4,7 @@ import { User } from '../types'; import { Shield, Activity, Save, CheckCircle, AlertCircle, Eye, EyeOff, Settings2, KeyRound, Globe, Clock, ChevronRight, Copy, Users, RefreshCw, Terminal, + Network, Trash2, Plus, } from 'lucide-react'; const SECRET_SENTINEL = '__SET__'; @@ -24,6 +25,16 @@ interface RawSettings { semaphore_api_url: string; semaphore_api_token: string; semaphore_project_id: string; + caddy_enabled: string; + caddy_admin_url: string; +} + +interface CaddyRoute { + id: number; + hostname: string; + upstream: string; + tls: number; + compress: number; } interface SettingsProps { @@ -106,7 +117,7 @@ function SecretInput({ value, onChange, alreadySet, show, onToggleShow }: { type={show ? 'text' : 'password'} value={value} onChange={e => onChange(e.target.value)} - placeholder={alreadySet ? '•••••••• (leave blank to keep)' : 'Paste secret value'} + placeholder={alreadySet ? '•••••••• (leave blank to keep)' : 'Secret'} className="w-full bg-slate-900 border border-slate-700 rounded-lg pl-9 pr-10 py-2.5 text-xs font-mono text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500/50 transition-all" /> + + + +
+ +
+ + } /> + + + {/* Route list */} + {caddyEnabled && ( +
+ + + {caddyStatus === 'unavailable' && ( +

+ Caddy Admin API not reachable — routes will be applied when Caddy starts. +

+ )} + + {/* Custom routes */} + {caddyRoutes?.custom.map(r => ( +
+
+ {r.hostname} + > + {r.upstream} + {r.tls ? TLS : null} + {r.compress ? GZ : null} +
+ +
+ ))} + + {/* Add route form */} +
+
+ + +
+
+ + +
+
+ TLS + +
+
+ GZ + +
+ +
+
+ )} +
+ + {/* Save bar */}

Changes are applied after saving and a server restart.