- Debug panel now probes both legacy (/api/status, /api/health) and current OpenClaw routes (/healthz, /health, /ready) with automatic fallback. Returns probedPath in response so admins know which route succeeded. - POST proxy allowlist updated to include current gateway health routes. - Path validation relaxed from /api/ prefix to / prefix (health routes don't use /api/). - Dockerfile: chmod 755 entrypoint (was +x/711, shell needs read permission) and chmod -R a+rX on public/ and src/ for nextjs user access.
This commit is contained in:
parent
311c7b06ab
commit
9a4c7c7b3c
|
|
@ -40,7 +40,8 @@ COPY --from=build /app/src/lib/schema.sql ./src/lib/schema.sql
|
||||||
RUN mkdir -p .data && chown nextjs:nodejs .data
|
RUN mkdir -p .data && chown nextjs:nodejs .data
|
||||||
RUN echo 'const http=require("http");const r=http.get("http://localhost:"+(process.env.PORT||3000)+"/api/status?action=health",s=>{process.exit(s.statusCode===200?0:1)});r.on("error",()=>process.exit(1));r.setTimeout(4000,()=>{r.destroy();process.exit(1)})' > /app/healthcheck.js
|
RUN echo 'const http=require("http");const r=http.get("http://localhost:"+(process.env.PORT||3000)+"/api/status?action=health",s=>{process.exit(s.statusCode===200?0:1)});r.on("error",()=>process.exit(1));r.setTimeout(4000,()=>{r.destroy();process.exit(1)})' > /app/healthcheck.js
|
||||||
COPY docker-entrypoint.sh /app/docker-entrypoint.sh
|
COPY docker-entrypoint.sh /app/docker-entrypoint.sh
|
||||||
RUN chmod +x /app/docker-entrypoint.sh
|
RUN chmod 755 /app/docker-entrypoint.sh && \
|
||||||
|
chmod -R a+rX /app/public/ /app/src/
|
||||||
USER nextjs
|
USER nextjs
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,25 @@ async function gatewayFetch(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try multiple candidate paths in order, return the first successful response.
|
||||||
|
* Supports both legacy (/api/*) and current (/healthz, /health, /ready) gateway routes.
|
||||||
|
*/
|
||||||
|
async function gatewayProbe(
|
||||||
|
candidates: string[],
|
||||||
|
options?: { timeoutMs?: number }
|
||||||
|
): Promise<{ res: Response; path: string } | null> {
|
||||||
|
for (const path of candidates) {
|
||||||
|
try {
|
||||||
|
const res = await gatewayFetch(path, { timeoutMs: options?.timeoutMs ?? 3000 })
|
||||||
|
if (res.ok) return { res, path }
|
||||||
|
} catch {
|
||||||
|
// try next candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
const auth = requireRole(request, 'admin')
|
const auth = requireRole(request, 'admin')
|
||||||
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
|
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
|
||||||
|
|
@ -36,25 +55,29 @@ export async function GET(request: Request) {
|
||||||
try {
|
try {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case 'status': {
|
case 'status': {
|
||||||
try {
|
const probe = await gatewayProbe(['/api/status', '/healthz', '/health'])
|
||||||
const res = await gatewayFetch('/api/status')
|
if (!probe) {
|
||||||
const data = await res.json()
|
|
||||||
return NextResponse.json(data)
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn({ err }, 'debug: gateway unreachable for status')
|
|
||||||
return NextResponse.json({ gatewayReachable: false })
|
return NextResponse.json({ gatewayReachable: false })
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
const data = await probe.res.json()
|
||||||
|
return NextResponse.json({ ...data, gatewayReachable: true, probedPath: probe.path })
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ gatewayReachable: true, probedPath: probe.path })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'health': {
|
case 'health': {
|
||||||
try {
|
const probe = await gatewayProbe(['/api/health', '/health', '/healthz', '/ready'])
|
||||||
const res = await gatewayFetch('/api/health')
|
if (!probe) {
|
||||||
const data = await res.json()
|
|
||||||
return NextResponse.json(data)
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn({ err }, 'debug: gateway unreachable for health')
|
|
||||||
return NextResponse.json({ healthy: false, error: 'Gateway unreachable' })
|
return NextResponse.json({ healthy: false, error: 'Gateway unreachable' })
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
const data = await probe.res.json()
|
||||||
|
return NextResponse.json({ ...data, healthy: true, probedPath: probe.path })
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ healthy: true, probedPath: probe.path })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'models': {
|
case 'models': {
|
||||||
|
|
@ -70,15 +93,12 @@ export async function GET(request: Request) {
|
||||||
|
|
||||||
case 'heartbeat': {
|
case 'heartbeat': {
|
||||||
const start = performance.now()
|
const start = performance.now()
|
||||||
try {
|
const probe = await gatewayProbe(['/api/heartbeat', '/healthz', '/ready'], { timeoutMs: 3000 })
|
||||||
const res = await gatewayFetch('/api/heartbeat', { timeoutMs: 3000 })
|
const latencyMs = Math.round(performance.now() - start)
|
||||||
const latencyMs = Math.round(performance.now() - start)
|
if (probe) {
|
||||||
const ok = res.ok
|
return NextResponse.json({ ok: true, latencyMs, timestamp: Date.now(), probedPath: probe.path })
|
||||||
return NextResponse.json({ ok, latencyMs, timestamp: Date.now() })
|
|
||||||
} catch {
|
|
||||||
const latencyMs = Math.round(performance.now() - start)
|
|
||||||
return NextResponse.json({ ok: false, latencyMs, timestamp: Date.now() })
|
|
||||||
}
|
}
|
||||||
|
return NextResponse.json({ ok: false, latencyMs, timestamp: Date.now() })
|
||||||
}
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
|
|
@ -90,6 +110,14 @@ export async function GET(request: Request) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Restrict proxy calls to known safe gateway paths to prevent SSRF
|
||||||
|
const ALLOWED_GATEWAY_PATHS = [
|
||||||
|
// Legacy gateway routes
|
||||||
|
'/api/status', '/api/health', '/api/models', '/api/heartbeat', '/api/agents', '/api/config',
|
||||||
|
// Current OpenClaw gateway routes
|
||||||
|
'/healthz', '/health', '/ready',
|
||||||
|
]
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const auth = requireRole(request, 'admin')
|
const auth = requireRole(request, 'admin')
|
||||||
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
|
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
|
||||||
|
|
@ -114,15 +142,13 @@ export async function POST(request: Request) {
|
||||||
return NextResponse.json({ error: 'method must be GET or POST' }, { status: 400 })
|
return NextResponse.json({ error: 'method must be GET or POST' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!path || typeof path !== 'string' || !path.startsWith('/api/')) {
|
if (!path || typeof path !== 'string' || !path.startsWith('/')) {
|
||||||
return NextResponse.json({ error: 'path must start with /api/' }, { status: 400 })
|
return NextResponse.json({ error: 'path must start with /' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restrict to known safe gateway API paths to prevent SSRF
|
|
||||||
const ALLOWED_GATEWAY_PATHS = ['/api/status', '/api/health', '/api/models', '/api/heartbeat', '/api/agents', '/api/config']
|
|
||||||
const normalizedPath = path.split('?')[0]
|
const normalizedPath = path.split('?')[0]
|
||||||
if (!ALLOWED_GATEWAY_PATHS.some(allowed => normalizedPath === allowed || normalizedPath.startsWith(allowed + '/'))) {
|
if (!ALLOWED_GATEWAY_PATHS.some(allowed => normalizedPath === allowed || normalizedPath.startsWith(allowed + '/'))) {
|
||||||
return NextResponse.json({ error: 'Path not in allowed gateway API paths' }, { status: 403 })
|
return NextResponse.json({ error: 'Path not in allowed gateway paths' }, { status: 403 })
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue