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:
@ -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}
|
||||||
|
|||||||
17
server-db.ts
17
server-db.ts
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
18
server.ts
18
server.ts
@ -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);
|
||||||
|
|||||||
@ -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">↳ {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>
|
||||||
|
|||||||
Reference in New Issue
Block a user