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

@ -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 ── */}