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

@ -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);