refactor: replace CADDY_MANAGER with DEPLOY_ENV for instance-role awareness

DEPLOY_ENV=production now marks the primary instance globally - used for
Caddy ownership, the Dev/Prod header badge, and Caddy UI gating. Removes
build-time VITE_DEPLOY_ENV/import.meta.env.DEV from the header in favour
of the runtime API response (isProduction field in /api/auth/config).
This commit is contained in:
Brückner
2026-06-10 14:43:31 +02:00
parent 49cd0ae4f6
commit 515052fbda
5 changed files with 34 additions and 30 deletions

View File

@ -190,8 +190,8 @@ msg_info "Creating .env file for each instance"
for d in "${APP_DIR}" "${DEV_DIR}"; do for d in "${APP_DIR}" "${DEV_DIR}"; do
SECRET="$(openssl rand -hex 32)" SECRET="$(openssl rand -hex 32)"
run "printf 'JWT_SECRET=\"%s\"\n' '${SECRET}' > ${d}/.env && chown ghostgrid:ghostgrid ${d}/.env && chmod 600 ${d}/.env" run "printf 'JWT_SECRET=\"%s\"\n' '${SECRET}' > ${d}/.env && chown ghostgrid:ghostgrid ${d}/.env && chmod 600 ${d}/.env"
# Only the production instance owns the shared Caddy (one Caddy per container). # Only the production instance owns Caddy and shows "Production" in the UI.
[[ "$d" == "${APP_DIR}" ]] && run "printf 'CADDY_MANAGER=true\n' >> ${d}/.env" [[ "$d" == "${APP_DIR}" ]] && run "printf 'DEPLOY_ENV=production\n' >> ${d}/.env"
done done
msg_ok ".env files created (main + dev)" msg_ok ".env files created (main + dev)"

View File

