Add docker attach support (#1377)
* Add attach mode * Better UI * Remove unnecessary message * extract + test the docker stream demux; reset terminal mode per session - Pull the Docker hijacked-stream decoder (HTTP header strip, chunked decode, 8-byte multiplexed frame demux + raw/TTY fallback) into a pure src/lib/server/docker-stream-core.ts with unit tests covering raw passthrough, stdout/stderr demux, frames split across socket events, a split header, chunked reassembly, and the raw-fallback path. - vite.config.ts now imports the core instead of carrying its own copy. server.js runs against ./build and can't import the TS core at runtime, so it keeps the inline copy with a note to keep the two in sync. - containers page: default a fresh terminal session to exec so a prior container's Attach choice (shared terminalMode) doesn't carry over. --------- Co-authored-by: Jarek Krochmalski <jkrochmalski@gmail.com>
This commit is contained in:
1 parent
560b21e03f
commit
0ca3b86f82
10 files changed
+688
-208
No files matched your search
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Pure Docker hijacked-stream decoding for the terminal WebSocket handlers.
|
||||
*
|
||||
* `docker exec` with a TTY returns a RAW byte stream, but `docker attach` to a
|
||||
* container started WITHOUT a tty returns a MULTIPLEXED stream: each frame is an
|
||||
* 8-byte header (stream type in byte 0, 32-bit big-endian payload size in bytes
|
||||
* 4-7) followed by the payload. The daemon may also chunk the HTTP body. This
|
||||
* module strips the HTTP response headers, decodes chunked transfer-encoding, and
|
||||
* demultiplexes the frames into plain output strings.
|
||||
*
|
||||
* No imports / no I/O so it is unit-testable in the bun runner and shared by BOTH
|
||||
* WebSocket servers (server.js in production, vite.config.ts in dev) instead of
|
||||
* being duplicated in each.
|
||||
*/
|
||||
|
||||
export interface DockerStreamState {
|
||||
headersStripped: boolean;
|
||||
isChunked: boolean;
|
||||
headerBuffer: Buffer;
|
||||
chunkBuffer: Buffer;
|
||||
chunkSize: number | null;
|
||||
chunkEnded: boolean;
|
||||
multiplexed: boolean;
|
||||
streamBuffer: Buffer;
|
||||
}
|
||||
|
||||
export function createDockerStreamState(multiplexed = false): DockerStreamState {
|
||||
return {
|
||||
headersStripped: false,
|
||||
isChunked: false,
|
||||
headerBuffer: Buffer.alloc(0),
|
||||
chunkBuffer: Buffer.alloc(0),
|
||||
chunkSize: null,
|
||||
chunkEnded: false,
|
||||
multiplexed,
|
||||
streamBuffer: Buffer.alloc(0)
|
||||
};
|
||||
}
|
||||
|
||||
/** Decode HTTP chunked transfer-encoding into raw body buffers. */
|
||||
export function decodeChunkedDockerBody(data: Buffer, state: DockerStreamState): Buffer[] {
|
||||
state.chunkBuffer = Buffer.concat([state.chunkBuffer, data]);
|
||||
const chunks: Buffer[] = [];
|
||||
|
||||
while (!state.chunkEnded) {
|
||||
if (state.chunkSize === null) {
|
||||
const lineEnd = state.chunkBuffer.indexOf('\r\n');
|
||||
if (lineEnd < 0) break;
|
||||
|
||||
const sizeText = state.chunkBuffer.slice(0, lineEnd).toString('ascii').split(';', 1)[0];
|
||||
const size = parseInt(sizeText, 16);
|
||||
if (!Number.isFinite(size) || size < 0) {
|
||||
// Malformed size line: surface what we have and stop parsing this body.
|
||||
state.chunkEnded = true;
|
||||
chunks.push(state.chunkBuffer);
|
||||
state.chunkBuffer = Buffer.alloc(0);
|
||||
break;
|
||||
}
|
||||
|
||||
state.chunkBuffer = state.chunkBuffer.slice(lineEnd + 2);
|
||||
state.chunkSize = size;
|
||||
if (size === 0) {
|
||||
state.chunkEnded = true;
|
||||
state.chunkBuffer = Buffer.alloc(0);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (state.chunkBuffer.length < state.chunkSize + 2) break;
|
||||
chunks.push(state.chunkBuffer.slice(0, state.chunkSize));
|
||||
state.chunkBuffer = state.chunkBuffer.slice(state.chunkSize + 2);
|
||||
state.chunkSize = null;
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Feed one raw socket chunk through the decoder and return zero or more decoded
|
||||
* output strings. Strips HTTP headers on the first call, decodes chunked bodies,
|
||||
* and demultiplexes 8-byte-framed output; a non-multiplexed (TTY) stream passes
|
||||
* through unchanged. Falls back to raw if the framing looks wrong (a proxy that
|
||||
* didn't preserve it).
|
||||
*/
|
||||
export function processDockerStreamChunk(data: Buffer | Uint8Array | string, state: DockerStreamState): string[] {
|
||||
let buffer = Buffer.isBuffer(data) ? data : Buffer.from(data as Uint8Array);
|
||||
if (!state.headersStripped) {
|
||||
state.headerBuffer = Buffer.concat([state.headerBuffer, buffer]);
|
||||
const headerEnd = state.headerBuffer.indexOf('\r\n\r\n');
|
||||
if (headerEnd < 0) return [];
|
||||
|
||||
const headers = state.headerBuffer.slice(0, headerEnd).toString('ascii').toLowerCase();
|
||||
state.isChunked = headers.includes('transfer-encoding: chunked');
|
||||
buffer = state.headerBuffer.slice(headerEnd + 4);
|
||||
state.headerBuffer = Buffer.alloc(0);
|
||||
state.headersStripped = true;
|
||||
}
|
||||
|
||||
const bodyChunks = state.isChunked ? decodeChunkedDockerBody(buffer, state) : [buffer];
|
||||
const output: string[] = [];
|
||||
for (const body of bodyChunks) {
|
||||
if (!body.length) continue;
|
||||
if (!state.multiplexed) {
|
||||
output.push(body.toString('utf-8'));
|
||||
continue;
|
||||
}
|
||||
|
||||
state.streamBuffer = Buffer.concat([state.streamBuffer, body]);
|
||||
while (state.streamBuffer.length > 0) {
|
||||
if (state.streamBuffer.length < 8) break;
|
||||
|
||||
const streamType = state.streamBuffer.readUInt8(0);
|
||||
const frameSize = state.streamBuffer.readUInt32BE(4);
|
||||
if (
|
||||
streamType > 2 ||
|
||||
state.streamBuffer[1] !== 0 ||
|
||||
state.streamBuffer[2] !== 0 ||
|
||||
state.streamBuffer[3] !== 0 ||
|
||||
frameSize > 10 * 1024 * 1024
|
||||
) {
|
||||
// TTY output is normally raw. Fall back to raw output if a Docker
|
||||
// proxy did not preserve the expected multiplexed framing.
|
||||
output.push(state.streamBuffer.toString('utf-8'));
|
||||
state.streamBuffer = Buffer.alloc(0);
|
||||
state.multiplexed = false;
|
||||
break;
|
||||
}
|
||||
|
||||
if (state.streamBuffer.length < 8 + frameSize) break;
|
||||
if (streamType === 1 || streamType === 2) {
|
||||
output.push(state.streamBuffer.slice(8, 8 + frameSize).toString('utf-8'));
|
||||
}
|
||||
state.streamBuffer = state.streamBuffer.slice(8 + frameSize);
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
@@ -4522,6 +4522,15 @@ export async function resizeExec(execId: string, cols: number, rows: number, env
|
||||
}
|
||||
}
|
||||
|
||||
export async function resizeContainer(containerId: string, cols: number, rows: number, envId?: number | null) {
|
||||
try {
|
||||
const response = await dockerFetch(`/containers/${containerId}/resize?h=${rows}&w=${cols}`, { method: 'POST' }, envId);
|
||||
await drainResponse(response);
|
||||
} catch {
|
||||
// Resize may fail if the container is not attached to a TTY, ignore
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Docker connection info for direct WebSocket connections from the client
|
||||
* This is used by the terminal to connect directly to the Docker API
|
||||
@@ -4561,6 +4570,8 @@ declare global {
|
||||
}>) | undefined;
|
||||
var __terminalCreateExec: ((containerId: string, shell: string, user: string, envId?: number) => Promise<string>) | undefined;
|
||||
var __terminalResizeExec: ((execId: string, cols: number, rows: number, envId?: number) => Promise<void>) | undefined;
|
||||
var __terminalGetContainerTty: ((containerId: string, envId?: number) => Promise<boolean>) | undefined;
|
||||
var __terminalResizeContainer: ((containerId: string, cols: number, rows: number, envId?: number) => Promise<void>) | undefined;
|
||||
}
|
||||
|
||||
globalThis.__terminalGetTarget = async (envId?: number) => {
|
||||
@@ -4598,6 +4609,15 @@ globalThis.__terminalResizeExec = async (execId, cols, rows, envId) => {
|
||||
await resizeExec(execId, cols, rows, envId);
|
||||
};
|
||||
|
||||
globalThis.__terminalGetContainerTty = async (containerId, envId) => {
|
||||
const container = await inspectContainer(containerId, envId);
|
||||
return Boolean(container.Config?.Tty);
|
||||
};
|
||||
|
||||
globalThis.__terminalResizeContainer = async (containerId, cols, rows, envId) => {
|
||||
await resizeContainer(containerId, cols, rows, envId);
|
||||
};
|
||||
|
||||
// System disk usage
|
||||
export async function getDiskUsage(envId?: number | null) {
|
||||
return dockerJsonRequest('/system/df', {}, envId);
|
||||
|
||||
Reference in new issue
Block a user