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:
@ -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 > App registrations > Authentication > 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]">></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>
|
||||
|
||||
Reference in New Issue
Block a user