@ -15,10 +15,10 @@ const uid = (prefix: string) => `${prefix}-${Date.now()}-${Math.random().toStrin
const JWT_SECRET = process.env.JWT_SECRET || 'ghostgrid-dev-secret-change-in-production'; const JWT_SECRET = process.env.JWT_SECRET || 'ghostgrid-dev-secret-change-in-production';
const JWT_EXPIRY = '24h'; const JWT_EXPIRY = '24h';
// One Caddy serves the whole container; only the instance with CADDY_MANAGER=true // DEPLOY_ENV=production marks the primary instance: it owns Caddy (pushes config,
// owns it (pushes config, seeds routes, accepts route edits). The other instance // seeds routes, accepts route edits) and shows "Production" in the UI. The dev
// must never push — POST /load replaces the entire config and would clobber it. // instance must never push to Caddy — POST /load replaces the entire config.
const IS_CADDY_MANAGER = process.env.CADDY_MANAGER === 'true'; const IS_PRODUCTION = process.env.DEPLOY_ENV === 'production';
interface JwtPayload { interface JwtPayload {
userId: string; userId: string;
@ -153,7 +153,7 @@ function importCaddyfileRoutes(userId?: string): void {
} }
async function pushCaddyConfig(): Promise<void> { async function pushCaddyConfig(): Promise<void> {
if (!IS_CADDY_MANAGER) return; if (!IS_PRODUCTION) return;
if (getSetting('caddy_enabled') !== 'true') return; if (getSetting('caddy_enabled') !== 'true') return;
const adminUrl = getSetting('caddy_admin_url') || 'http://localhost:2019'; const adminUrl = getSetting('caddy_admin_url') || 'http://localhost:2019';
const body = buildCaddyfile(); const body = buildCaddyfile();
@ -180,7 +180,7 @@ async function startServer() {
console.log('[Init] Default admin user created — login: admin@ghostgrid.local / admin'); console.log('[Init] Default admin user created — login: admin@ghostgrid.local / admin');
} }
if (IS_CADDY_MANAGER && getSetting('caddy_enabled') === 'true' && getCaddyRoutes().length === 0) { if (IS_PRODUCTION && getSetting('caddy_enabled') === 'true' && getCaddyRoutes().length === 0) {
importCaddyfileRoutes(); importCaddyfileRoutes();
} }
@ -277,7 +277,7 @@ async function startServer() {
effectiveRedirectUri, effectiveRedirectUri,
checkmkEnabled: getSetting('checkmk_enabled') === 'true', checkmkEnabled: getSetting('checkmk_enabled') === 'true',
checkmkBaseUrl: cmkApiUrl.replace(/\/api\/.*$/, ''), checkmkBaseUrl: cmkApiUrl.replace(/\/api\/.*$/, ''),
caddyManaged: IS_CADDY_MANAGER, isProduction: IS_PRODUCTION,
}); });
}); });
@ -1205,7 +1205,7 @@ 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_PRODUCTION) return res.status(403).json({ error: 'Caddy is managed by another instance.' });
const { hostname, upstream, tls, compress, redirectPath } = req.body as { const { hostname, upstream, tls, compress, redirectPath } = req.body as {
hostname?: string; upstream?: string; tls?: boolean; compress?: boolean; redirectPath?: string; hostname?: string; upstream?: string; tls?: boolean; compress?: boolean; redirectPath?: string;
}; };
@ -1225,7 +1225,7 @@ async function startServer() {
app.put('/api/caddy/routes/:id', requireAuth, async (req, res) => { app.put('/api/caddy/routes/:id', requireAuth, async (req, res) => {
try { try {
if (!IS_CADDY_MANAGER) return res.status(403).json({ error: 'Caddy is managed by another instance.' }); if (!IS_PRODUCTION) 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, redirectPath } = req.body as { const { hostname, upstream, tls, compress, redirectPath } = req.body as {
@ -1245,7 +1245,7 @@ async function startServer() {
app.delete('/api/caddy/routes/:id', requireAuth, (req, res) => { app.delete('/api/caddy/routes/:id', requireAuth, (req, res) => {
try { try {
if (!IS_CADDY_MANAGER) return res.status(403).json({ error: 'Caddy is managed by another instance.' }); if (!IS_PRODUCTION) 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 existing = getCaddyRouteById(id); const existing = getCaddyRouteById(id);

View File

@ -55,6 +55,7 @@ export default function App() {
const [inventoryHighlightDevice, setInventoryHighlightDevice] = useState<Device | null>(null); const [inventoryHighlightDevice, setInventoryHighlightDevice] = useState<Device | null>(null);
const [checkmkEnabled, setCheckmkEnabled] = useState(false); const [checkmkEnabled, setCheckmkEnabled] = useState(false);
const [checkmkBaseUrl, setCheckmkBaseUrl] = useState(''); const [checkmkBaseUrl, setCheckmkBaseUrl] = useState('');
const [isProduction, setIsProduction] = useState(false);
useEffect(() => { useEffect(() => {
const root = document.documentElement; const root = document.documentElement;
@ -143,7 +144,7 @@ export default function App() {
if (bookingsRes.ok) setBookings(await bookingsRes.json()); if (bookingsRes.ok) setBookings(await bookingsRes.json());
if (logsRes.ok) setLogs(await logsRes.json()); if (logsRes.ok) setLogs(await logsRes.json());
if (linksRes.ok) setLinks(await linksRes.json()); if (linksRes.ok) setLinks(await linksRes.json());
if (configRes.ok) { const cfg = await configRes.json(); setCheckmkEnabled(!!cfg.checkmkEnabled); setCheckmkBaseUrl(cfg.checkmkBaseUrl || ''); } if (configRes.ok) { const cfg = await configRes.json(); setCheckmkEnabled(!!cfg.checkmkEnabled); setCheckmkBaseUrl(cfg.checkmkBaseUrl || ''); setIsProduction(!!cfg.isProduction); }
} catch (err) { } catch (err) {
console.error('[App] Failed to load data:', err); console.error('[App] Failed to load data:', err);
} finally { } finally {
@ -484,6 +485,7 @@ export default function App() {
theme={theme} theme={theme}
onThemeToggle={() => setTheme(t => t === 'dark' ? 'light' : 'dark')} onThemeToggle={() => setTheme(t => t === 'dark' ? 'light' : 'dark')}
onLogout={handleLogout} onLogout={handleLogout}
isProduction={isProduction}
/> />
<div className="flex-1 flex flex-col md:flex-row"> <div className="flex-1 flex flex-col md:flex-row">

View File

@ -11,6 +11,7 @@ interface HeaderProps {
theme: 'dark' | 'light'; theme: 'dark' | 'light';
onThemeToggle: () => void; onThemeToggle: () => void;
onLogout: () => void; onLogout: () => void;
isProduction: boolean;
} }
export default function Header({ export default function Header({
@ -22,6 +23,7 @@ export default function Header({
theme, theme,
onThemeToggle, onThemeToggle,
onLogout, onLogout,
isProduction,
}: HeaderProps) { }: HeaderProps) {
const [showMailInbox, setShowMailInbox] = useState(false); const [showMailInbox, setShowMailInbox] = useState(false);
const [showBellDropdown, setShowBellDropdown] = useState(false); const [showBellDropdown, setShowBellDropdown] = useState(false);
@ -63,8 +65,8 @@ export default function Header({
{/* System Indicator */} {/* System Indicator */}
<div className="hidden md:flex items-center gap-2 px-3 py-1 bg-slate-800/60 rounded-full border border-slate-700/50 text-xs font-mono text-slate-300"> <div className="hidden md:flex items-center gap-2 px-3 py-1 bg-slate-800/60 rounded-full border border-slate-700/50 text-xs font-mono text-slate-300">
<span className={`w-2 h-2 rounded-full animate-pulse ${(import.meta.env.VITE_DEPLOY_ENV === 'dev' || import.meta.env.DEV) ? 'bg-amber-400' : 'bg-emerald-500'}`} /> <span className={`w-2 h-2 rounded-full animate-pulse ${isProduction ? 'bg-emerald-500' : 'bg-amber-400'}`} />
<span>System: {(import.meta.env.VITE_DEPLOY_ENV === 'dev' || import.meta.env.DEV) ? 'Development' : 'Production'}</span> <span>System: {isProduction ? 'Production' : 'Development'}</span>
</div> </div>
{/* Mail Inbox */} {/* Mail Inbox */}

View File

@ -193,7 +193,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
const [caddyEnabled, setCaddyEnabled] = useState(false); const [caddyEnabled, setCaddyEnabled] = useState(false);
const [caddyManaged, setCaddyManaged] = useState(true); const [caddyManaged, setCaddyManaged] = useState(true);
const [caddyAdminUrl, setCaddyAdminUrl] = useState('http://localhost:2019'); const [caddyAdminUrl, setCaddyAdminUrl] = useState('http://127.0.0.1:2019');
const [caddyStatus, setCaddyStatus] = useState<'unknown' | 'available' | 'unavailable'>('unknown'); const [caddyStatus, setCaddyStatus] = useState<'unknown' | 'available' | 'unavailable'>('unknown');
const [caddyRoutes, setCaddyRoutes] = useState<CaddyRoute[]>([]); const [caddyRoutes, setCaddyRoutes] = useState<CaddyRoute[]>([]);
const [addingRoute, setAddingRoute] = useState(false); const [addingRoute, setAddingRoute] = useState(false);
@ -225,7 +225,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
.then(r => r.json()) .then(r => r.json())
.then(d => { .then(d => {
if (d.effectiveRedirectUri) setEffectiveRedirectUri(d.effectiveRedirectUri); if (d.effectiveRedirectUri) setEffectiveRedirectUri(d.effectiveRedirectUri);
setCaddyManaged(d.caddyManaged !== false); setCaddyManaged(d.isProduction !== false);
}) })
.catch(() => {}); .catch(() => {});
}, []); }, []);
@ -265,7 +265,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
setSemaphoreApiToken(''); setSemaphoreApiToken('');
setSemaphoreProjectId(data.semaphore_project_id || ''); setSemaphoreProjectId(data.semaphore_project_id || '');
setCaddyEnabled(data.caddy_enabled === 'true'); setCaddyEnabled(data.caddy_enabled === 'true');
setCaddyAdminUrl(data.caddy_admin_url || 'http://localhost:2019'); setCaddyAdminUrl(data.caddy_admin_url || 'http://127.0.0.1:2019');
} catch { } catch {
setError('Network error loading settings.'); setError('Network error loading settings.');
} finally { } finally {
@ -350,7 +350,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
return; return;
} }
const templates = await res.json() as any[]; const templates = await res.json() as any[];
setSemaphoreTestResult(`Connected ${templates.length} task template${templates.length !== 1 ? 's' : ''} found.`); setSemaphoreTestResult(`Connected - ${templates.length} task template${templates.length !== 1 ? 's' : ''} found.`);
} catch { } catch {
setSemaphoreTestResult('Error: Network error connecting to Semaphore.'); setSemaphoreTestResult('Error: Network error connecting to Semaphore.');
} finally { } finally {
@ -371,7 +371,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
const res = await authFetch('/api/caddy/routes'); const res = await authFetch('/api/caddy/routes');
if (res.ok) setCaddyRoutes(await res.json()); if (res.ok) setCaddyRoutes(await res.json());
} catch {} } catch {}
// Status check runs separately purely informational, never blocks the list // Status check runs separately - purely informational, never blocks the list
authFetch('/api/caddy/status') authFetch('/api/caddy/status')
.then(res => res.ok ? res.json() : null) .then(res => res.ok ? res.json() : null)
.then(s => setCaddyStatus(s?.available ? 'available' : 'unavailable')) .then(s => setCaddyStatus(s?.available ? 'available' : 'unavailable'))
@ -570,7 +570,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
</div> </div>
)} )}
{/* Section tabs switch between Integrations and System to keep the page light */} {/* Section tabs - switch between Integrations and System to keep the page light */}
<div className="inline-flex items-center gap-0.5 p-0.5 bg-slate-900/50 border border-slate-800 rounded-lg w-fit"> <div className="inline-flex items-center gap-0.5 p-0.5 bg-slate-900/50 border border-slate-800 rounded-lg w-fit">
{([ {([
{ id: 'integrations', label: 'Integrations', icon: <Plug className="w-3.5 h-3.5" /> }, { id: 'integrations', label: 'Integrations', icon: <Plug className="w-3.5 h-3.5" /> },
@ -748,7 +748,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
/> />
</FieldRow> </FieldRow>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5"> <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">
<Input <Input
value={checkmkApiUser} value={checkmkApiUser}
onChange={setCheckmkApiUser} onChange={setCheckmkApiUser}
@ -944,19 +944,19 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
{caddyManaged && ( {caddyManaged && (
<div className={`space-y-5 transition-opacity duration-200 ${!caddyEnabled ? 'opacity-40 pointer-events-none' : ''}`}> <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"> <FieldRow label="Caddy Admin URL" hint="Default: http://127.0.0.1:2019">
<Input value={caddyAdminUrl} onChange={setCaddyAdminUrl} placeholder="http://localhost:2019" monospace icon={<Globe className="w-3.5 h-3.5" />} /> <Input value={caddyAdminUrl} onChange={setCaddyAdminUrl} placeholder="http://172.0.0.1:2019" monospace icon={<Globe className="w-3.5 h-3.5" />} />
</FieldRow> </FieldRow>
{/* Route list */} {/* Route list */}
{caddyEnabled && ( {caddyEnabled && (
<div className="space-y-2"> <div className="space-y-2">
<Label>Proxy Routes</Label> <Label>Proxy Routes</Label>
<Hint>Prefix the upstream with https:// for TLS backends (e.g. Semaphore) — the certificate is not verified.</Hint> <Hint>Prefix the upstream with https:// for TLS backends - the certificate is not verified.</Hint>
{caddyStatus === 'unavailable' && ( {caddyStatus === 'unavailable' && (
<p className="text-[11px] font-mono text-amber-400 mb-2"> <p className="text-[11px] font-mono text-amber-400 mb-2">
Caddy Admin API not reachable routes will be applied when Caddy starts. Caddy Admin API not reachable - routes will be applied when Caddy starts.
</p> </p>
)} )}
@ -1003,7 +1003,7 @@ 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 /> <Input value={editRedirect} onChange={setEditRedirect} placeholder="root redirect" monospace />
</div> </div>
) : ( ) : (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@ -1071,7 +1071,7 @@ 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 /> <Input value={newRedirect} onChange={setNewRedirect} placeholder="Root redirect (optional), e.g. /site1/" monospace />
</div> </div>
</div> </div>
)} )}
@ -1095,7 +1095,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
</div> </div>
<div className="text-right"> <div className="text-right">
<p className="text-xl font-bold text-white font-mono leading-none"> <p className="text-xl font-bold text-white font-mono leading-none">
{dbInfo ? formatBytes(dbInfo.sizeBytes) : ''} {dbInfo ? formatBytes(dbInfo.sizeBytes) : '-'}
</p> </p>
<p className="text-[10px] text-slate-500 font-mono mt-1"> <p className="text-[10px] text-slate-500 font-mono mt-1">
{dbInfo ? new Date(dbInfo.lastModified).toLocaleDateString() : 'Loading…'} {dbInfo ? new Date(dbInfo.lastModified).toLocaleDateString() : 'Loading…'}
@ -1172,7 +1172,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
<div className="flex items-start gap-2 bg-amber-950/40 border border-amber-900/50 rounded-xl px-3 py-2"> <div className="flex items-start gap-2 bg-amber-950/40 border border-amber-900/50 rounded-xl px-3 py-2">
<AlertTriangle className="w-3.5 h-3.5 text-amber-400 shrink-0 mt-0.5" /> <AlertTriangle className="w-3.5 h-3.5 text-amber-400 shrink-0 mt-0.5" />
<p className="text-[11px] text-amber-300 leading-relaxed"> <p className="text-[11px] text-amber-300 leading-relaxed">
<strong>Import overwrites the entire database</strong> this cannot be undone. <strong>Import overwrites the entire database</strong> - this cannot be undone.
</p> </p>
</div> </div>
<label className="w-full flex items-center gap-2 cursor-pointer bg-slate-900 border border-slate-700 hover:border-slate-600 rounded-lg px-3 py-2 text-xs text-slate-400 hover:text-slate-200 transition-all"> <label className="w-full flex items-center gap-2 cursor-pointer bg-slate-900 border border-slate-700 hover:border-slate-600 rounded-lg px-3 py-2 text-xs text-slate-400 hover:text-slate-200 transition-all">