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)
|
||||
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 |
|
||||
|
||||
---
|
||||
|
||||
@ -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)"
|
||||
|
||||
|
||||
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_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);
|
||||
|
||||
@ -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,21 +910,34 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
||||
</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: boolean) => !v)}
|
||||
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>
|
||||
{caddyManaged ? (
|
||||
<>
|
||||
<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: boolean) => !v)}
|
||||
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>
|
||||
</>
|
||||
) : (
|
||||
<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 ── */}
|
||||
|
||||
Reference in New Issue
Block a user