feat(caddy): single owner via CADDY_MANAGER env flag

One Caddy serves the whole container and POST /load replaces the entire
config, so two instances pushing would clobber each other. Now only the
instance with CADDY_MANAGER=true (production) pushes, seeds routes from the
Caddyfile, and accepts route mutations (others get 403). /api/auth/config
exposes caddyManaged so the non-owner Settings UI shows the Caddy section
read-only. The installer sets the flag on the production .env only.
This commit is contained in:
Brückner
2026-06-09 12:47:20 +02:00
parent bc677ff805
commit e0332b05ad
4 changed files with 53 additions and 14 deletions

View File

@ -478,7 +478,15 @@ importCaddyfileRoutes(): reads /etc/caddy/Caddyfile on first Caddy enable
pushCaddyConfig(): POST <caddy_admin_url>/load (Content-Type: text/caddyfile)
called on startup, after settings save, after route add/delete
(failures logged as warnings, non-fatal; skipped if caddy_enabled !== 'true')
(failures logged as warnings, non-fatal; skipped if caddy_enabled !== 'true'
or if this instance is not the Caddy manager)
Ownership — one Caddy serves the whole container (admin API on :2019); POST /load
replaces the ENTIRE config. Only the instance with env CADDY_MANAGER=true (production)
pushes, seeds routes, and accepts route edits (POST/PUT/DELETE → 403 otherwise). The
other instance shows the Caddy section read-only (/api/auth/config → caddyManaged:false)
and never pushes — otherwise its own (partial) config would clobber the owner's. The
owner's caddy table therefore holds ALL routes (both GhostGrid domains + every service).
```
---
@ -660,6 +668,7 @@ Backup : ghostgrid.db + ghostgrid.db-wal + ghostgrid.db-shm
| `APP_URL` | `http://localhost:<PORT>` | Base URL for deriving the Azure redirect URI |
| `PORT` | `3000` | HTTP listen port |
| `NODE_ENV` | — | `production` switches to static `dist/` serving |
| `CADDY_MANAGER` | unset | `true` makes this instance the sole Caddy owner (push/seed/edit). Set on production only — one Caddy per container |
| `CHECKMK_API_URL` / `CHECKMK_API_USER` / `CHECKMK_API_SECRET` | — | Fallbacks if not set in the Settings UI |
---

View File

@ -190,6 +190,8 @@ msg_info "Creating .env file for each instance"
for d in "${APP_DIR}" "${DEV_DIR}"; do
SECRET="$(openssl rand -hex 32)"
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).
[[ "$d" == "${APP_DIR}" ]] && run "printf 'CADDY_MANAGER=true\n' >> ${d}/.env"
done
msg_ok ".env files created (main + dev)"

View File

