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
+100 -23
View File
@@ -7,10 +7,13 @@
* them here eliminates duplication and provides a single place to test.
*/
/** Which Docker stream a log line came from. */
export type LogStreamSource = 'STDOUT' | 'STDERR';
export interface GlobalLogEntry {
stackName: string;
containerName: string;
source: 'STDOUT' | 'STDERR';
source: LogStreamSource;
level: 'INFO' | 'WARN' | 'ERROR';
message: string;
timestampMs: number;
@@ -87,7 +90,7 @@ export function parseLogTimestamp(line: string): { timestampMs: number; cleanMes
* 3. ERROR/FATAL/CRIT/PANIC keywords or `Exception:` pattern
* 4. Fallback: STDERR -> ERROR, STDOUT -> INFO
*/
export function detectLogLevel(message: string, source: 'STDOUT' | 'STDERR'): 'INFO' | 'WARN' | 'ERROR' {
export function detectLogLevel(message: string, source: LogStreamSource): 'INFO' | 'WARN' | 'ERROR' {
// Tier 1: INFO/DEBUG/TRACE (overrides STDERR default)
if (INFO_STRUCTURED_RE.test(message) || INFO_BRACKET_RE.test(message) || INFO_KEYWORD_RE.test(message)) {
return 'INFO';
@@ -109,32 +112,106 @@ export function stripControlChars(text: string): string {
return text.replace(CONTROL_CHARS_RE, '');
}
/** A stateful demuxer that survives chunk boundaries. See `createFrameDemuxer`. */
export interface FrameDemuxer {
/** Feed the next chunk of stream bytes; emits every complete line found. */
push(chunk: Buffer): void;
/** Emit any buffered partial line that has no trailing newline. Call once on stream end. */
flush(): void;
}
/**
* Parse Docker's multiplexed log stream format and call `onLine` for each
* line. TTY containers produce raw text (no headers); non-TTY containers
* prepend an 8-byte header per frame:
* Create a stateful demuxer for Docker's multiplexed log stream.
*
* Unlike a one-shot parse, this retains state across `push` calls so a frame
* header or payload split across two chunk boundaries is reassembled instead
* of dropped, and a log line split across two frames is joined instead of
* emitted as two partials. Line buffers are kept per source so interleaved
* STDOUT/STDERR frames don't bleed into one another.
*
* TTY containers produce raw text (no headers, single STDOUT stream); non-TTY
* containers prepend an 8-byte header per frame:
* [streamType(1), reserved(3), payloadLength(4 BE)]
*
* @param onFrameError called once per malformed frame header (invalid stream
* type). The demuxer advances one byte to attempt resync rather than
* stalling; the callback lets the caller count corruption events.
*/
export function createFrameDemuxer(
isTty: boolean,
onLine: (line: string, source: LogStreamSource) => void,
onFrameError?: () => void,
): FrameDemuxer {
if (isTty) {
let lineBuf = '';
return {
push(chunk: Buffer): void {
lineBuf += stripControlChars(chunk.toString('utf-8'));
const parts = lineBuf.split('\n');
lineBuf = parts.pop() ?? '';
for (const line of parts) onLine(line, 'STDOUT');
},
flush(): void {
if (lineBuf) {
onLine(lineBuf, 'STDOUT');
lineBuf = '';
}
},
};
}
let leftover: Buffer = Buffer.alloc(0);
const lineBufs: Record<LogStreamSource, string> = { STDOUT: '', STDERR: '' };
const emitPayload = (payload: string, source: LogStreamSource): void => {
const parts = (lineBufs[source] + payload).split('\n');
lineBufs[source] = parts.pop() ?? '';
for (const line of parts) onLine(line, source);
};
return {
push(chunk: Buffer): void {
const buf = leftover.length ? Buffer.concat([leftover, chunk]) : chunk;
let offset = 0;
while (offset + 8 <= buf.length) {
const streamType = buf[offset];
// Valid Docker stream types: 0 (stdin, unused here), 1 (stdout), 2 (stderr).
if (streamType > 2) {
onFrameError?.();
offset += 1; // attempt resync rather than stalling on a corrupt header
continue;
}
const length = buf.readUInt32BE(offset + 4);
if (offset + 8 + length > buf.length) break; // payload not fully arrived yet
const payload = buf.slice(offset + 8, offset + 8 + length).toString('utf-8');
offset += 8 + length;
emitPayload(payload, streamType === 2 ? 'STDERR' : 'STDOUT');
}
leftover = offset < buf.length ? buf.subarray(offset) : Buffer.alloc(0);
},
flush(): void {
for (const source of ['STDOUT', 'STDERR'] as const) {
if (lineBufs[source]) {
onLine(lineBufs[source], source);
lineBufs[source] = '';
}
}
},
};
}
/**
* One-shot demux of a complete Docker log buffer (the polling snapshot path,
* where the whole response is in hand). Implemented on top of
* `createFrameDemuxer` so frame- and line-splitting are handled identically to
* the streaming path.
*/
export function demuxDockerLog(
buf: Buffer,
isTty: boolean,
onLine: (line: string, source: 'STDOUT' | 'STDERR') => void,
onLine: (line: string, source: LogStreamSource) => void,
): void {
if (isTty) {
stripControlChars(buf.toString('utf-8'))
.split('\n')
.forEach(line => onLine(line, 'STDOUT'));
return;
}
let offset = 0;
while (offset < buf.length) {
if (offset + 8 > buf.length) break;
const streamType = buf[offset];
const length = buf.readUInt32BE(offset + 4);
offset += 8;
if (offset + length > buf.length) break;
const payload = buf.slice(offset, offset + length).toString('utf-8');
offset += length;
payload.split('\n').forEach(line => onLine(line, streamType === 2 ? 'STDERR' : 'STDOUT'));
}
const demuxer = createFrameDemuxer(isTty, onLine);
demuxer.push(buf);
demuxer.flush();
}
+25
View File
@@ -0,0 +1,25 @@
import path from 'path';
/** Minimal container shape needed to decide managed-vs-external. */
export interface LabeledContainer {
Labels?: Record<string, string>;
}
/**
* "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 mis-classified as external. Containers without the label (plain
* `docker run`, or another tool's compose project outside COMPOSE_DIR) are
* treated as unmanaged.
*
* @param container any object carrying Docker labels
* @param composeDir an already `path.resolve`d COMPOSE_DIR for the node
*/
export function isManagedByComposeDir(container: LabeledContainer, composeDir: string): boolean {
const workingDir = container.Labels?.['com.docker.compose.project.working_dir'];
if (!workingDir) return false;
const resolved = path.resolve(workingDir);
return resolved === composeDir || resolved.startsWith(composeDir + path.sep);
}