feat(caddy): optional root redirect per route

Add a redirect_path column to the caddy table and an optional 'root redirect'
field in the route form. When set, buildCaddyfile emits 'redir / <path>' 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.
This commit is contained in:
Brückner
2026-06-10 10:22:39 +02:00
parent a2d515992c
commit 49cd0ae4f6
4 changed files with 54 additions and 28 deletions

View File

@ -282,6 +282,7 @@ CREATE TABLE IF NOT EXISTS caddy (
upstream TEXT NOT NULL, upstream TEXT NOT NULL,
tls INTEGER NOT NULL DEFAULT 1, tls INTEGER NOT NULL DEFAULT 1,
compress INTEGER NOT NULL DEFAULT 1, compress INTEGER NOT NULL DEFAULT 1,
redirect_path TEXT NOT NULL DEFAULT '', -- optional 'redir / <path>' for the bare root
created_at TEXT DEFAULT (datetime('now')) created_at TEXT DEFAULT (datetime('now'))
); );
``` ```
@ -463,7 +464,9 @@ Manual: POST /api/semaphore/trigger/{bookingId} body { type: 'setup'|'teardown
``` ```
buildCaddyfile(): buildCaddyfile():
{ local_certs } # global block { local_certs } # global block
per custom route { [encode] [tls internal] reverse_proxy <upstream> { … } } per custom route { [encode] [tls internal] [redir / <redirect_path>] reverse_proxy <upstream> { … } }
redirect_path set → `redir / <path>` redirects only the bare root '/'
(other paths pass through; e.g. CheckMK served at /<site>/check_mk/)
every reverse_proxy block carries standard forwarding headers: every reverse_proxy block carries standard forwarding headers:
header_up X-Forwarded-Proto {scheme} header_up X-Forwarded-Proto {scheme}
header_up X-Real-IP {remote_host} header_up X-Real-IP {remote_host}

View File

@ -91,10 +91,14 @@ db.exec(`
upstream TEXT NOT NULL, upstream TEXT NOT NULL,
tls INTEGER NOT NULL DEFAULT 1, tls INTEGER NOT NULL DEFAULT 1,
compress INTEGER NOT NULL DEFAULT 1, compress INTEGER NOT NULL DEFAULT 1,
redirect_path TEXT NOT NULL DEFAULT '',
created_at TEXT DEFAULT (datetime('now')) 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. // Seed default settings — INSERT OR IGNORE writes a key only if it is absent.
const DEFAULT_SETTINGS: Record<string, string> = { const DEFAULT_SETTINGS: Record<string, string> = {
azure_enabled: 'false', azure_enabled: 'false',
@ -139,6 +143,7 @@ export interface CaddyRoute {
upstream: string; upstream: string;
tls: number; tls: number;
compress: number; compress: number;
redirect_path: string;
created_at: 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[]; 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( const { lastInsertRowid } = db.prepare(
'INSERT INTO caddy (hostname, upstream, tls, compress) VALUES (?, ?, ?, ?)' 'INSERT INTO caddy (hostname, upstream, tls, compress, redirect_path) VALUES (?, ?, ?, ?, ?)'
).run(hostname, upstream, tls ? 1 : 0, compress ? 1 : 0); ).run(hostname, upstream, tls ? 1 : 0, compress ? 1 : 0, redirectPath);
return db.prepare('SELECT * FROM caddy WHERE id = ?').get(lastInsertRowid) as CaddyRoute; 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 { export function updateCaddyRoute(id: number, hostname: string, upstream: string, tls: boolean, compress: boolean, redirectPath = ''): CaddyRoute {
db.prepare('UPDATE caddy SET hostname = ?, upstream = ?, tls = ?, compress = ? WHERE id = ?') db.prepare('UPDATE caddy SET hostname = ?, upstream = ?, tls = ?, compress = ?, redirect_path = ? WHERE id = ?')
.run(hostname, upstream, tls ? 1 : 0, compress ? 1 : 0, id); .run(hostname, upstream, tls ? 1 : 0, compress ? 1 : 0, redirectPath, id);
return db.prepare('SELECT * FROM caddy WHERE id = ?').get(id) as CaddyRoute; return db.prepare('SELECT * FROM caddy WHERE id = ?').get(id) as CaddyRoute;
} }

View File

@ -87,6 +87,12 @@ function buildCaddyfile(): string {
lines.push(`${route.hostname} {`); lines.push(`${route.hostname} {`);
if (route.compress) lines.push(' encode zstd gzip'); if (route.compress) lines.push(' encode zstd gzip');
if (route.tls) lines.push(' tls internal'); 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 /<site>/check_mk/).
const target = route.redirect_path.startsWith('/') ? route.redirect_path : `/${route.redirect_path}`;
lines.push(` redir / ${target}`);
}
lines.push(` reverse_proxy ${route.upstream} {`); lines.push(` reverse_proxy ${route.upstream} {`);
// Standard forwarding headers for every backend. Caddy already sets the // Standard forwarding headers for every backend. Caddy already sets the
// X-Forwarded-* family and the Host header by default; these make them // 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) => { app.post('/api/caddy/routes', requireAuth, async (req, res) => {
try { try {
if (!IS_CADDY_MANAGER) return res.status(403).json({ error: 'Caddy is managed by another instance.' }); if (!IS_CADDY_MANAGER) return res.status(403).json({ error: 'Caddy is managed by another instance.' });
const { hostname, upstream, tls, compress } = req.body as { const { hostname, upstream, tls, compress, redirectPath } = req.body as {
hostname?: string; upstream?: string; tls?: boolean; compress?: boolean; 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 (!hostname || !upstream) return res.status(400).json({ error: 'hostname and upstream are required.' });
if (getCaddyRoutes().some(r => r.hostname === hostname.trim())) if (getCaddyRoutes().some(r => r.hostname === hostname.trim()))
return res.status(409).json({ error: `Route for ${hostname.trim()} already exists.` }); 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 (?, ?, ?, ?, ?, ?)') db.prepare('INSERT INTO logs (id, timestamp, type, message, deviceId, userId) VALUES (?, ?, ?, ?, ?, ?)')
.run(uid('log'), new Date().toISOString(), 'system', .run(uid('log'), new Date().toISOString(), 'system',
`Caddy route added: ${hostname.trim()}${upstream.trim()}`, null, req.user!.userId); `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.' }); if (!IS_CADDY_MANAGER) return res.status(403).json({ error: 'Caddy is managed by another instance.' });
const id = Number(req.params.id); const id = Number(req.params.id);
if (!id) return res.status(400).json({ error: 'Invalid route id.' }); if (!id) return res.status(400).json({ error: 'Invalid route id.' });
const { hostname, upstream, tls, compress } = req.body as { const { hostname, upstream, tls, compress, redirectPath } = req.body as {
hostname?: string; upstream?: string; tls?: boolean; compress?: boolean; 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 (!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 (?, ?, ?, ?, ?, ?)') db.prepare('INSERT INTO logs (id, timestamp, type, message, deviceId, userId) VALUES (?, ?, ?, ?, ?, ?)')
.run(uid('log'), new Date().toISOString(), 'system', .run(uid('log'), new Date().toISOString(), 'system',
`Caddy route updated: ${hostname.trim()}${upstream.trim()}`, null, req.user!.userId); `Caddy route updated: ${hostname.trim()}${upstream.trim()}`, null, req.user!.userId);

View File

@ -35,6 +35,7 @@ interface CaddyRoute {
upstream: string; upstream: string;
tls: number; tls: number;
compress: number; compress: number;
redirect_path: string;
} }
interface DbInfo { interface DbInfo {
@ -200,12 +201,14 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
const [newUpstream, setNewUpstream] = useState(''); const [newUpstream, setNewUpstream] = useState('');
const [newTls, setNewTls] = useState(true); const [newTls, setNewTls] = useState(true);
const [newCompress, setNewCompress] = useState(true); const [newCompress, setNewCompress] = useState(true);
const [newRedirect, setNewRedirect] = useState('');
const [editingRouteId, setEditingRouteId] = useState<number | null>(null); const [editingRouteId, setEditingRouteId] = useState<number | null>(null);
const [editHostname, setEditHostname] = useState(''); const [editHostname, setEditHostname] = useState('');
const [editUpstream, setEditUpstream] = useState(''); const [editUpstream, setEditUpstream] = useState('');
const [editTls, setEditTls] = useState(true); const [editTls, setEditTls] = useState(true);
const [editCompress, setEditCompress] = useState(true); const [editCompress, setEditCompress] = useState(true);
const [editRedirect, setEditRedirect] = useState('');
const [savingRoute, setSavingRoute] = useState(false); const [savingRoute, setSavingRoute] = useState(false);
const [dbInfo, setDbInfo] = useState<DbInfo | null>(null); const [dbInfo, setDbInfo] = useState<DbInfo | null>(null);
@ -381,7 +384,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
try { try {
const res = await authFetch('/api/caddy/routes', { const res = await authFetch('/api/caddy/routes', {
method: 'POST', 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) { if (!res.ok) {
const d = await res.json(); const d = await res.json();
@ -392,6 +395,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
setNewUpstream(''); setNewUpstream('');
setNewTls(true); setNewTls(true);
setNewCompress(true); setNewCompress(true);
setNewRedirect('');
await loadCaddyRoutes(); await loadCaddyRoutes();
} catch { } catch {
setError('Network error adding route.'); setError('Network error adding route.');
@ -420,6 +424,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
setEditUpstream(r.upstream); setEditUpstream(r.upstream);
setEditTls(r.tls === 1); setEditTls(r.tls === 1);
setEditCompress(r.compress === 1); setEditCompress(r.compress === 1);
setEditRedirect(r.redirect_path || '');
} }
function handleEditCancel() { function handleEditCancel() {
@ -432,7 +437,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
try { try {
const res = await authFetch(`/api/caddy/routes/${id}`, { const res = await authFetch(`/api/caddy/routes/${id}`, {
method: 'PUT', 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) { if (!res.ok) {
const d = await res.json(); const d = await res.json();
@ -965,6 +970,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
{caddyRoutes.map((r: CaddyRoute) => ( {caddyRoutes.map((r: CaddyRoute) => (
<div key={r.id} className="bg-slate-900/60 border border-slate-700/60 rounded-xl px-4 py-2.5"> <div key={r.id} className="bg-slate-900/60 border border-slate-700/60 rounded-xl px-4 py-2.5">
{editingRouteId === r.id ? ( {editingRouteId === r.id ? (
<div className="space-y-2">
<div className="flex items-end gap-2"> <div className="flex items-end gap-2">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<Label>Hostname</Label> <Label>Hostname</Label>
@ -997,6 +1003,8 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
<X className="w-3.5 h-3.5" /> <X className="w-3.5 h-3.5" />
</button> </button>
</div> </div>
<Input value={editRedirect} onChange={setEditRedirect} placeholder="Root redirect (optional), e.g. /monitoring/check_mk/" monospace />
</div>
) : ( ) : (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-3 min-w-0"> <div className="flex items-center gap-3 min-w-0">
@ -1005,6 +1013,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
<span className="text-[11px] font-mono text-slate-400 truncate">{r.upstream}</span> <span className="text-[11px] font-mono text-slate-400 truncate">{r.upstream}</span>
{r.tls ? <span className="text-[9px] font-semibold text-sky-500 font-mono">TLS</span> : null} {r.tls ? <span className="text-[9px] font-semibold text-sky-500 font-mono">TLS</span> : null}
{r.compress ? <span className="text-[9px] font-semibold text-slate-500 font-mono">GZ</span> : null} {r.compress ? <span className="text-[9px] font-semibold text-slate-500 font-mono">GZ</span> : null}
{r.redirect_path ? <span className="text-[9px] font-mono text-slate-500 truncate">&#8627; {r.redirect_path}</span> : null}
</div> </div>
<div className="flex items-center gap-1 ml-3 shrink-0"> <div className="flex items-center gap-1 ml-3 shrink-0">
<button type="button" onClick={() => handleEditStart(r)} <button type="button" onClick={() => handleEditStart(r)}
@ -1022,7 +1031,8 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
))} ))}
{/* Add route form */} {/* Add route form */}
<div className="flex items-end gap-2 pt-1"> <div className="space-y-2 pt-1">
<div className="flex items-end gap-2">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<Label>Hostname</Label> <Label>Hostname</Label>
<Input value={newHostname} onChange={setNewHostname} placeholder="semaphore" monospace /> <Input value={newHostname} onChange={setNewHostname} placeholder="semaphore" monospace />
@ -1061,6 +1071,8 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
{addingRoute ? 'Adding…' : 'Add'} {addingRoute ? 'Adding…' : 'Add'}
</button> </button>
</div> </div>
<Input value={newRedirect} onChange={setNewRedirect} placeholder="Root redirect (optional), e.g. /monitoring/check_mk/" monospace />
</div>
</div> </div>
)} )}
</div> </div>