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:
@ -478,7 +478,15 @@ importCaddyfileRoutes(): reads /etc/caddy/Caddyfile on first Caddy enable
|
|||||||
|
|
||||||
pushCaddyConfig(): POST <caddy_admin_url>/load (Content-Type: text/caddyfile)
|
pushCaddyConfig(): POST <caddy_admin_url>/load (Content-Type: text/caddyfile)
|
||||||
called on startup, after settings save, after route add/delete
|
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 |
|
| `APP_URL` | `http://localhost:<PORT>` | Base URL for deriving the Azure redirect URI |
|
||||||
| `PORT` | `3000` | HTTP listen port |
|
| `PORT` | `3000` | HTTP listen port |
|
||||||
| `NODE_ENV` | — | `production` switches to static `dist/` serving |
|
| `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 |
|
| `CHECKMK_API_URL` / `CHECKMK_API_USER` / `CHECKMK_API_SECRET` | — | Fallbacks if not set in the Settings UI |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@ -190,6 +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).
|
||||||
|
[[ "$d" == "${APP_DIR}" ]] && run "printf 'CADDY_MANAGER=true\n' >> ${d}/.env"
|
||||||
done
|
done
|
||||||
msg_ok ".env files created (main + dev)"
|
msg_ok ".env files created (main + dev)"
|
||||||
|
|
||||||
|
|||||||
14
server.ts
14
server.ts
@ -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_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
|
||||||
|
// 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 {
|
interface JwtPayload {
|
||||||
userId: string;
|
userId: string;
|
||||||
email: string;
|
email: string;
|
||||||
@ -90,7 +95,7 @@ function buildCaddyfile(): string {
|
|||||||
lines.push(' header_up X-Real-IP {remote_host}');
|
lines.push(' header_up X-Real-IP {remote_host}');
|
||||||
lines.push(' header_up Host {host}');
|
lines.push(' header_up Host {host}');
|
||||||
if (/^https:\/\//i.test(route.upstream)) {
|
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.
|
// verification, since such backends typically use a self-signed cert.
|
||||||
lines.push(' transport http {');
|
lines.push(' transport http {');
|
||||||
lines.push(' tls_insecure_skip_verify');
|
lines.push(' tls_insecure_skip_verify');
|
||||||
@ -142,6 +147,7 @@ function importCaddyfileRoutes(userId?: string): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function pushCaddyConfig(): Promise<void> {
|
async function pushCaddyConfig(): Promise<void> {
|
||||||
|
if (!IS_CADDY_MANAGER) 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();
|
||||||
@ -168,7 +174,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 (getSetting('caddy_enabled') === 'true' && getCaddyRoutes().length === 0) {
|
if (IS_CADDY_MANAGER && getSetting('caddy_enabled') === 'true' && getCaddyRoutes().length === 0) {
|
||||||
importCaddyfileRoutes();
|
importCaddyfileRoutes();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -265,6 +271,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,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -1192,6 +1199,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.' });
|
||||||
const { hostname, upstream, tls, compress } = req.body as {
|
const { hostname, upstream, tls, compress } = req.body as {
|
||||||
hostname?: string; upstream?: string; tls?: boolean; compress?: boolean;
|
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) => {
|
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.' });
|
||||||
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 } = req.body as {
|
const { hostname, upstream, tls, compress } = req.body as {
|
||||||
@ -1230,6 +1239,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.' });
|
||||||
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);
|
||||||
|
|||||||
@ -191,6 +191,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
|||||||
const [semaphoreTestResult, setSemaphoreTestResult] = useState<string | null>(null);
|
const [semaphoreTestResult, setSemaphoreTestResult] = useState<string | null>(null);
|
||||||
|
|
||||||
const [caddyEnabled, setCaddyEnabled] = useState(false);
|
const [caddyEnabled, setCaddyEnabled] = useState(false);
|
||||||
|
const [caddyManaged, setCaddyManaged] = useState(true);
|
||||||
const [caddyAdminUrl, setCaddyAdminUrl] = useState('http://localhost:2019');
|
const [caddyAdminUrl, setCaddyAdminUrl] = useState('http://localhost: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[]>([]);
|
||||||
@ -219,7 +220,10 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
|||||||
loadDbInfo();
|
loadDbInfo();
|
||||||
fetch('/api/auth/config')
|
fetch('/api/auth/config')
|
||||||
.then(r => r.json())
|
.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(() => {});
|
.catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@ -906,21 +910,34 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<span className={`text-[10px] font-semibold font-mono ${caddyEnabled ? 'text-sky-400' : 'text-slate-600'}`}>
|
{caddyManaged ? (
|
||||||
{caddyEnabled ? 'ENABLED' : 'DISABLED'}
|
<>
|
||||||
</span>
|
<span className={`text-[10px] font-semibold font-mono ${caddyEnabled ? 'text-sky-400' : 'text-slate-600'}`}>
|
||||||
<button
|
{caddyEnabled ? 'ENABLED' : 'DISABLED'}
|
||||||
type="button"
|
</span>
|
||||||
onClick={() => setCaddyEnabled((v: boolean) => !v)}
|
<button
|
||||||
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'}`}
|
type="button"
|
||||||
>
|
onClick={() => setCaddyEnabled((v: boolean) => !v)}
|
||||||
<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'}`} />
|
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'}`}
|
||||||
</button>
|
>
|
||||||
|
<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>
|
</div>
|
||||||
|
|
||||||
<div className="h-px bg-slate-800/60" />
|
<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' : ''}`}>
|
<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://localhost: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://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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
{/* ── Database ── */}
|
{/* ── Database ── */}
|
||||||
|
|||||||
Reference in New Issue
Block a user