fix(observability): gate global logs to admins, scope to managed containers, harden SSE (#1254)

* fix(observability): gate global logs to admins, scope to managed containers, harden SSE

Make the Logs feed an administrator view enforced on both sides (requireAdmin on
the /api/logs/global poll and SSE routes; the Logs nav item plus a redirect guard
on the frontend), and scope the feed to Sencho-managed containers only via a
shared isManagedByComposeDir helper that /stats now reuses.

Harden the SSE stream: a stateful frame demuxer that survives chunk boundaries so
a Docker frame split across reads is reassembled instead of dropped or garbled; a
per-stream error listener so one broken follow stream cannot crash the event loop
(it posts a single degraded notice and keeps the others alive); a cap on
concurrent follow streams with a truncation notice; a bounded initial tail; and
backpressure that pauses the source streams when the client is slow and resumes on
drain. Bound the polling snapshot's per-container fan-out with a concurrency limit.

Add process-local, in-memory log-stream counters exposed at the admin-only
/api/system/log-stream-metrics endpoint (active connections, lines streamed,
attach and frame errors). Collapse the view to the local hub and remove the dead
remote-node handling.

* fix(observability): close remote-proxy bypass of the global-logs admin gate

The logs feed's requireAdmin lives in the local route handler, which the remote
proxy skips when forwarding a request whose nodeId targets a remote node. A hub
user could therefore request /api/logs/global*, /api/logs/global/stream, or
/api/system/log-stream-metrics with x-node-id (or ?nodeId= for the SSE transport)
pointing at a remote node and have it served as the node-proxy admin on the far
side, sidestepping the gate entirely.

Add these paths to HUB_ONLY_PREFIXES so hubOnlyGuard rejects a remote nodeId with
403 before the proxy runs, matching the existing protection on audit-log,
scheduled-tasks, and notification-routes. Add regression tests covering the
collection path, the SSE sub-path (both the x-node-id header and the ?nodeId=
query transport), and the stream-metrics endpoint.
This commit is contained in:
Anso
2026-05-29 21:09:20 -04:00
committed by GitHub
parent a5bfd48005
commit 69edb0dcbb
14 changed files with 720 additions and 115 deletions
+167 -51
View File
@@ -11,16 +11,71 @@ import { requireAdmin } from '../middleware/tierGates';
import { STATS_CACHE_TTL_MS, SYSTEM_STATS_CACHE_TTL_MS } from '../helpers/constants';
import { isDebugEnabled } from '../utils/debug';
import { getErrorMessage } from '../utils/errors';
import { isManagedByComposeDir } from '../utils/managed-containers';
import { GlobalLogsMetrics } from '../services/GlobalLogsMetrics';
import {
type GlobalLogEntry,
normalizeContainerName,
parseLogTimestamp,
detectLogLevel,
demuxDockerLog,
createFrameDemuxer,
} from '../utils/log-parsing';
export const metricsRouter = Router();
// Lines of history each container replays when a feed opens. Bounds the
// open-time burst (was 500 per container, multiplied across every container).
const STREAM_INITIAL_TAIL = 200;
const POLL_TAIL = 100;
// Hard cap on simultaneous `docker logs --follow` streams behind one SSE
// connection. Beyond this the feed is truncated and the operator is told.
const MAX_FOLLOW_STREAMS = 60;
// Concurrency limit for the polling snapshot's per-container Docker calls so a
// large managed set does not fan out N simultaneous requests at the daemon.
const POLL_CONCURRENCY = 8;
interface ContainerSummary {
Id: string;
Names?: string[];
Labels?: Record<string, string>;
}
/** Read a managed-only set of running containers for the node. */
async function getManagedRunningContainers(
nodeId: number,
): Promise<{ containers: ContainerSummary[]; total: number }> {
const composeDir = path.resolve(NodeRegistry.getInstance().getComposeDir(nodeId));
const all = (await DockerController.getInstance(nodeId).getRunningContainers()) as ContainerSummary[];
const containers = all.filter(c => isManagedByComposeDir(c, composeDir));
return { containers, total: all.length };
}
/** Map a Docker container summary to its display stack + container name. */
function describeContainer(c: ContainerSummary): { stackName: string; containerName: string } {
const stackName = c.Labels?.['com.docker.compose.project'] || 'system';
const rawName = c.Names?.[0]?.replace(/^\//, '') || c.Id.substring(0, 12);
return { stackName, containerName: normalizeContainerName(rawName, stackName) };
}
/**
* Run an async mapper over items with a bounded number in flight. The caller's
* `fn` must handle its own errors: a rejection propagates and abandons the
* remaining work (the log endpoints wrap their body in try/catch for this).
* Exported for unit testing.
*/
export async function mapWithConcurrency<T>(items: T[], limit: number, fn: (item: T) => Promise<void>): Promise<void> {
let cursor = 0;
const worker = async (): Promise<void> => {
while (cursor < items.length) {
const item = items[cursor++];
await fn(item);
}
};
const workerCount = Math.min(limit, items.length);
await Promise.all(Array.from({ length: workerCount }, () => worker()));
}
/**
* Container stats aggregated for the dashboard. Cached per-node for 2s to
* collapse multi-tab polling pressure. Write-path endpoints (deploy, down,
@@ -35,24 +90,13 @@ metricsRouter.get('/stats', authMiddleware, async (req: Request, res: Response):
async () => {
const allContainers = await DockerController.getInstance(req.nodeId).getAllContainers();
// "Managed" means Docker started the container from within COMPOSE_DIR.
// We key on `com.docker.compose.project.working_dir` rather than the
// project name so stacks launched from the COMPOSE_DIR root (not a
// subdirectory) aren't all mis-classified as external.
const isManagedByComposeDir = (c: { Labels?: Record<string, string> }): boolean => {
const workingDir: string | undefined = c.Labels?.['com.docker.compose.project.working_dir'];
if (!workingDir) return false;
const resolved = path.resolve(workingDir);
return resolved === composeDir || resolved.startsWith(composeDir + path.sep);
};
type ContainerInfo = { State?: string; Labels?: Record<string, string> };
const cs = allContainers as ContainerInfo[];
const active = cs.filter(c => c.State === 'running').length;
const exited = cs.filter(c => c.State === 'exited').length;
const total = cs.length;
const managed = cs.filter(c => c.State === 'running' && isManagedByComposeDir(c)).length;
const unmanaged = cs.filter(c => c.State === 'running' && !isManagedByComposeDir(c)).length;
const managed = cs.filter(c => c.State === 'running' && isManagedByComposeDir(c, composeDir)).length;
const unmanaged = cs.filter(c => c.State === 'running' && !isManagedByComposeDir(c, composeDir)).length;
return { active, managed, unmanaged, exited, total };
},
@@ -73,23 +117,22 @@ metricsRouter.get('/metrics/historical', authMiddleware, async (_req: Request, r
});
metricsRouter.get('/logs/global', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
try {
GlobalLogsMetrics.increment('poll_requests_total');
const debug = isDebugEnabled();
const dockerController = DockerController.getInstance(req.nodeId);
const containers = await dockerController.getRunningContainers();
const { containers, total } = await getManagedRunningContainers(req.nodeId);
const allLogs: GlobalLogEntry[] = [];
if (debug) console.debug('[GlobalLogs:debug] Polling snapshot starting', { containerCount: containers.length, nodeId: req.nodeId });
await Promise.all(containers.map(async (c) => {
const stackName = c.Labels?.['com.docker.compose.project'] || 'system';
const rawName = c.Names?.[0]?.replace(/^\//, '') || c.Id.substring(0, 12);
const containerName = normalizeContainerName(rawName, stackName);
if (debug) console.debug('[GlobalLogs:debug] Polling snapshot starting', { managed: containers.length, total, nodeId: req.nodeId });
await mapWithConcurrency(containers, POLL_CONCURRENCY, async (c) => {
const { stackName, containerName } = describeContainer(c);
try {
const container = dockerController.getDocker().getContainer(c.Id);
const inspect = await container.inspect();
const isTty = inspect.Config.Tty;
const logsBuffer = await container.logs({ stdout: true, stderr: true, tail: 100, timestamps: true }) as Buffer;
const logsBuffer = await container.logs({ stdout: true, stderr: true, tail: POLL_TAIL, timestamps: true }) as Buffer;
demuxDockerLog(logsBuffer, isTty, (line, source) => {
if (!line.trim()) return;
@@ -98,15 +141,20 @@ metricsRouter.get('/logs/global', authMiddleware, async (req: Request, res: Resp
allLogs.push({ stackName, containerName, source, level, message: cleanMessage, timestampMs });
});
} catch (err) {
// Mirror the SSE path so per-container read failures are visible on the
// same counter rather than only in the log.
GlobalLogsMetrics.increment('stream_attach_errors_total');
console.warn(`[GlobalLogs] Failed to fetch/parse logs for container ${containerName} (${c.Id.substring(0, 12)}):`, getErrorMessage(err, 'unknown'));
}
}));
});
// Sort ascending by timestamp (newest bottom). Limit to 500 lines; the
// client only renders ~300 at a time.
allLogs.sort((a, b) => a.timestampMs - b.timestampMs);
if (debug) console.debug('[GlobalLogs:debug] Polling snapshot complete', { totalLines: allLogs.length });
res.json(allLogs.slice(-500));
const snapshot = allLogs.slice(-500);
GlobalLogsMetrics.increment('lines_streamed_total', snapshot.length);
if (debug) console.debug('[GlobalLogs:debug] Polling snapshot complete', { totalLines: allLogs.length, returned: snapshot.length });
res.json(snapshot);
} catch (error) {
console.error('[GlobalLogs] Snapshot fetch failed:', getErrorMessage(error, 'unknown'));
res.status(500).json({ error: 'Failed to fetch global logs' });
@@ -114,6 +162,8 @@ metricsRouter.get('/logs/global', authMiddleware, async (req: Request, res: Resp
});
metricsRouter.get('/logs/global/stream', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
@@ -125,57 +175,106 @@ metricsRouter.get('/logs/global/stream', authMiddleware, async (req: Request, re
const dockerController = DockerController.getInstance(req.nodeId);
const streams: NodeJS.ReadableStream[] = [];
GlobalLogsMetrics.openConnection();
let closed = false;
let paused = false;
const destroyStream = (s: NodeJS.ReadableStream): void => {
try { (s as NodeJS.ReadableStream & { destroy(): void }).destroy(); } catch { /* already ended */ }
};
// Back off every source follow-stream when the socket buffer fills, then
// resume on 'drain', so a slow client cannot drive unbounded Node-side
// buffering across N concurrent streams.
const pauseAll = (): void => { if (paused) return; paused = true; streams.forEach(s => { try { s.pause(); } catch { /* ended */ } }); };
const resumeAll = (): void => { if (!paused) return; paused = false; streams.forEach(s => { try { s.resume(); } catch { /* ended */ } }); };
res.on('drain', resumeAll);
const writeEvent = (entry: GlobalLogEntry): void => {
if (res.writableEnded) return;
const ok = res.write(`data: ${JSON.stringify(entry)}\n\n`);
GlobalLogsMetrics.increment('lines_streamed_total');
if (!ok) pauseAll();
};
// SSE heartbeat (: prefix is a comment, silently dropped by EventSource)
// every 30s keeps reverse proxies from closing idle connections.
// every 30s keeps reverse proxies from closing idle connections. Honor
// backpressure here too so a heartbeat that fills the socket buffer still
// pauses the source streams.
const heartbeat = setInterval(() => {
if (!res.writableEnded) res.write(':heartbeat\n\n');
if (!res.writableEnded && !res.write(':heartbeat\n\n')) pauseAll();
}, 30_000);
const cleanup = (): void => {
if (closed) return;
closed = true;
clearInterval(heartbeat);
res.removeListener('drain', resumeAll);
if (debug) console.debug('[GlobalLogs:debug] SSE stream closed, cleaning up', { streamCount: streams.length });
streams.forEach(destroyStream);
GlobalLogsMetrics.closeConnection();
};
req.on('close', cleanup);
try {
const containers = await dockerController.getRunningContainers();
if (debug) console.debug('[GlobalLogs:debug] SSE stream opened', { containerCount: containers.length, nodeId: req.nodeId });
await Promise.all(containers.map(async (c) => {
const stackName = c.Labels?.['com.docker.compose.project'] || 'system';
const rawName = c.Names?.[0]?.replace(/^\//, '') || c.Id.substring(0, 12);
const containerName = normalizeContainerName(rawName, stackName);
const { containers, total } = await getManagedRunningContainers(req.nodeId);
const followSet = containers.slice(0, MAX_FOLLOW_STREAMS);
const truncated = containers.length - followSet.length;
if (debug) console.debug('[GlobalLogs:debug] SSE stream opened', { managed: containers.length, total, following: followSet.length, nodeId: req.nodeId });
await Promise.all(followSet.map(async (c) => {
const { stackName, containerName } = describeContainer(c);
try {
const container = dockerController.getDocker().getContainer(c.Id);
const inspect = await container.inspect();
const isTty = inspect.Config.Tty;
const stream = await container.logs({ follow: true, stdout: true, stderr: true, tail: 500, timestamps: true });
const stream = await container.logs({ follow: true, stdout: true, stderr: true, tail: STREAM_INITIAL_TAIL, timestamps: true });
// The connection may have closed while we awaited inspect/logs.
if (closed) { destroyStream(stream); return; }
streams.push(stream);
if (paused) { try { stream.pause(); } catch { /* ended */ } }
stream.on('data', (chunk: Buffer) => {
demuxDockerLog(chunk, isTty, (line, source) => {
const demuxer = createFrameDemuxer(
isTty,
(line, source) => {
if (!line.trim()) return;
const { timestampMs, cleanMessage } = parseLogTimestamp(line);
const level = detectLogLevel(cleanMessage, source);
if (!res.writableEnded) {
res.write(`data: ${JSON.stringify({ stackName, containerName, source, level, message: cleanMessage, timestampMs })}\n\n`);
}
});
writeEvent({ stackName, containerName, source, level, message: cleanMessage, timestampMs });
},
() => GlobalLogsMetrics.increment('demux_frame_errors_total'),
);
stream.on('data', (chunk: Buffer) => demuxer.push(chunk));
// Drain the demuxer's buffered trailing line (one with no newline, the
// common shape of a crash/exit line) when the follow stream ends or
// breaks, so the last thing a container said is not silently lost.
stream.on('end', () => demuxer.flush());
stream.on('error', (err) => {
GlobalLogsMetrics.increment('stream_attach_errors_total');
console.warn(`[GlobalLogs] Follow stream error for ${containerName} (${c.Id.substring(0, 12)}):`, getErrorMessage(err, 'unknown'));
demuxer.flush();
// One degraded notice per drop (not per failed read) so the operator
// sees the gap in the feed and the WARNINGS tile without log spam.
writeEvent({ stackName, containerName, source: 'STDERR', level: 'WARN', message: `[Sencho] Log stream for ${containerName} ended unexpectedly; reopen the tab to resume.`, timestampMs: Date.now() });
destroyStream(stream);
});
} catch (err) {
GlobalLogsMetrics.increment('stream_attach_errors_total');
console.warn(`[GlobalLogs] Failed to attach stream for container ${containerName} (${c.Id.substring(0, 12)}):`, getErrorMessage(err, 'unknown'));
}
}));
req.on('close', () => {
clearInterval(heartbeat);
if (debug) console.debug('[GlobalLogs:debug] SSE stream closed, cleaning up', { streamCount: streams.length });
streams.forEach(s => {
try { (s as NodeJS.ReadableStream & { destroy(): void }).destroy(); } catch { /* stream already ended */ }
});
});
if (truncated > 0) {
writeEvent({ stackName: 'system', containerName: 'sencho', source: 'STDOUT', level: 'WARN', message: `[Sencho] Following ${followSet.length} of ${containers.length} managed containers; ${truncated} not shown. Use the per-container log viewer for the rest.`, timestampMs: Date.now() });
}
} catch (error) {
clearInterval(heartbeat);
console.error('[GlobalLogs] SSE stream attachment failed:', getErrorMessage(error, 'unknown'));
res.write(`data: ${JSON.stringify({ level: 'ERROR', message: '[Sencho] Failed to attach global log stream.', timestampMs: Date.now(), stackName: 'system', containerName: 'backend', source: 'STDERR' })}\n\n`);
res.end();
if (!res.writableEnded) {
res.write(`data: ${JSON.stringify({ level: 'ERROR', message: '[Sencho] Failed to attach global log stream.', timestampMs: Date.now(), stackName: 'system', containerName: 'backend', source: 'STDERR' })}\n\n`);
}
cleanup();
if (!res.writableEnded) res.end();
}
});
@@ -267,3 +366,20 @@ metricsRouter.get('/system/pilot-tunnels', authMiddleware, async (req: Request,
res.status(500).json({ error: 'Failed to fetch pilot tunnel metrics' });
}
});
/**
* Admin-only Global Observability log-stream observability. Process-local,
* in-memory counters (reset on restart by design; see GlobalLogsMetrics). The
* `active_sse_connections` gauge is the load-bearing field: it should drain to
* zero when no Logs tab is open, and a rising `stream_attach_errors_total` or
* `demux_frame_errors_total` points at a daemon or stream-corruption problem.
*/
metricsRouter.get('/system/log-stream-metrics', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
try {
res.json(GlobalLogsMetrics.snapshot());
} catch (error) {
console.error('Failed to fetch log-stream metrics:', error);
res.status(500).json({ error: 'Failed to fetch log-stream metrics' });
}
});