fix: update debug panel gateway routes and Docker file permissions (#461, #458)

- 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:
Nyk 2026-03-21 18:51:40 +07:00
parent 311c7b06ab
commit 9a4c7c7b3c
2 changed files with 53 additions and 26 deletions

View File

@ -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 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
RUN chmod +x /app/docker-entrypoint.sh
RUN chmod 755 /app/docker-entrypoint.sh && \
chmod -R a+rX /app/public/ /app/src/
USER nextjs
ENV PORT=3000
EXPOSE 3000

View File

@ -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) {
const auth = requireRole(request, 'admin')
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
@ -36,25 +55,29 @@ export async function GET(request: Request) {
try {
switch (action) {
case 'status': {
try {
const res = await gatewayFetch('/api/status')
const data = await res.json()
return NextResponse.json(data)
} catch (err) {
logger.warn({ err }, 'debug: gateway unreachable for status')
const probe = await gatewayProbe(['/api/status', '/healthz', '/health'])
if (!probe) {
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': {
try {
const res = await gatewayFetch('/api/health')
const data = await res.json()
return NextResponse.json(data)
} catch (err) {
logger.warn({ err }, 'debug: gateway unreachable for health')
const probe = await gatewayProbe(['/api/health', '/health', '/healthz', '/ready'])
if (!probe) {
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': {
@ -70,15 +93,12 @@ export async function GET(request: Request) {
case 'heartbeat': {
const start = performance.now()
try {
const res = await gatewayFetch('/api/heartbeat', { timeoutMs: 3000 })
const latencyMs = Math.round(performance.now() - start)
const ok = res.ok
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() })
const probe = await gatewayProbe(['/api/heartbeat', '/healthz', '/ready'], { timeoutMs: 3000 })
const latencyMs = Math.round(performance.now() - start)
if (probe) {
return NextResponse.json({ ok: true, latencyMs, timestamp: Date.now(), probedPath: probe.path })
}
return NextResponse.json({ ok: false, latencyMs, timestamp: Date.now() })
}
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) {
const auth = requireRole(request, 'admin')
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 })
}
if (!path || typeof path !== 'string' || !path.startsWith('/api/')) {
return NextResponse.json({ error: 'path must start with /api/' }, { status: 400 })
if (!path || typeof path !== 'string' || !path.startsWith('/')) {
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]
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 {