From 49cd0ae4f65b9dd3da34d3b24866e0d8708d0fe9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Br=C3=BCckner?= Date: Wed, 10 Jun 2026 10:22:39 +0200 Subject: [PATCH] feat(caddy): optional root redirect per route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a redirect_path column to the caddy table and an optional 'root redirect' field in the route form. When set, buildCaddyfile emits 'redir / ' so the bare host (e.g. checkmk.domain.local/) redirects to a sub-path (e.g. /monitoring/check_mk/) while every other path still passes through to the backend — the safe pattern for apps like CheckMK that bake their site path into absolute URLs. Defensive ALTER TABLE keeps existing databases working. --- ARCHITECTURE.md | 17 ++++++++++------- server-db.ts | 29 +++++++++++++++++------------ server.ts | 18 ++++++++++++------ src/components/Settings.tsx | 18 +++++++++++++++--- 4 files changed, 54 insertions(+), 28 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 39e3b75..91ec25c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -277,12 +277,13 @@ CREATE TABLE IF NOT EXISTS settings ( ); 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')) + 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, + redirect_path TEXT NOT NULL DEFAULT '', -- optional 'redir / ' for the bare root + created_at TEXT DEFAULT (datetime('now')) ); ``` @@ -463,7 +464,9 @@ Manual: POST /api/semaphore/trigger/{bookingId} body { type: 'setup'|'teardown ``` buildCaddyfile(): { local_certs } # global block - per custom route { [encode] [tls internal] reverse_proxy { … } } + per custom route { [encode] [tls internal] [redir / ] reverse_proxy { … } } + redirect_path set → `redir / ` redirects only the bare root '/' + (other paths pass through; e.g. CheckMK served at //check_mk/) every reverse_proxy block carries standard forwarding headers: header_up X-Forwarded-Proto {scheme} header_up X-Real-IP {remote_host} diff --git a/server-db.ts b/server-db.ts index 9aefbb5..cc560d5 100644 --- a/server-db.ts +++ b/server-db.ts @@ -86,15 +86,19 @@ db.exec(` ); 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')) + 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, + redirect_path TEXT NOT NULL DEFAULT '', + created_at TEXT DEFAULT (datetime('now')) ); `); +// redirect_path was added later; ensure it exists on databases created before it. +try { db.exec("ALTER TABLE caddy ADD COLUMN redirect_path TEXT NOT NULL DEFAULT ''"); } catch { /* column already exists */ } + // Seed default settings — INSERT OR IGNORE writes a key only if it is absent. const DEFAULT_SETTINGS: Record = { azure_enabled: 'false', @@ -139,6 +143,7 @@ export interface CaddyRoute { upstream: string; tls: number; compress: number; + redirect_path: string; created_at: string; } @@ -146,16 +151,16 @@ 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 { +export function addCaddyRoute(hostname: string, upstream: string, tls: boolean, compress: boolean, redirectPath = ''): CaddyRoute { const { lastInsertRowid } = db.prepare( - 'INSERT INTO caddy (hostname, upstream, tls, compress) VALUES (?, ?, ?, ?)' - ).run(hostname, upstream, tls ? 1 : 0, compress ? 1 : 0); + 'INSERT INTO caddy (hostname, upstream, tls, compress, redirect_path) VALUES (?, ?, ?, ?, ?)' + ).run(hostname, upstream, tls ? 1 : 0, compress ? 1 : 0, redirectPath); return db.prepare('SELECT * FROM caddy WHERE id = ?').get(lastInsertRowid) as CaddyRoute; } -export function updateCaddyRoute(id: number, hostname: string, upstream: string, tls: boolean, compress: boolean): CaddyRoute { - db.prepare('UPDATE caddy SET hostname = ?, upstream = ?, tls = ?, compress = ? WHERE id = ?') - .run(hostname, upstream, tls ? 1 : 0, compress ? 1 : 0, id); +export function updateCaddyRoute(id: number, hostname: string, upstream: string, tls: boolean, compress: boolean, redirectPath = ''): CaddyRoute { + db.prepare('UPDATE caddy SET hostname = ?, upstream = ?, tls = ?, compress = ?, redirect_path = ? WHERE id = ?') + .run(hostname, upstream, tls ? 1 : 0, compress ? 1 : 0, redirectPath, id); return db.prepare('SELECT * FROM caddy WHERE id = ?').get(id) as CaddyRoute; } diff --git a/server.ts b/server.ts index 493a440..4ef13db 100644 --- a/server.ts +++ b/server.ts @@ -87,6 +87,12 @@ function buildCaddyfile(): string { lines.push(`${route.hostname} {`); if (route.compress) lines.push(' encode zstd gzip'); if (route.tls) lines.push(' tls internal'); + if (route.redirect_path) { + // Redirect only the bare root ('/') to the given path — other paths pass + // through to the backend unchanged (e.g. CheckMK at //check_mk/). + const target = route.redirect_path.startsWith('/') ? route.redirect_path : `/${route.redirect_path}`; + lines.push(` redir / ${target}`); + } lines.push(` reverse_proxy ${route.upstream} {`); // Standard forwarding headers for every backend. Caddy already sets the // X-Forwarded-* family and the Host header by default; these make them @@ -1200,13 +1206,13 @@ async function startServer() { app.post('/api/caddy/routes', requireAuth, async (req, res) => { try { if (!IS_CADDY_MANAGER) return res.status(403).json({ error: 'Caddy is managed by another instance.' }); - const { hostname, upstream, tls, compress } = req.body as { - hostname?: string; upstream?: string; tls?: boolean; compress?: boolean; + const { hostname, upstream, tls, compress, redirectPath } = req.body as { + hostname?: string; upstream?: string; tls?: boolean; compress?: boolean; redirectPath?: string; }; if (!hostname || !upstream) return res.status(400).json({ error: 'hostname and upstream are required.' }); if (getCaddyRoutes().some(r => r.hostname === hostname.trim())) return res.status(409).json({ error: `Route for ${hostname.trim()} already exists.` }); - const route = addCaddyRoute(hostname.trim(), upstream.trim(), tls !== false, compress !== false); + const route = addCaddyRoute(hostname.trim(), upstream.trim(), tls !== false, compress !== false, (redirectPath ?? '').trim()); db.prepare('INSERT INTO logs (id, timestamp, type, message, deviceId, userId) VALUES (?, ?, ?, ?, ?, ?)') .run(uid('log'), new Date().toISOString(), 'system', `Caddy route added: ${hostname.trim()} → ${upstream.trim()}`, null, req.user!.userId); @@ -1222,11 +1228,11 @@ async function startServer() { if (!IS_CADDY_MANAGER) return res.status(403).json({ error: 'Caddy is managed by another instance.' }); const id = Number(req.params.id); if (!id) return res.status(400).json({ error: 'Invalid route id.' }); - const { hostname, upstream, tls, compress } = req.body as { - hostname?: string; upstream?: string; tls?: boolean; compress?: boolean; + const { hostname, upstream, tls, compress, redirectPath } = req.body as { + hostname?: string; upstream?: string; tls?: boolean; compress?: boolean; redirectPath?: string; }; if (!hostname || !upstream) return res.status(400).json({ error: 'hostname and upstream are required.' }); - const route = updateCaddyRoute(id, hostname.trim(), upstream.trim(), tls !== false, compress !== false); + const route = updateCaddyRoute(id, hostname.trim(), upstream.trim(), tls !== false, compress !== false, (redirectPath ?? '').trim()); db.prepare('INSERT INTO logs (id, timestamp, type, message, deviceId, userId) VALUES (?, ?, ?, ?, ?, ?)') .run(uid('log'), new Date().toISOString(), 'system', `Caddy route updated: ${hostname.trim()} → ${upstream.trim()}`, null, req.user!.userId); diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index 3a34263..13bb65d 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -35,6 +35,7 @@ interface CaddyRoute { upstream: string; tls: number; compress: number; + redirect_path: string; } interface DbInfo { @@ -200,12 +201,14 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) { const [newUpstream, setNewUpstream] = useState(''); const [newTls, setNewTls] = useState(true); const [newCompress, setNewCompress] = useState(true); + const [newRedirect, setNewRedirect] = useState(''); const [editingRouteId, setEditingRouteId] = useState(null); const [editHostname, setEditHostname] = useState(''); const [editUpstream, setEditUpstream] = useState(''); const [editTls, setEditTls] = useState(true); const [editCompress, setEditCompress] = useState(true); + const [editRedirect, setEditRedirect] = useState(''); const [savingRoute, setSavingRoute] = useState(false); const [dbInfo, setDbInfo] = useState(null); @@ -381,7 +384,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) { try { const res = await authFetch('/api/caddy/routes', { method: 'POST', - body: JSON.stringify({ hostname: newHostname.trim(), upstream: newUpstream.trim(), tls: newTls, compress: newCompress }), + body: JSON.stringify({ hostname: newHostname.trim(), upstream: newUpstream.trim(), tls: newTls, compress: newCompress, redirectPath: newRedirect.trim() }), }); if (!res.ok) { const d = await res.json(); @@ -392,6 +395,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) { setNewUpstream(''); setNewTls(true); setNewCompress(true); + setNewRedirect(''); await loadCaddyRoutes(); } catch { setError('Network error adding route.'); @@ -420,6 +424,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) { setEditUpstream(r.upstream); setEditTls(r.tls === 1); setEditCompress(r.compress === 1); + setEditRedirect(r.redirect_path || ''); } function handleEditCancel() { @@ -432,7 +437,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) { try { const res = await authFetch(`/api/caddy/routes/${id}`, { method: 'PUT', - body: JSON.stringify({ hostname: editHostname.trim(), upstream: editUpstream.trim(), tls: editTls, compress: editCompress }), + body: JSON.stringify({ hostname: editHostname.trim(), upstream: editUpstream.trim(), tls: editTls, compress: editCompress, redirectPath: editRedirect.trim() }), }); if (!res.ok) { const d = await res.json(); @@ -965,6 +970,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) { {caddyRoutes.map((r: CaddyRoute) => (
{editingRouteId === r.id ? ( +
@@ -997,6 +1003,8 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
+ +
) : (
@@ -1005,6 +1013,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) { {r.upstream} {r.tls ? TLS : null} {r.compress ? GZ : null} + {r.redirect_path ? ↳ {r.redirect_path} : null}
+ +
)}