diff --git a/src/app/api/status/route.ts b/src/app/api/status/route.ts index 869545c..0ca1fb0 100644 --- a/src/app/api/status/route.ts +++ b/src/app/api/status/route.ts @@ -16,6 +16,13 @@ import { isHermesInstalled, scanHermesSessions } from '@/lib/hermes-sessions' import { registerMcAsDashboard } from '@/lib/gateway-runtime' export async function GET(request: NextRequest) { + // Docker/Kubernetes health probes must work without auth/cookies. + const preAction = new URL(request.url).searchParams.get('action') || 'overview' + if (preAction === 'health') { + const health = await performHealthCheck() + return NextResponse.json(health) + } + const auth = requireRole(request, 'viewer') if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) diff --git a/src/proxy.test.ts b/src/proxy.test.ts index cd2f7d7..abdcd6c 100644 --- a/src/proxy.test.ts +++ b/src/proxy.test.ts @@ -50,4 +50,62 @@ describe('proxy host matching', () => { const response = proxy(request) expect(response.status).toBe(403) }) + + it('allows unauthenticated health probe for /api/status?action=health', async () => { + vi.resetModules() + vi.doMock('node:os', () => ({ + default: { hostname: () => 'hetzner-jarv' }, + hostname: () => 'hetzner-jarv', + })) + + const { proxy } = await import('./proxy') + const request = { + headers: new Headers({ host: 'localhost:3000' }), + nextUrl: { + host: 'localhost:3000', + hostname: 'localhost', + pathname: '/api/status', + searchParams: new URLSearchParams('action=health'), + clone: () => ({ pathname: '/api/status' }), + }, + method: 'GET', + cookies: { get: () => undefined }, + } as any + + setNodeEnv('production') + process.env.MC_ALLOWED_HOSTS = 'localhost,127.0.0.1' + delete process.env.MC_ALLOW_ANY_HOST + + const response = proxy(request) + expect(response.status).not.toBe(401) + }) + + it('still blocks unauthenticated non-health status API calls', async () => { + vi.resetModules() + vi.doMock('node:os', () => ({ + default: { hostname: () => 'hetzner-jarv' }, + hostname: () => 'hetzner-jarv', + })) + + const { proxy } = await import('./proxy') + const request = { + headers: new Headers({ host: 'localhost:3000' }), + nextUrl: { + host: 'localhost:3000', + hostname: 'localhost', + pathname: '/api/status', + searchParams: new URLSearchParams('action=overview'), + clone: () => ({ pathname: '/api/status' }), + }, + method: 'GET', + cookies: { get: () => undefined }, + } as any + + setNodeEnv('production') + process.env.MC_ALLOWED_HOSTS = 'localhost,127.0.0.1' + delete process.env.MC_ALLOW_ANY_HOST + + const response = proxy(request) + expect(response.status).toBe(401) + }) }) diff --git a/src/proxy.ts b/src/proxy.ts index 8be932b..84fbc27 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -181,8 +181,9 @@ export function proxy(request: NextRequest) { } } - // Allow login page, auth API, and docs without session - if (pathname === '/login' || pathname.startsWith('/api/auth/') || pathname === '/api/docs' || pathname === '/docs') { + // Allow login page, auth API, docs, and container health probe without session + const isPublicHealthProbe = pathname === '/api/status' && request.nextUrl.searchParams.get('action') === 'health' + if (pathname === '/login' || pathname.startsWith('/api/auth/') || pathname === '/api/docs' || pathname === '/docs' || isPublicHealthProbe) { const { response, nonce } = nextResponseWithNonce(request) return addSecurityHeaders(response, request, nonce) }