refactor(caddy): remove redundant GhostGrid domain fields, keep only custom routes

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.
This commit is contained in:
Brückner
2026-06-08 08:45:24 +02:00
parent 7afb4829bc
commit f1200425af
4 changed files with 1214 additions and 94 deletions

View File

@ -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"
/>
<button
@ -171,6 +182,16 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
const [semaphoreTesting, setSemaphoreTesting] = useState(false);
const [semaphoreTestResult, setSemaphoreTestResult] = useState<string | null>(null);
const [caddyEnabled, setCaddyEnabled] = useState(false);
const [caddyAdminUrl, setCaddyAdminUrl] = useState('http://localhost:2019');
const [caddyStatus, setCaddyStatus] = useState<'unknown' | 'available' | 'unavailable'>('unknown');
const [caddyRoutes, setCaddyRoutes] = useState<{ system: { hostname: string; upstream: string }[]; custom: CaddyRoute[] } | null>(null);
const [addingRoute, setAddingRoute] = useState(false);
const [newHostname, setNewHostname] = useState('');
const [newUpstream, setNewUpstream] = useState('');
const [newTls, setNewTls] = useState(true);
const [newCompress, setNewCompress] = useState(true);
useEffect(() => {
loadSettings();
fetch('/api/auth/config')
@ -209,6 +230,9 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
setSemaphoreTokenSet(data.semaphore_api_token === SECRET_SENTINEL);
setSemaphoreApiToken('');
setSemaphoreProjectId(data.semaphore_project_id || '');
setCaddyEnabled(data.caddy_enabled === 'true');
setCaddyAdminUrl(data.caddy_admin_url || 'http://localhost:2019');
if (data.caddy_enabled === 'true') loadCaddyRoutes();
} catch {
setError('Network error loading settings.');
} finally {
@ -233,6 +257,8 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
semaphore_enabled: semaphoreEnabled ? 'true' : 'false',
semaphore_api_url: semaphoreApiUrl,
semaphore_project_id: semaphoreProjectId,
caddy_enabled: caddyEnabled ? 'true' : 'false',
caddy_admin_url: caddyAdminUrl,
};
if (azureClientSecret) payload.azure_client_secret = azureClientSecret;
if (checkmkApiSecret) payload.checkmk_api_secret = checkmkApiSecret;
@ -251,6 +277,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
setAzureClientSecret('');
setCheckmkApiSecret('');
setSemaphoreApiToken('');
loadCaddyRoutes();
setSuccessMsg('Settings saved successfully.');
setTimeout(() => setSuccessMsg(''), 4000);
} catch {
@ -306,6 +333,63 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
}).catch(() => {});
}
async function loadCaddyRoutes() {
try {
const [statusRes, routesRes] = await Promise.all([
authFetch('/api/caddy/status'),
authFetch('/api/caddy/routes'),
]);
if (statusRes.ok) {
const s = await statusRes.json();
setCaddyStatus(s.available ? 'available' : 'unavailable');
}
if (routesRes.ok) {
setCaddyRoutes(await routesRes.json());
}
} catch {
setCaddyStatus('unavailable');
}
}
async function handleAddRoute() {
if (!newHostname.trim() || !newUpstream.trim()) return;
setAddingRoute(true);
try {
const res = await authFetch('/api/caddy/routes', {
method: 'POST',
body: JSON.stringify({ hostname: newHostname.trim(), upstream: newUpstream.trim(), tls: newTls, compress: newCompress }),
});
if (!res.ok) {
const d = await res.json();
setError(d.error || 'Failed to add route.');
return;
}
setNewHostname('');
setNewUpstream('');
setNewTls(true);
setNewCompress(true);
await loadCaddyRoutes();
} catch {
setError('Network error adding route.');
} finally {
setAddingRoute(false);
}
}
async function handleDeleteRoute(id: number) {
try {
const res = await authFetch(`/api/caddy/routes/${id}`, { method: 'DELETE' });
if (!res.ok && res.status !== 204) {
const d = await res.json().catch(() => ({}));
setError((d as any).error || 'Failed to delete route.');
return;
}
await loadCaddyRoutes();
} catch {
setError('Network error deleting route.');
}
}
if (loading) {
return (
<div className="flex items-center justify-center h-64">
@ -384,10 +468,10 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
<div className={`grid grid-cols-1 sm:grid-cols-2 gap-5 transition-opacity duration-200 ${!azureEnabled ? 'opacity-40 pointer-events-none' : ''}`}>
<FieldRow label="Tenant ID" hint="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx">
<Input value={azureTenantId} onChange={setAzureTenantId} placeholder="Paste Tenant ID" monospace />
<Input value={azureTenantId} onChange={setAzureTenantId} placeholder="Tenant ID" monospace />
</FieldRow>
<FieldRow label="Client ID (Application ID)" hint="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx">
<Input value={azureClientId} onChange={setAzureClientId} placeholder="Paste Client ID" monospace />
<Input value={azureClientId} onChange={setAzureClientId} placeholder="Client ID" monospace />
</FieldRow>
<FieldRow
label="Client Secret"
@ -404,7 +488,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
<div className="sm:col-span-2">
<FieldRow
label="Redirect URI"
hint="Must match exactly what is registered in Azure Portal App registrations Authentication. Leave blank to auto-derive from APP_URL."
hint="Must match exactly what is registered in Azure Portal > App registrations > Authentication. Leave blank to auto-derive from APP_URL."
>
<Input
value={azureRedirectUri}
@ -434,7 +518,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
<div className="flex-1 min-w-0">
<p className="text-[10px] font-semibold text-slate-400 uppercase tracking-wide mb-1">Required Redirect URI</p>
<p className="text-[11px] font-mono text-slate-200 break-all">{effectiveRedirectUri}</p>
<p className="text-[10px] text-slate-500 mt-1">Register this in Azure Portal App registrations Authentication Redirect URIs</p>
<p className="text-[10px] text-slate-500 mt-1">Register this in Azure Portal &gt; App registrations &gt; Authentication &gt; Redirect URIs</p>
</div>
<button
type="button"
@ -489,13 +573,13 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
<Input
value={checkmkApiUrl}
onChange={setCheckmkApiUrl}
placeholder="https://checkmk.internal/<site>/check_mk/api/1.0"
placeholder="https://checkmk/<site>/check_mk/api/1.0"
monospace
icon={<Globe className="w-3.5 h-3.5" />}
/>
</FieldRow>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
<FieldRow label="Automation User" hint="Setup Users Automation user (e.g. automation)">
<FieldRow label="Automation User" hint="Setup > Users > Automation user (e.g. automation)">
<Input
value={checkmkApiUser}
onChange={setCheckmkApiUser}
@ -506,7 +590,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
</FieldRow>
<FieldRow
label="Automation Secret"
hint="Setup Users Automation user Automation secret"
hint="Setup > Users > Automation user > Automation secret"
badge={checkmkSecretSet ? <ConfiguredBadge /> : undefined}
>
<SecretInput
@ -582,7 +666,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
<Input
value={semaphoreApiUrl}
onChange={setSemaphoreApiUrl}
placeholder="https://semaphore.internal"
placeholder="https://semaphore/api/v1alpha"
monospace
icon={<Globe className="w-3.5 h-3.5" />}
/>
@ -590,7 +674,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
<FieldRow
label="API Token"
hint="Profile API Tokens in Semaphore"
hint="Profile > API Tokens in Semaphore"
badge={semaphoreTokenSet ? <ConfiguredBadge /> : undefined}
>
<SecretInput
@ -634,6 +718,124 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
</div>{/* end grid */}
{/* ── Caddy Reverse Proxy ── */}
<SectionCard accentColor="bg-gradient-to-r from-sky-600 to-cyan-600">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 bg-sky-950/60 border border-sky-900/40 rounded-xl">
<Network className="w-4 h-4 text-sky-400" />
</div>
<div>
<div className="flex items-center gap-2">
<h2 className="text-sm font-semibold text-white">Caddy Reverse Proxy</h2>
{caddyEnabled && caddyStatus === 'available' && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-semibold bg-sky-950/60 border border-sky-900/50 text-sky-400">
<span className="w-1 h-1 rounded-full bg-sky-400 animate-pulse inline-block" />
ACTIVE
</span>
)}
</div>
<p className="text-[11px] text-slate-500 mt-0.5">Manage reverse proxy routes for internal services</p>
</div>
</div>
<div className="flex items-center gap-2.5">
<span className={`text-[10px] font-semibold font-mono ${caddyEnabled ? 'text-sky-400' : 'text-slate-600'}`}>
{caddyEnabled ? 'ENABLED' : 'DISABLED'}
</span>
<button
type="button"
onClick={() => { setCaddyEnabled(v => !v); if (!caddyEnabled) loadCaddyRoutes(); }}
className={`relative w-10 h-5 rounded-full transition-all duration-200 focus:outline-none ${caddyEnabled ? 'bg-sky-600 shadow-[0_0_10px_rgba(2,132,199,0.4)]' : 'bg-slate-800 border border-slate-700'}`}
>
<span className={`absolute top-0.5 w-4 h-4 bg-white rounded-full shadow transition-all duration-200 ${caddyEnabled ? 'left-5' : 'left-0.5'}`} />
</button>
</div>
</div>
<div className="h-px bg-slate-800/60" />
<div className={`space-y-5 transition-opacity duration-200 ${!caddyEnabled ? 'opacity-40 pointer-events-none' : ''}`}>
<FieldRow label="Caddy Admin URL" hint="Default: http://localhost:2019">
<Input value={caddyAdminUrl} onChange={setCaddyAdminUrl} placeholder="http://localhost:2019" monospace icon={<Globe className="w-3.5 h-3.5" />} />
</FieldRow>
{/* Route list */}
{caddyEnabled && (
<div className="space-y-2">
<Label>Proxy Routes</Label>
{caddyStatus === 'unavailable' && (
<p className="text-[11px] font-mono text-amber-400 mb-2">
Caddy Admin API not reachable routes will be applied when Caddy starts.
</p>
)}
{/* Custom routes */}
{caddyRoutes?.custom.map(r => (
<div key={r.id} className="flex items-center justify-between bg-slate-900/60 border border-slate-700/60 rounded-xl px-4 py-2.5">
<div className="flex items-center gap-3 min-w-0">
<span className="text-[11px] font-mono text-white truncate">{r.hostname}</span>
<span className="text-slate-600 text-[11px]">&gt;</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.compress ? <span className="text-[9px] font-semibold text-slate-500 font-mono">GZ</span> : null}
</div>
<button
type="button"
onClick={() => handleDeleteRoute(r.id)}
className="ml-3 shrink-0 p-1.5 rounded-lg text-slate-600 hover:text-red-400 hover:bg-red-950/30 transition-all"
title="Remove route"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
))}
{/* Add route form */}
<div className="flex items-end gap-2 pt-1">
<div className="flex-1 min-w-0">
<Label>Hostname</Label>
<Input value={newHostname} onChange={setNewHostname} placeholder="semaphore" monospace />
</div>
<div className="flex-1 min-w-0">
<Label>Upstream</Label>
<Input value={newUpstream} onChange={setNewUpstream} placeholder="127.0.0.1:3000" monospace />
</div>
<div className="flex flex-col items-center gap-1 pb-0.5">
<span className="text-[9px] font-semibold text-slate-400 uppercase tracking-wide">TLS</span>
<button
type="button"
onClick={() => setNewTls(v => !v)}
className={`relative w-8 h-4 rounded-full transition-all duration-200 focus:outline-none ${newTls ? 'bg-sky-600' : 'bg-slate-800 border border-slate-700'}`}
>
<span className={`absolute top-0.5 w-3 h-3 bg-white rounded-full shadow transition-all duration-200 ${newTls ? 'left-4' : 'left-0.5'}`} />
</button>
</div>
<div className="flex flex-col items-center gap-1 pb-0.5">
<span className="text-[9px] font-semibold text-slate-400 uppercase tracking-wide">GZ</span>
<button
type="button"
onClick={() => setNewCompress(v => !v)}
className={`relative w-8 h-4 rounded-full transition-all duration-200 focus:outline-none ${newCompress ? 'bg-sky-600' : 'bg-slate-800 border border-slate-700'}`}
>
<span className={`absolute top-0.5 w-3 h-3 bg-white rounded-full shadow transition-all duration-200 ${newCompress ? 'left-4' : 'left-0.5'}`} />
</button>
</div>
<button
type="button"
onClick={handleAddRoute}
disabled={addingRoute || !newHostname.trim() || !newUpstream.trim()}
className="flex items-center gap-1.5 bg-sky-950/60 hover:bg-sky-900/40 border border-sky-900/50 text-sky-400 text-xs font-semibold px-3 py-2 rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed shrink-0"
>
<Plus className="w-3.5 h-3.5" />
{addingRoute ? 'Adding…' : 'Add'}
</button>
</div>
</div>
)}
</div>
</SectionCard>
{/* Save bar */}
<div className="flex items-center justify-between pt-2 border-t border-slate-800/60">
<p className="text-[11px] text-slate-600">Changes are applied after saving and a server restart.</p>