@ -15,6 +15,11 @@ 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_EXPIRY = '24h';
// One Caddy serves the whole container; only the instance with CADDY_MANAGER=true
// owns it (pushes config, seeds routes, accepts route edits). The other instance
// must never push — POST /load replaces the entire config and would clobber it.
const IS_CADDY_MANAGER = process.env.CADDY_MANAGER === 'true';
interface JwtPayload {
userId: string;
email: string;
@ -90,7 +95,7 @@ function buildCaddyfile(): string {
lines.push(' header_up X-Real-IP {remote_host}');
lines.push(' header_up Host {host}');
if (/^https:\/\//i.test(route.upstream)) {
// HTTPS upstream (e.g. Semaphore) — connect over TLS and skip certificate
// HTTPS upstream - connect over TLS and skip certificate
// verification, since such backends typically use a self-signed cert.
lines.push(' transport http {');
lines.push(' tls_insecure_skip_verify');
@ -142,6 +147,7 @@ function importCaddyfileRoutes(userId?: string): void {
}
async function pushCaddyConfig(): Promise<void> {
if (!IS_CADDY_MANAGER) return;
if (getSetting('caddy_enabled') !== 'true') return;
const adminUrl = getSetting('caddy_admin_url') || 'http://localhost:2019';
const body = buildCaddyfile();
@ -168,7 +174,7 @@ async function startServer() {
console.log('[Init] Default admin user created — login: admin@ghostgrid.local / admin');
}
if (getSetting('caddy_enabled') === 'true' && getCaddyRoutes().length === 0) {
if (IS_CADDY_MANAGER && getSetting('caddy_enabled') === 'true' && getCaddyRoutes().length === 0) {
importCaddyfileRoutes();
}
@ -265,6 +271,7 @@ async function startServer() {
effectiveRedirectUri,
checkmkEnabled: getSetting('checkmk_enabled') === 'true',
checkmkBaseUrl: cmkApiUrl.replace(/\/api\/.*$/, ''),
caddyManaged: IS_CADDY_MANAGER,
});
});
@ -1192,6 +1199,7 @@ async function startServer() {
app.post('/api/caddy/routes', requireAuth, async (req, res) => {
try {
if (!IS_CADDY_MANAGER) return res.status(403).json({ error: 'Caddy is managed by another instance.' });
const { hostname, upstream, tls, compress } = req.body as {
hostname?: string; upstream?: string; tls?: boolean; compress?: boolean;
};
@ -1211,6 +1219,7 @@ async function startServer() {
app.put('/api/caddy/routes/:id', requireAuth, async (req, res) => {
try {
if (!IS_CADDY_MANAGER) return res.status(403).json({ error: 'Caddy is managed by another instance.' });
const id = Number(req.params.id);
if (!id) return res.status(400).json({ error: 'Invalid route id.' });
const { hostname, upstream, tls, compress } = req.body as {
@ -1230,6 +1239,7 @@ async function startServer() {
app.delete('/api/caddy/routes/:id', requireAuth, (req, res) => {
try {
if (!IS_CADDY_MANAGER) return res.status(403).json({ error: 'Caddy is managed by another instance.' });
const id = Number(req.params.id);
if (!id) return res.status(400).json({ error: 'Invalid route id.' });
const existing = getCaddyRouteById(id);

View File

@ -191,6 +191,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
const [semaphoreTestResult, setSemaphoreTestResult] = useState<string | null>(null);
const [caddyEnabled, setCaddyEnabled] = useState(false);
const [caddyManaged, setCaddyManaged] = useState(true);
const [caddyAdminUrl, setCaddyAdminUrl] = useState('http://localhost:2019');
const [caddyStatus, setCaddyStatus] = useState<'unknown' | 'available' | 'unavailable'>('unknown');
const [caddyRoutes, setCaddyRoutes] = useState<CaddyRoute[]>([]);
@ -219,7 +220,10 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
loadDbInfo();
fetch('/api/auth/config')
.then(r => r.json())
.then(d => { if (d.effectiveRedirectUri) setEffectiveRedirectUri(d.effectiveRedirectUri); })
.then(d => {
if (d.effectiveRedirectUri) setEffectiveRedirectUri(d.effectiveRedirectUri);
setCaddyManaged(d.caddyManaged !== false);
})
.catch(() => {});
}, []);
@ -906,6 +910,8 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
</div>
</div>
<div className="flex items-center gap-2.5">
{caddyManaged ? (
<>
<span className={`text-[10px] font-semibold font-mono ${caddyEnabled ? 'text-sky-400' : 'text-slate-600'}`}>
{caddyEnabled ? 'ENABLED' : 'DISABLED'}
</span>
@ -916,11 +922,22 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
>
<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>
</>
) : (
<span className="text-[10px] font-semibold font-mono text-slate-500">MANAGED BY PRODUCTION</span>
)}
</div>
</div>
<div className="h-px bg-slate-800/60" />
{!caddyManaged && (
<p className="text-[11px] font-mono text-slate-500 leading-relaxed">
Caddy is centrally managed by the production instance (ghostgrid). Manage proxy routes there.
</p>
)}
{caddyManaged && (
<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" />} />
@ -1047,6 +1064,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
</div>
)}
</div>
)}
</SectionCard>
{/* ── Database ── */}