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 <[email protected]>
This commit is contained in:
1 parent
560b21e03f
commit
0ca3b86f82
10 files changed
+688
-208
No files matched your search
@@ -2,7 +2,7 @@
|
|||||||
* Production Server Wrapper
|
* Production Server Wrapper
|
||||||
*
|
*
|
||||||
* Wraps @sveltejs/adapter-node's output with WebSocket support for:
|
* Wraps @sveltejs/adapter-node's output with WebSocket support for:
|
||||||
* - Terminal exec connections (xterm.js ↔ Docker exec)
|
* - Terminal connections (xterm.js ↔ Docker exec/attach)
|
||||||
* - Hawser Edge agent connections
|
* - Hawser Edge agent connections
|
||||||
*
|
*
|
||||||
* Usage: node ./server.js
|
* Usage: node ./server.js
|
||||||
@@ -235,11 +235,139 @@ wss.on('connection', (ws, req) => {
|
|||||||
* - __terminalGetTarget(envId) - resolves connection info from environment
|
* - __terminalGetTarget(envId) - resolves connection info from environment
|
||||||
* - __terminalCreateExec(containerId, shell, user, envId) - creates exec via Docker API
|
* - __terminalCreateExec(containerId, shell, user, envId) - creates exec via Docker API
|
||||||
* - __terminalResizeExec(execId, cols, rows, envId) - resizes exec terminal
|
* - __terminalResizeExec(execId, cols, rows, envId) - resizes exec terminal
|
||||||
|
* - __terminalGetContainerTty(containerId, envId) - reads the container TTY setting
|
||||||
|
* - __terminalResizeContainer(containerId, cols, rows, envId) - resizes an attached TTY
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// NOTE: createDockerStreamState/decodeChunkedDockerBody/processDockerStreamChunk below
|
||||||
|
// mirror src/lib/server/docker-stream-core.ts (the tested source of truth). server.js
|
||||||
|
// runs against ./build and cannot import the TS core at runtime, so the logic is kept
|
||||||
|
// inline here; keep the two in sync (vite.config.ts imports the core directly).
|
||||||
|
function buildDockerStreamRequest(path, target, body = '') {
|
||||||
|
const host = target.host || 'localhost';
|
||||||
|
const tokenHeader = target.hawserToken ? `X-Hawser-Token: ${target.hawserToken}\r\n` : '';
|
||||||
|
return (
|
||||||
|
`POST ${path} HTTP/1.1\r\n` +
|
||||||
|
`Host: ${host}\r\n` +
|
||||||
|
`Content-Type: application/json\r\n` +
|
||||||
|
`${tokenHeader}` +
|
||||||
|
`Connection: Upgrade\r\n` +
|
||||||
|
`Upgrade: tcp\r\n` +
|
||||||
|
`Content-Length: ${Buffer.byteLength(body)}\r\n` +
|
||||||
|
`\r\n` +
|
||||||
|
body
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDockerStreamState(multiplexed = false) {
|
||||||
|
return {
|
||||||
|
headersStripped: false,
|
||||||
|
isChunked: false,
|
||||||
|
headerBuffer: Buffer.alloc(0),
|
||||||
|
chunkBuffer: Buffer.alloc(0),
|
||||||
|
chunkSize: null,
|
||||||
|
chunkEnded: false,
|
||||||
|
multiplexed,
|
||||||
|
streamBuffer: Buffer.alloc(0)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeChunkedDockerBody(data, state) {
|
||||||
|
state.chunkBuffer = Buffer.concat([state.chunkBuffer, data]);
|
||||||
|
const chunks = [];
|
||||||
|
|
||||||
|
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) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function processDockerStreamChunk(data, state) {
|
||||||
|
let buffer = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
||||||
|
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 = [];
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
async function handleTerminalConnection(ws, url, connId) {
|
async function handleTerminalConnection(ws, url, connId) {
|
||||||
const pathParts = url.pathname.split('/');
|
const pathParts = url.pathname.split('/');
|
||||||
const containerIdIndex = pathParts.indexOf('containers') + 1;
|
const containerIdIndex = pathParts.indexOf('containers') + 1;
|
||||||
const containerId = pathParts[containerIdIndex];
|
const containerId = pathParts[containerIdIndex];
|
||||||
|
const mode = url.searchParams.get('mode') === 'attach' ? 'attach' : 'exec';
|
||||||
const shell = url.searchParams.get('shell') || '/bin/sh';
|
const shell = url.searchParams.get('shell') || '/bin/sh';
|
||||||
const user = url.searchParams.get('user') || 'root';
|
const user = url.searchParams.get('user') || 'root';
|
||||||
const envIdParam = url.searchParams.get('envId');
|
const envIdParam = url.searchParams.get('envId');
|
||||||
@@ -294,23 +422,45 @@ async function handleTerminalConnection(ws, url, connId) {
|
|||||||
target = { type: 'socket', connectionType: 'socket', socketPath: process.env.DOCKER_SOCKET || '/var/run/docker.sock' };
|
target = { type: 'socket', connectionType: 'socket', socketPath: process.env.DOCKER_SOCKET || '/var/run/docker.sock' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle Hawser Edge mode - relay through agent WebSocket
|
// Hawser Edge currently exposes an exec-only terminal protocol.
|
||||||
if (target.connectionType === 'hawser-edge') {
|
if (target.connectionType === 'hawser-edge') {
|
||||||
|
if (mode === 'attach') {
|
||||||
|
ws.send(JSON.stringify({ type: 'error', message: 'Container attach is not supported for Edge environments' }));
|
||||||
|
ws.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
handleEdgeExec(ws, connId, containerId, shell, user, target.environmentId);
|
handleEdgeExec(ws, connId, containerId, shell, user, target.environmentId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create exec instance via SvelteKit app (handles all connection types)
|
let execId = null;
|
||||||
let execId;
|
let streamPath;
|
||||||
if (typeof globalThis.__terminalCreateExec === 'function') {
|
let streamBody = '';
|
||||||
execId = await globalThis.__terminalCreateExec(containerId, shell, user, envId);
|
let multiplexed = false;
|
||||||
|
if (mode === 'attach') {
|
||||||
|
let containerTty = false;
|
||||||
|
if (typeof globalThis.__terminalGetContainerTty === 'function') {
|
||||||
|
try {
|
||||||
|
containerTty = await globalThis.__terminalGetContainerTty(containerId, envId);
|
||||||
|
} catch {
|
||||||
|
// Keep multiplexing enabled if the TTY setting cannot be read.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
multiplexed = !containerTty;
|
||||||
|
streamPath = `/containers/${encodeURIComponent(containerId)}/attach?stream=1&stdin=1&stdout=1&stderr=1`;
|
||||||
} else {
|
} else {
|
||||||
// Fallback: create exec directly via local socket
|
// Create exec instance via SvelteKit app (handles all connection types)
|
||||||
execId = await createExecLocal(containerId, shell, user, target.socketPath || '/var/run/docker.sock');
|
if (typeof globalThis.__terminalCreateExec === 'function') {
|
||||||
|
execId = await globalThis.__terminalCreateExec(containerId, shell, user, envId);
|
||||||
|
} else {
|
||||||
|
// Fallback: create exec directly via local socket
|
||||||
|
execId = await createExecLocal(containerId, shell, user, target.socketPath || '/var/run/docker.sock');
|
||||||
|
}
|
||||||
|
streamPath = `/exec/${execId}/start`;
|
||||||
|
streamBody = JSON.stringify({ Detach: false, Tty: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open raw bidirectional stream to Docker for the exec session
|
// Open raw bidirectional stream to Docker for the attach or exec session.
|
||||||
const startBody = JSON.stringify({ Detach: false, Tty: true });
|
|
||||||
let dockerStream;
|
let dockerStream;
|
||||||
|
|
||||||
if (target.type === 'socket') {
|
if (target.type === 'socket') {
|
||||||
@@ -333,45 +483,16 @@ async function handleTerminalConnection(ws, url, connId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dockerStream.on('connect', () => {
|
dockerStream.on('connect', () => {
|
||||||
const host = target.host || 'localhost';
|
dockerStream.write(buildDockerStreamRequest(streamPath, target, streamBody));
|
||||||
const tokenHeader = target.hawserToken ? `X-Hawser-Token: ${target.hawserToken}\r\n` : '';
|
|
||||||
dockerStream.write(
|
|
||||||
`POST /exec/${execId}/start HTTP/1.1\r\n` +
|
|
||||||
`Host: ${host}\r\n` +
|
|
||||||
`Content-Type: application/json\r\n` +
|
|
||||||
`${tokenHeader}` +
|
|
||||||
`Connection: Upgrade\r\n` +
|
|
||||||
`Upgrade: tcp\r\n` +
|
|
||||||
`Content-Length: ${Buffer.byteLength(startBody)}\r\n` +
|
|
||||||
`\r\n` +
|
|
||||||
startBody
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let headersStripped = false;
|
const streamState = createDockerStreamState(multiplexed);
|
||||||
let isChunked = false;
|
|
||||||
|
|
||||||
dockerStream.on('data', (data) => {
|
dockerStream.on('data', (data) => {
|
||||||
if (ws.readyState !== 1) return;
|
if (ws.readyState !== 1) return;
|
||||||
|
|
||||||
let text = data.toString('utf-8');
|
for (const text of processDockerStreamChunk(data, streamState)) {
|
||||||
if (!headersStripped) {
|
if (text) ws.send(JSON.stringify({ type: 'output', data: text }));
|
||||||
if (text.toLowerCase().includes('transfer-encoding: chunked')) {
|
|
||||||
isChunked = true;
|
|
||||||
}
|
|
||||||
const headerEnd = text.indexOf('\r\n\r\n');
|
|
||||||
if (headerEnd > -1) {
|
|
||||||
text = text.slice(headerEnd + 4);
|
|
||||||
headersStripped = true;
|
|
||||||
} else if (text.startsWith('HTTP/')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (isChunked && text) {
|
|
||||||
text = text.replace(/^[0-9a-fA-F]+\r\n/gm, '').replace(/\r\n$/g, '');
|
|
||||||
}
|
|
||||||
if (text) {
|
|
||||||
ws.send(JSON.stringify({ type: 'output', data: text }));
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -396,19 +517,34 @@ async function handleTerminalConnection(ws, url, connId) {
|
|||||||
if (msg.type === 'input' && msg.data) {
|
if (msg.type === 'input' && msg.data) {
|
||||||
dockerStream.write(msg.data);
|
dockerStream.write(msg.data);
|
||||||
} else if (msg.type === 'resize' && msg.cols && msg.rows) {
|
} else if (msg.type === 'resize' && msg.cols && msg.rows) {
|
||||||
// Use SvelteKit's resize function if available (works for all connection types)
|
if (mode === 'attach') {
|
||||||
if (typeof globalThis.__terminalResizeExec === 'function') {
|
if (typeof globalThis.__terminalResizeContainer === 'function') {
|
||||||
globalThis.__terminalResizeExec(execId, msg.cols, msg.rows, envId).catch(() => {});
|
globalThis.__terminalResizeContainer(containerId, msg.cols, msg.rows, envId).catch(() => {});
|
||||||
|
} else if (target.type === 'socket') {
|
||||||
|
const socketPath = target.socketPath || '/var/run/docker.sock';
|
||||||
|
const resizeReq = httpRequest({
|
||||||
|
socketPath,
|
||||||
|
path: `/containers/${encodeURIComponent(containerId)}/resize?h=${msg.rows}&w=${msg.cols}`,
|
||||||
|
method: 'POST',
|
||||||
|
}, () => {});
|
||||||
|
resizeReq.on('error', () => {});
|
||||||
|
resizeReq.end();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Fallback: resize via local socket
|
// Use SvelteKit's resize function if available (works for all connection types)
|
||||||
const socketPath = target.socketPath || '/var/run/docker.sock';
|
if (typeof globalThis.__terminalResizeExec === 'function') {
|
||||||
const resizeReq = httpRequest({
|
globalThis.__terminalResizeExec(execId, msg.cols, msg.rows, envId).catch(() => {});
|
||||||
socketPath,
|
} else {
|
||||||
path: `/exec/${execId}/resize?h=${msg.rows}&w=${msg.cols}`,
|
// Fallback: resize via local socket
|
||||||
method: 'POST',
|
const socketPath = target.socketPath || '/var/run/docker.sock';
|
||||||
}, () => {});
|
const resizeReq = httpRequest({
|
||||||
resizeReq.on('error', () => {});
|
socketPath,
|
||||||
resizeReq.end();
|
path: `/exec/${execId}/resize?h=${msg.rows}&w=${msg.cols}`,
|
||||||
|
method: 'POST',
|
||||||
|
}, () => {});
|
||||||
|
resizeReq.on('error', () => {});
|
||||||
|
resizeReq.end();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|||||||
@@ -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
|
* Get Docker connection info for direct WebSocket connections from the client
|
||||||
* This is used by the terminal to connect directly to the Docker API
|
* This is used by the terminal to connect directly to the Docker API
|
||||||
@@ -4561,6 +4570,8 @@ declare global {
|
|||||||
}>) | undefined;
|
}>) | undefined;
|
||||||
var __terminalCreateExec: ((containerId: string, shell: string, user: string, envId?: number) => Promise<string>) | 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 __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) => {
|
globalThis.__terminalGetTarget = async (envId?: number) => {
|
||||||
@@ -4598,6 +4609,15 @@ globalThis.__terminalResizeExec = async (execId, cols, rows, envId) => {
|
|||||||
await resizeExec(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
|
// System disk usage
|
||||||
export async function getDiskUsage(envId?: number | null) {
|
export async function getDiskUsage(envId?: number | null) {
|
||||||
return dockerJsonRequest('/system/df', {}, envId);
|
return dockerJsonRequest('/system/df', {}, envId);
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ export interface NewerVersion {
|
|||||||
digest?: string;
|
digest?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Terminal connection mode: an interactive `docker exec` shell, or `docker attach` to PID 1. */
|
||||||
|
export type TerminalMode = 'exec' | 'attach';
|
||||||
|
|
||||||
export interface ContainerInfo {
|
export interface ContainerInfo {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -67,7 +67,8 @@
|
|||||||
Copy,
|
Copy,
|
||||||
Loader2,
|
Loader2,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Tag
|
Tag,
|
||||||
|
Unplug
|
||||||
} from 'lucide-svelte';
|
} from 'lucide-svelte';
|
||||||
import { broom } from '@lucide/lab';
|
import { broom } from '@lucide/lab';
|
||||||
import { copyToClipboard } from '$lib/utils/clipboard';
|
import { copyToClipboard } from '$lib/utils/clipboard';
|
||||||
@@ -83,7 +84,7 @@
|
|||||||
import BatchOperationModal from '$lib/components/BatchOperationModal.svelte';
|
import BatchOperationModal from '$lib/components/BatchOperationModal.svelte';
|
||||||
import VersionUpdateBadge from '$lib/components/VersionUpdateBadge.svelte';
|
import VersionUpdateBadge from '$lib/components/VersionUpdateBadge.svelte';
|
||||||
import VersionUpdateModal from '$lib/components/VersionUpdateModal.svelte';
|
import VersionUpdateModal from '$lib/components/VersionUpdateModal.svelte';
|
||||||
import type { ContainerInfo } from '$lib/types';
|
import type { ContainerInfo, TerminalMode } from '$lib/types';
|
||||||
import { EmptyState, NoEnvironment } from '$lib/components/ui/empty-state';
|
import { EmptyState, NoEnvironment } from '$lib/components/ui/empty-state';
|
||||||
import { currentEnvironment, environments, appendEnvParam, clearStaleEnvironment } from '$lib/stores/environment';
|
import { currentEnvironment, environments, appendEnvParam, clearStaleEnvironment } from '$lib/stores/environment';
|
||||||
import { containerStore } from '$lib/stores/containers';
|
import { containerStore } from '$lib/stores/containers';
|
||||||
@@ -258,10 +259,12 @@
|
|||||||
containerName: string;
|
containerName: string;
|
||||||
shell: string;
|
shell: string;
|
||||||
user: string;
|
user: string;
|
||||||
|
mode: TerminalMode;
|
||||||
}
|
}
|
||||||
let activeTerminals = $state<ActiveTerminal[]>([]);
|
let activeTerminals = $state<ActiveTerminal[]>([]);
|
||||||
let currentTerminalContainerId = $state<string | null>(null);
|
let currentTerminalContainerId = $state<string | null>(null);
|
||||||
let terminalPopoverStates = $state<Record<string, boolean>>({});
|
let terminalPopoverStates = $state<Record<string, boolean>>({});
|
||||||
|
let terminalMode = $state<TerminalMode>('exec');
|
||||||
let terminalShell = $state('/bin/bash');
|
let terminalShell = $state('/bin/bash');
|
||||||
let terminalUser = $state('root');
|
let terminalUser = $state('root');
|
||||||
let terminalCustomUser = $state('');
|
let terminalCustomUser = $state('');
|
||||||
@@ -1121,19 +1124,23 @@
|
|||||||
const savedUser = getSavedUser(container.id);
|
const savedUser = getSavedUser(container.id);
|
||||||
terminalUser = savedUser ?? 'root';
|
terminalUser = savedUser ?? 'root';
|
||||||
terminalCustomUsers = getCustomUsers();
|
terminalCustomUsers = getCustomUsers();
|
||||||
|
// Default a fresh session to exec so a prior container's Attach choice
|
||||||
|
// (terminalMode is shared) doesn't carry over to this one.
|
||||||
|
terminalMode = 'exec';
|
||||||
// Show popover to configure new terminal
|
// Show popover to configure new terminal
|
||||||
terminalPopoverStates[container.id] = true;
|
terminalPopoverStates[container.id] = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function startTerminal(container: ContainerInfo) {
|
function startTerminal(container: ContainerInfo) {
|
||||||
saveUserForContainer(container.id, terminalUser);
|
if (terminalMode === 'exec') saveUserForContainer(container.id, terminalUser);
|
||||||
terminalCustomUsers = getCustomUsers();
|
terminalCustomUsers = getCustomUsers();
|
||||||
const terminal: ActiveTerminal = {
|
const terminal: ActiveTerminal = {
|
||||||
containerId: container.id,
|
containerId: container.id,
|
||||||
containerName: container.name,
|
containerName: container.name,
|
||||||
shell: terminalShell,
|
shell: terminalShell,
|
||||||
user: terminalUser
|
user: terminalUser,
|
||||||
|
mode: terminalMode
|
||||||
};
|
};
|
||||||
activeTerminals = [...activeTerminals, terminal];
|
activeTerminals = [...activeTerminals, terminal];
|
||||||
currentTerminalContainerId = container.id;
|
currentTerminalContainerId = container.id;
|
||||||
@@ -1434,7 +1441,7 @@
|
|||||||
onkeydown={(e) => e.key === 'Escape' && (searchQuery = '')}
|
onkeydown={(e) => e.key === 'Escape' && (searchQuery = '')}
|
||||||
class="pl-8 h-8 w-48 text-sm"
|
class="pl-8 h-8 w-48 text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<!-- Status filter (multi-select). The synthetic 'update-available'
|
<!-- Status filter (multi-select). The synthetic 'update-available'
|
||||||
entry appears once at least one container has a pending update,
|
entry appears once at least one container has a pending update,
|
||||||
and ANDs with selected real states (#1063). -->
|
and ANDs with selected real states (#1063). -->
|
||||||
@@ -2160,7 +2167,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<Popover.Root open={terminalPopoverStates[container.id] ?? false} onOpenChange={(open) => {
|
<Popover.Root open={terminalPopoverStates[container.id] ?? false} onOpenChange={(open) => {
|
||||||
terminalPopoverStates[container.id] = open;
|
terminalPopoverStates[container.id] = open;
|
||||||
if (open) detectContainerShells(container.id);
|
if (open && terminalMode === 'exec') detectContainerShells(container.id);
|
||||||
}}>
|
}}>
|
||||||
<Popover.Trigger
|
<Popover.Trigger
|
||||||
onclick={(e: MouseEvent) => e.stopPropagation()}
|
onclick={(e: MouseEvent) => e.stopPropagation()}
|
||||||
@@ -2175,12 +2182,12 @@
|
|||||||
<span class="text-xs font-medium truncate" title={container.name}>{container.name}</span>
|
<span class="text-xs font-medium truncate" title={container.name}>{container.name}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{#if detectingShellsFor === container.id}
|
{#if terminalMode === 'exec' && detectingShellsFor === container.id}
|
||||||
<div class="p-4 text-center">
|
<div class="p-4 text-center">
|
||||||
<Loader2 class="w-5 h-5 mx-auto mb-2 text-muted-foreground animate-spin" />
|
<Loader2 class="w-5 h-5 mx-auto mb-2 text-muted-foreground animate-spin" />
|
||||||
<p class="text-xs text-muted-foreground">Detecting shells...</p>
|
<p class="text-xs text-muted-foreground">Detecting shells...</p>
|
||||||
</div>
|
</div>
|
||||||
{:else if !anyShellAvailableFor(container.id)}
|
{:else if terminalMode === 'exec' && !anyShellAvailableFor(container.id)}
|
||||||
<div class="p-4 text-center">
|
<div class="p-4 text-center">
|
||||||
<AlertCircle class="w-5 h-5 mx-auto mb-2 text-amber-500" />
|
<AlertCircle class="w-5 h-5 mx-auto mb-2 text-amber-500" />
|
||||||
<p class="text-xs font-medium text-amber-500">No shell available</p>
|
<p class="text-xs font-medium text-amber-500">No shell available</p>
|
||||||
@@ -2189,76 +2196,105 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div class="p-3 space-y-3">
|
<div class="p-3 space-y-3">
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<Label class="text-xs">Shell</Label>
|
<Label class="text-xs">Mode</Label>
|
||||||
<Select.Root type="single" bind:value={terminalShell}>
|
<Select.Root type="single" value={terminalMode} onValueChange={(value) => {
|
||||||
|
terminalMode = value as TerminalMode;
|
||||||
|
if (terminalMode === 'exec') detectContainerShells(container.id);
|
||||||
|
}}>
|
||||||
<Select.Trigger class="w-full h-8 text-xs">
|
<Select.Trigger class="w-full h-8 text-xs">
|
||||||
<Shell class="w-3 h-3 mr-1.5 text-muted-foreground" />
|
{#if terminalMode === 'attach'}
|
||||||
<span>{shellDetectionCache[container.id]?.allShells.find(o => o.path === terminalShell)?.label || 'Select'}</span>
|
<Unplug class="w-3 h-3 mr-1.5 text-muted-foreground" />
|
||||||
|
Attach to process
|
||||||
|
{:else}
|
||||||
|
<Shell class="w-3 h-3 mr-1.5 text-muted-foreground" />
|
||||||
|
Shell (exec)
|
||||||
|
{/if}
|
||||||
</Select.Trigger>
|
</Select.Trigger>
|
||||||
<Select.Content>
|
<Select.Content>
|
||||||
{#if shellDetectionCache[container.id]}
|
<Select.Item value="exec" label="Shell (exec)">
|
||||||
{#each shellDetectionCache[container.id].allShells as option}
|
<Shell class="w-3 h-3 mr-1.5 text-muted-foreground" />
|
||||||
<Select.Item value={option.path} label={option.label} disabled={!option.available}>
|
Shell (exec)
|
||||||
<Shell class="w-3 h-3 mr-1.5 {option.available ? 'text-green-500' : 'text-muted-foreground/40'}" />
|
</Select.Item>
|
||||||
<span class={option.available ? 'text-foreground' : 'text-muted-foreground/60'}>
|
<Select.Item value="attach" label="Attach to process">
|
||||||
{option.label}
|
<Unplug class="w-3 h-3 mr-1.5 text-muted-foreground" />
|
||||||
{#if !option.available}
|
Attach to process
|
||||||
<span class="text-xs ml-1">(unavailable)</span>
|
</Select.Item>
|
||||||
{/if}
|
</Select.Content>
|
||||||
</span>
|
</Select.Root>
|
||||||
|
</div>
|
||||||
|
{#if terminalMode === 'exec'}
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label class="text-xs">Shell</Label>
|
||||||
|
<Select.Root type="single" bind:value={terminalShell}>
|
||||||
|
<Select.Trigger class="w-full h-8 text-xs">
|
||||||
|
<Shell class="w-3 h-3 mr-1.5 text-muted-foreground" />
|
||||||
|
<span>{shellDetectionCache[container.id]?.allShells.find(o => o.path === terminalShell)?.label || 'Select'}</span>
|
||||||
|
</Select.Trigger>
|
||||||
|
<Select.Content>
|
||||||
|
{#if shellDetectionCache[container.id]}
|
||||||
|
{#each shellDetectionCache[container.id].allShells as option}
|
||||||
|
<Select.Item value={option.path} label={option.label} disabled={!option.available}>
|
||||||
|
<Shell class="w-3 h-3 mr-1.5 {option.available ? 'text-green-500' : 'text-muted-foreground/40'}" />
|
||||||
|
<span class={option.available ? 'text-foreground' : 'text-muted-foreground/60'}>
|
||||||
|
{option.label}
|
||||||
|
{#if !option.available}
|
||||||
|
<span class="text-xs ml-1">(unavailable)</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</Select.Item>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Root>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label class="text-xs">User</Label>
|
||||||
|
<Select.Root type="single" bind:value={terminalUser}>
|
||||||
|
<Select.Trigger class="w-full h-8 text-xs">
|
||||||
|
<User class="w-3 h-3 mr-1.5 text-muted-foreground" />
|
||||||
|
<span>{userOptions.find(o => o.value === terminalUser)?.label || terminalUser || 'Select'}</span>
|
||||||
|
</Select.Trigger>
|
||||||
|
<Select.Content>
|
||||||
|
{#each userOptions as option}
|
||||||
|
<Select.Item value={option.value} label={option.label}>
|
||||||
|
<User class="w-3 h-3 mr-1.5 text-muted-foreground" />
|
||||||
|
{option.label}
|
||||||
</Select.Item>
|
</Select.Item>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{#if terminalCustomUsers.length > 0}
|
||||||
</Select.Content>
|
<div class="h-px bg-border my-1"></div>
|
||||||
</Select.Root>
|
{#each terminalCustomUsers as cu}
|
||||||
</div>
|
<div class="flex items-center group">
|
||||||
<div class="space-y-1.5">
|
<Select.Item value={cu} label={cu} class="flex-1">
|
||||||
<Label class="text-xs">User</Label>
|
<User class="w-3 h-3 mr-1.5 text-muted-foreground" />
|
||||||
<Select.Root type="single" bind:value={terminalUser}>
|
{cu}
|
||||||
<Select.Trigger class="w-full h-8 text-xs">
|
</Select.Item>
|
||||||
<User class="w-3 h-3 mr-1.5 text-muted-foreground" />
|
<button
|
||||||
<span>{userOptions.find(o => o.value === terminalUser)?.label || terminalUser || 'Select'}</span>
|
type="button"
|
||||||
</Select.Trigger>
|
class="p-1 mr-1 opacity-0 group-hover:opacity-100 hover:text-destructive transition-opacity"
|
||||||
<Select.Content>
|
onclick={(e) => { e.stopPropagation(); e.preventDefault(); removeCustomUser(cu); terminalCustomUsers = getCustomUsers(); if (terminalUser === cu) { terminalUser = 'root'; } }}
|
||||||
{#each userOptions as option}
|
title="Remove user"
|
||||||
<Select.Item value={option.value} label={option.label}>
|
>
|
||||||
<User class="w-3 h-3 mr-1.5 text-muted-foreground" />
|
<Trash2 class="w-3 h-3" />
|
||||||
{option.label}
|
</button>
|
||||||
</Select.Item>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{#if terminalCustomUsers.length > 0}
|
{/if}
|
||||||
<div class="h-px bg-border my-1"></div>
|
<div class="h-px bg-border my-1"></div>
|
||||||
{#each terminalCustomUsers as cu}
|
<div class="px-2 py-1">
|
||||||
<div class="flex items-center group">
|
<Input
|
||||||
<Select.Item value={cu} label={cu} class="flex-1">
|
class="h-7 text-xs"
|
||||||
<User class="w-3 h-3 mr-1.5 text-muted-foreground" />
|
placeholder="Add user... (Enter)"
|
||||||
{cu}
|
bind:value={terminalCustomUser}
|
||||||
</Select.Item>
|
onkeydown={(e) => { e.stopPropagation(); if (e.key === 'Enter' && terminalCustomUser.trim()) { const u = terminalCustomUser.trim(); terminalUser = u; saveUserForContainer(container.id, u); terminalCustomUsers = getCustomUsers(); terminalCustomUser = ''; } }}
|
||||||
<button
|
onclick={(e) => e.stopPropagation()}
|
||||||
type="button"
|
/>
|
||||||
class="p-1 mr-1 opacity-0 group-hover:opacity-100 hover:text-destructive transition-opacity"
|
</div>
|
||||||
onclick={(e) => { e.stopPropagation(); e.preventDefault(); removeCustomUser(cu); terminalCustomUsers = getCustomUsers(); if (terminalUser === cu) { terminalUser = 'root'; } }}
|
</Select.Content>
|
||||||
title="Remove user"
|
</Select.Root>
|
||||||
>
|
</div>
|
||||||
<Trash2 class="w-3 h-3" />
|
{/if}
|
||||||
</button>
|
<Button size="sm" class="w-full h-7 text-xs" onclick={() => startTerminal(container)}>
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
<div class="h-px bg-border my-1"></div>
|
|
||||||
<div class="px-2 py-1">
|
|
||||||
<Input
|
|
||||||
class="h-7 text-xs"
|
|
||||||
placeholder="Add user... (Enter)"
|
|
||||||
bind:value={terminalCustomUser}
|
|
||||||
onkeydown={(e) => { e.stopPropagation(); if (e.key === 'Enter' && terminalCustomUser.trim()) { const u = terminalCustomUser.trim(); terminalUser = u; saveUserForContainer(container.id, u); terminalCustomUsers = getCustomUsers(); terminalCustomUser = ''; } }}
|
|
||||||
onclick={(e) => e.stopPropagation()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Select.Content>
|
|
||||||
</Select.Root>
|
|
||||||
</div>
|
|
||||||
<Button size="sm" class="w-full h-7 text-xs" onclick={() => startTerminal(container)}>
|
|
||||||
<Terminal class="w-3 h-3" />
|
<Terminal class="w-3 h-3" />
|
||||||
Connect
|
Connect
|
||||||
</Button>
|
</Button>
|
||||||
@@ -2436,6 +2472,7 @@
|
|||||||
containerName={t.containerName}
|
containerName={t.containerName}
|
||||||
shell={t.shell}
|
shell={t.shell}
|
||||||
user={t.user}
|
user={t.user}
|
||||||
|
mode={t.mode}
|
||||||
visible={t.containerId === currentTerminalContainerId}
|
visible={t.containerId === currentTerminalContainerId}
|
||||||
envId={envId}
|
envId={envId}
|
||||||
fillHeight={true}
|
fillHeight={true}
|
||||||
@@ -2473,6 +2510,7 @@
|
|||||||
containerName={t.containerName}
|
containerName={t.containerName}
|
||||||
shell={t.shell}
|
shell={t.shell}
|
||||||
user={t.user}
|
user={t.user}
|
||||||
|
mode={t.mode}
|
||||||
visible={t.containerId === currentTerminalContainerId}
|
visible={t.containerId === currentTerminalContainerId}
|
||||||
envId={envId}
|
envId={envId}
|
||||||
onClose={() => closeTerminal(t.containerId)}
|
onClose={() => closeTerminal(t.containerId)}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
import { Search, ChevronDown, Terminal as TerminalIcon, Unplug, RefreshCw, Trash2, Copy, Shell, User, Loader2, AlertCircle } from 'lucide-svelte';
|
import { Search, ChevronDown, Terminal as TerminalIcon, Unplug, RefreshCw, Trash2, Copy, Shell, User, Loader2, AlertCircle } from 'lucide-svelte';
|
||||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
import ContainerIcon from '$lib/components/ContainerIcon.svelte';
|
import ContainerIcon from '$lib/components/ContainerIcon.svelte';
|
||||||
import type { ContainerInfo } from '$lib/types';
|
import type { ContainerInfo, TerminalMode } from '$lib/types';
|
||||||
import { currentEnvironment, environments, appendEnvParam } from '$lib/stores/environment';
|
import { currentEnvironment, environments, appendEnvParam } from '$lib/stores/environment';
|
||||||
import Terminal from './Terminal.svelte';
|
import Terminal from './Terminal.svelte';
|
||||||
import { NoEnvironment } from '$lib/components/ui/empty-state';
|
import { NoEnvironment } from '$lib/components/ui/empty-state';
|
||||||
@@ -34,6 +34,7 @@
|
|||||||
let detectingShells = $state(false);
|
let detectingShells = $state(false);
|
||||||
|
|
||||||
// Shell/user options
|
// Shell/user options
|
||||||
|
let terminalMode = $state<TerminalMode>('exec');
|
||||||
let selectedShell = $state('/bin/bash');
|
let selectedShell = $state('/bin/bash');
|
||||||
let selectedUser = $state('root');
|
let selectedUser = $state('root');
|
||||||
let customUserInput = $state('');
|
let customUserInput = $state('');
|
||||||
@@ -193,7 +194,7 @@
|
|||||||
|
|
||||||
// Watch for shell/user changes while connected and trigger reconnect
|
// Watch for shell/user changes while connected and trigger reconnect
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (selectedContainer && connected && terminalComponent) {
|
if (terminalMode === 'exec' && selectedContainer && connected && terminalComponent) {
|
||||||
if (selectedShell !== prevShell || committedUser !== prevUser) {
|
if (selectedShell !== prevShell || committedUser !== prevUser) {
|
||||||
terminalComponent.reconnect();
|
terminalComponent.reconnect();
|
||||||
}
|
}
|
||||||
@@ -215,6 +216,9 @@
|
|||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
customUsers = getCustomUsers();
|
customUsers = getCustomUsers();
|
||||||
|
if ($page.url.searchParams.get('mode') === 'attach') {
|
||||||
|
terminalMode = 'attach';
|
||||||
|
}
|
||||||
await fetchContainers();
|
await fetchContainers();
|
||||||
|
|
||||||
// Check for container ID in URL query parameter
|
// Check for container ID in URL query parameter
|
||||||
@@ -307,6 +311,30 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Label class="text-sm text-muted-foreground">Mode:</Label>
|
||||||
|
<Select.Root type="single" value={terminalMode} onValueChange={(value) => terminalMode = value as TerminalMode}>
|
||||||
|
<Select.Trigger class="h-9 w-48">
|
||||||
|
{#if terminalMode === 'attach'}
|
||||||
|
<Unplug class="w-4 h-4 mr-2 text-muted-foreground" />
|
||||||
|
<span>Attach to process</span>
|
||||||
|
{:else}
|
||||||
|
<Shell class="w-4 h-4 mr-2 text-muted-foreground" />
|
||||||
|
<span>Shell (exec)</span>
|
||||||
|
{/if}
|
||||||
|
</Select.Trigger>
|
||||||
|
<Select.Content>
|
||||||
|
<Select.Item value="exec" label="Shell (exec)">
|
||||||
|
<Shell class="w-4 h-4 mr-2 text-muted-foreground" />
|
||||||
|
Shell (exec)
|
||||||
|
</Select.Item>
|
||||||
|
<Select.Item value="attach" label="Attach to process">
|
||||||
|
<Unplug class="w-4 h-4 mr-2 text-muted-foreground" />
|
||||||
|
Attach to process
|
||||||
|
</Select.Item>
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Root>
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if selectedContainer}
|
{#if selectedContainer}
|
||||||
<Button size="sm" variant="ghost" onclick={clearSelection} class="h-9 px-3 text-sm text-muted-foreground hover:text-foreground">
|
<Button size="sm" variant="ghost" onclick={clearSelection} class="h-9 px-3 text-sm text-muted-foreground hover:text-foreground">
|
||||||
@@ -315,7 +343,8 @@
|
|||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Shell selector - always visible -->
|
<!-- Shell selector - only used by exec mode -->
|
||||||
|
{#if terminalMode === 'exec'}
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Label class="text-sm text-muted-foreground">Shell:</Label>
|
<Label class="text-sm text-muted-foreground">Shell:</Label>
|
||||||
{#if detectingShells}
|
{#if detectingShells}
|
||||||
@@ -374,7 +403,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- User selector - always visible -->
|
<!-- User selector - only used by exec mode -->
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Label class="text-sm text-muted-foreground">User:</Label>
|
<Label class="text-sm text-muted-foreground">User:</Label>
|
||||||
<Select.Root type="single" bind:value={selectedUser} onValueChange={onUserSelectChange}>
|
<Select.Root type="single" bind:value={selectedUser} onValueChange={onUserSelectChange}>
|
||||||
@@ -422,6 +451,7 @@
|
|||||||
</Select.Content>
|
</Select.Content>
|
||||||
</Select.Root>
|
</Select.Root>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Shell output - full height -->
|
<!-- Shell output - full height -->
|
||||||
@@ -433,14 +463,14 @@
|
|||||||
<p>Select a container to open shell</p>
|
<p>Select a container to open shell</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else if detectingShells}
|
{:else if terminalMode === 'exec' && detectingShells}
|
||||||
<div class="flex items-center justify-center h-full text-muted-foreground">
|
<div class="flex items-center justify-center h-full text-muted-foreground">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<Loader2 class="w-12 h-12 mx-auto mb-3 opacity-50 animate-spin" />
|
<Loader2 class="w-12 h-12 mx-auto mb-3 opacity-50 animate-spin" />
|
||||||
<p>Detecting available shells...</p>
|
<p>Detecting available shells...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else if !anyShellAvailable}
|
{:else if terminalMode === 'exec' && !anyShellAvailable}
|
||||||
<div class="flex items-center justify-center h-full text-muted-foreground">
|
<div class="flex items-center justify-center h-full text-muted-foreground">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<AlertCircle class="w-12 h-12 mx-auto mb-3 opacity-50 text-amber-500" />
|
<AlertCircle class="w-12 h-12 mx-auto mb-3 opacity-50 text-amber-500" />
|
||||||
@@ -499,13 +529,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 min-h-0 w-full">
|
<div class="flex-1 min-h-0 w-full">
|
||||||
{#key `${selectedContainer.id}-${selectedShell}-${committedUser}`}
|
{#key `${selectedContainer.id}-${terminalMode}-${terminalMode === 'exec' ? selectedShell : ''}-${terminalMode === 'exec' ? committedUser : ''}`}
|
||||||
<Terminal
|
<Terminal
|
||||||
bind:this={terminalComponent}
|
bind:this={terminalComponent}
|
||||||
containerId={selectedContainer.id}
|
containerId={selectedContainer.id}
|
||||||
containerName={selectedContainer.name}
|
containerName={selectedContainer.name}
|
||||||
shell={selectedShell}
|
shell={selectedShell}
|
||||||
user={committedUser}
|
user={committedUser}
|
||||||
|
mode={terminalMode}
|
||||||
{envId}
|
{envId}
|
||||||
fontSize={terminalFontSize}
|
fontSize={terminalFontSize}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { copyToClipboard } from '$lib/utils/clipboard';
|
import { copyToClipboard } from '$lib/utils/clipboard';
|
||||||
import { themeStore } from '$lib/stores/theme';
|
import { themeStore } from '$lib/stores/theme';
|
||||||
import { getMonospaceFont } from '$lib/themes';
|
import { getMonospaceFont } from '$lib/themes';
|
||||||
|
import type { TerminalMode } from '$lib/types';
|
||||||
|
|
||||||
// Dynamic imports for browser-only xterm
|
// Dynamic imports for browser-only xterm
|
||||||
let TerminalClass: any;
|
let TerminalClass: any;
|
||||||
@@ -16,11 +17,12 @@
|
|||||||
shell: string;
|
shell: string;
|
||||||
user: string;
|
user: string;
|
||||||
envId: number | null;
|
envId: number | null;
|
||||||
|
mode?: TerminalMode;
|
||||||
fontSize?: number;
|
fontSize?: number;
|
||||||
autoConnect?: boolean;
|
autoConnect?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { containerId, containerName, shell, user, envId, fontSize = 13, autoConnect = true }: Props = $props();
|
let { containerId, containerName, shell, user, envId, mode = 'exec', fontSize = 13, autoConnect = true }: Props = $props();
|
||||||
|
|
||||||
let terminal: any = null;
|
let terminal: any = null;
|
||||||
let fitAddon: any = null;
|
let fitAddon: any = null;
|
||||||
@@ -178,13 +180,24 @@
|
|||||||
// In production, connect to the same port as the app
|
// In production, connect to the same port as the app
|
||||||
const isDev = import.meta.env.DEV;
|
const isDev = import.meta.env.DEV;
|
||||||
const portPart = isDev ? ':5174' : (window.location.port ? `:${window.location.port}` : '');
|
const portPart = isDev ? ':5174' : (window.location.port ? `:${window.location.port}` : '');
|
||||||
let wsUrl = `${protocol}//${wsHost}${portPart}/api/containers/${containerId}/exec?shell=${encodeURIComponent(shell)}&user=${encodeURIComponent(user)}`;
|
const params = new URLSearchParams({
|
||||||
|
shell,
|
||||||
|
user
|
||||||
|
});
|
||||||
|
if (mode === 'attach') {
|
||||||
|
params.set('mode', 'attach');
|
||||||
|
}
|
||||||
|
let wsUrl = `${protocol}//${wsHost}${portPart}/api/containers/${containerId}/exec?${params}`;
|
||||||
if (envId) {
|
if (envId) {
|
||||||
wsUrl += `&envId=${envId}`;
|
wsUrl += `&envId=${envId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
terminal.writeln(`\x1b[90mConnecting to ${containerName}...\x1b[0m`);
|
terminal.writeln(`\x1b[90m${mode === 'attach' ? 'Attaching to' : 'Connecting to'} ${containerName}...\x1b[0m`);
|
||||||
terminal.writeln(`\x1b[90mShell: ${shell}, User: ${user || 'default'}\x1b[0m`);
|
if (mode === 'attach') {
|
||||||
|
terminal.writeln('\x1b[90mDocker attach: container main process\x1b[0m');
|
||||||
|
} else {
|
||||||
|
terminal.writeln(`\x1b[90mShell: ${shell}, User: ${user || 'default'}\x1b[0m`);
|
||||||
|
}
|
||||||
terminal.writeln('');
|
terminal.writeln('');
|
||||||
|
|
||||||
ws = new WebSocket(wsUrl);
|
ws = new WebSocket(wsUrl);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { X, GripHorizontal, RefreshCw, Copy, Trash2 } from 'lucide-svelte';
|
import { X, GripHorizontal, RefreshCw, Copy, Trash2 } from 'lucide-svelte';
|
||||||
import * as Select from '$lib/components/ui/select';
|
import * as Select from '$lib/components/ui/select';
|
||||||
|
import type { TerminalMode } from '$lib/types';
|
||||||
import Terminal from './Terminal.svelte';
|
import Terminal from './Terminal.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -9,13 +10,14 @@
|
|||||||
containerName: string;
|
containerName: string;
|
||||||
shell: string;
|
shell: string;
|
||||||
user: string;
|
user: string;
|
||||||
|
mode?: TerminalMode;
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
envId: number | null;
|
envId: number | null;
|
||||||
fillHeight?: boolean;
|
fillHeight?: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { containerId, containerName, shell, user, visible, envId, fillHeight = false, onClose }: Props = $props();
|
let { containerId, containerName, shell, user, mode = 'exec', visible, envId, fillHeight = false, onClose }: Props = $props();
|
||||||
|
|
||||||
let terminalComponent: ReturnType<typeof Terminal>;
|
let terminalComponent: ReturnType<typeof Terminal>;
|
||||||
let panelRef: HTMLDivElement;
|
let panelRef: HTMLDivElement;
|
||||||
@@ -167,7 +169,7 @@
|
|||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="flex items-center justify-between px-3 py-1.5 border-b border-zinc-800 bg-zinc-900/50">
|
<div class="flex items-center justify-between px-3 py-1.5 border-b border-zinc-800 bg-zinc-900/50">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="text-xs text-zinc-400">Terminal:</span>
|
<span class="text-xs text-zinc-400">{mode === 'attach' ? 'Attach:' : 'Terminal:'}</span>
|
||||||
<span class="text-xs text-zinc-200 font-medium">{containerName}</span>
|
<span class="text-xs text-zinc-200 font-medium">{containerName}</span>
|
||||||
{#if connected}
|
{#if connected}
|
||||||
<span class="inline-flex items-center gap-1 text-xs text-green-500">
|
<span class="inline-flex items-center gap-1 text-xs text-green-500">
|
||||||
@@ -229,14 +231,17 @@
|
|||||||
|
|
||||||
<!-- Terminal content -->
|
<!-- Terminal content -->
|
||||||
<div class="flex-1 overflow-hidden p-1">
|
<div class="flex-1 overflow-hidden p-1">
|
||||||
<Terminal
|
{#key `${containerId}-${mode}-${shell}-${user}`}
|
||||||
bind:this={terminalComponent}
|
<Terminal
|
||||||
{containerId}
|
bind:this={terminalComponent}
|
||||||
{containerName}
|
{containerId}
|
||||||
{shell}
|
{containerName}
|
||||||
{user}
|
{shell}
|
||||||
{envId}
|
{user}
|
||||||
{fontSize}
|
{mode}
|
||||||
/>
|
{envId}
|
||||||
|
{fontSize}
|
||||||
|
/>
|
||||||
|
{/key}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
// @ts-expect-error -- bun:test is a runtime built-in with no types installed
|
||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
import {
|
||||||
|
createDockerStreamState,
|
||||||
|
decodeChunkedDockerBody,
|
||||||
|
processDockerStreamChunk
|
||||||
|
} from '../src/lib/server/docker-stream-core';
|
||||||
|
|
||||||
|
const HTTP_HEAD = 'HTTP/1.1 200 OK\r\nContent-Type: application/vnd.docker.raw-stream\r\n\r\n';
|
||||||
|
const HTTP_HEAD_CHUNKED = 'HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n';
|
||||||
|
|
||||||
|
/** Build one 8-byte-framed multiplexed frame: type 1=stdout, 2=stderr. */
|
||||||
|
function frame(type: number, payload: string): Buffer {
|
||||||
|
const body = Buffer.from(payload, 'utf-8');
|
||||||
|
const header = Buffer.alloc(8);
|
||||||
|
header.writeUInt8(type, 0);
|
||||||
|
header.writeUInt32BE(body.length, 4);
|
||||||
|
return Buffer.concat([header, body]);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('processDockerStreamChunk - raw (TTY) stream', () => {
|
||||||
|
test('passes raw body through once HTTP headers are stripped', () => {
|
||||||
|
const st = createDockerStreamState(false);
|
||||||
|
const out = processDockerStreamChunk(Buffer.from(HTTP_HEAD + 'hello world'), st);
|
||||||
|
expect(out.join('')).toBe('hello world');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buffers until the full HTTP header terminator arrives', () => {
|
||||||
|
const st = createDockerStreamState(false);
|
||||||
|
// header split across two socket events - nothing emitted until \r\n\r\n seen
|
||||||
|
expect(processDockerStreamChunk(Buffer.from('HTTP/1.1 200 OK\r\n'), st)).toEqual([]);
|
||||||
|
const out = processDockerStreamChunk(Buffer.from('\r\nlate'), st);
|
||||||
|
expect(out.join('')).toBe('late');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('processDockerStreamChunk - multiplexed (non-TTY attach) stream', () => {
|
||||||
|
test('demuxes stdout and stderr frames', () => {
|
||||||
|
const st = createDockerStreamState(true);
|
||||||
|
const data = Buffer.concat([Buffer.from(HTTP_HEAD), frame(1, 'out '), frame(2, 'err')]);
|
||||||
|
expect(processDockerStreamChunk(data, st).join('')).toBe('out err');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reassembles a frame split across two socket events', () => {
|
||||||
|
const st = createDockerStreamState(true);
|
||||||
|
const f = frame(1, 'hello');
|
||||||
|
// deliver the header + first 2 payload bytes, then the rest
|
||||||
|
const first = Buffer.concat([Buffer.from(HTTP_HEAD), f.slice(0, 8 + 2)]);
|
||||||
|
const second = f.slice(8 + 2);
|
||||||
|
expect(processDockerStreamChunk(first, st)).toEqual([]); // incomplete frame -> nothing yet
|
||||||
|
expect(processDockerStreamChunk(second, st).join('')).toBe('hello');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reassembles a frame whose 8-byte header itself is split', () => {
|
||||||
|
const st = createDockerStreamState(true);
|
||||||
|
const f = frame(2, 'x');
|
||||||
|
const first = Buffer.concat([Buffer.from(HTTP_HEAD), f.slice(0, 4)]); // half the header
|
||||||
|
const second = f.slice(4);
|
||||||
|
expect(processDockerStreamChunk(first, st)).toEqual([]);
|
||||||
|
expect(processDockerStreamChunk(second, st).join('')).toBe('x');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to raw when the framing is not valid multiplexed (proxy stripped it)', () => {
|
||||||
|
const st = createDockerStreamState(true);
|
||||||
|
// a byte-0 stream type of 5 is invalid -> raw fallback, and stays raw after
|
||||||
|
const bogus = Buffer.concat([Buffer.from(HTTP_HEAD), Buffer.from([5, 0, 0, 0, 0, 0, 0, 1]), Buffer.from('raw')]);
|
||||||
|
const out = processDockerStreamChunk(bogus, st);
|
||||||
|
expect(out.join('')).toContain('raw');
|
||||||
|
expect(st.multiplexed).toBe(false);
|
||||||
|
// subsequent bytes now pass through raw
|
||||||
|
expect(processDockerStreamChunk(Buffer.from('more'), st).join('')).toBe('more');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('decodeChunkedDockerBody', () => {
|
||||||
|
test('decodes a chunked body and drops the framing', () => {
|
||||||
|
const st = createDockerStreamState(false);
|
||||||
|
// header says chunked; body: "5\r\nhello\r\n"
|
||||||
|
const out = processDockerStreamChunk(Buffer.from(HTTP_HEAD_CHUNKED + '5\r\nhello\r\n'), st);
|
||||||
|
expect(out.join('')).toBe('hello');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reassembles a chunk whose data is split across two events', () => {
|
||||||
|
const st = createDockerStreamState(false);
|
||||||
|
processDockerStreamChunk(Buffer.from(HTTP_HEAD_CHUNKED + '5\r\nhel'), st);
|
||||||
|
const out = processDockerStreamChunk(Buffer.from('lo\r\n'), st);
|
||||||
|
expect(out.join('')).toBe('hello');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the 0-size terminating chunk ends the body', () => {
|
||||||
|
const st = createDockerStreamState(false);
|
||||||
|
st.headersStripped = true;
|
||||||
|
st.isChunked = true;
|
||||||
|
const out = decodeChunkedDockerBody(Buffer.from('3\r\nabc\r\n0\r\n\r\n'), st);
|
||||||
|
expect(Buffer.concat(out).toString()).toBe('abc');
|
||||||
|
expect(st.chunkEnded).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
+57
-59
@@ -171,45 +171,20 @@ function resolveDockerTarget(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ Exec API Helpers ============
|
// ============ Terminal Stream Helpers ============
|
||||||
|
|
||||||
function buildExecStartHttpRequest(execId: string, target: DockerTarget): string {
|
function buildDockerStreamHttpRequest(path: string, target: DockerTarget, body = ''): string {
|
||||||
const body = JSON.stringify({ Detach: false, Tty: true });
|
|
||||||
const tokenHeader = target.type === 'tcp' && target.hawserToken
|
const tokenHeader = target.type === 'tcp' && target.hawserToken
|
||||||
? `X-Hawser-Token: ${target.hawserToken}\r\n`
|
? `X-Hawser-Token: ${target.hawserToken}\r\n`
|
||||||
: '';
|
: '';
|
||||||
// Use actual host for proper routing through reverse proxies like Caddy
|
// Use actual host for proper routing through reverse proxies like Caddy
|
||||||
const host = target.host || 'localhost';
|
const host = target.host || 'localhost';
|
||||||
return `POST /exec/${execId}/start HTTP/1.1\r\nHost: ${host}\r\nContent-Type: application/json\r\n${tokenHeader}Connection: Upgrade\r\nUpgrade: tcp\r\nContent-Length: ${body.length}\r\n\r\n${body}`;
|
return `POST ${path} HTTP/1.1\r\nHost: ${host}\r\nContent-Type: application/json\r\n${tokenHeader}Connection: Upgrade\r\nUpgrade: tcp\r\nContent-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ Stream Processing ============
|
// ============ Stream Processing ============
|
||||||
|
|
||||||
function processTerminalOutput(
|
import { createDockerStreamState, processDockerStreamChunk, type DockerStreamState } from './src/lib/server/docker-stream-core';
|
||||||
data: string,
|
|
||||||
state: { headersStripped: boolean; isChunked: boolean }
|
|
||||||
): string | null {
|
|
||||||
let text = data;
|
|
||||||
|
|
||||||
if (!state.headersStripped) {
|
|
||||||
if (text.toLowerCase().includes('transfer-encoding: chunked')) {
|
|
||||||
state.isChunked = true;
|
|
||||||
}
|
|
||||||
const headerEnd = text.indexOf('\r\n\r\n');
|
|
||||||
if (headerEnd > -1) {
|
|
||||||
text = text.slice(headerEnd + 4);
|
|
||||||
state.headersStripped = true;
|
|
||||||
} else if (text.startsWith('HTTP/')) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.isChunked && text) {
|
|
||||||
text = text.replace(/^[0-9a-fA-F]+\r\n/gm, '').replace(/\r\n$/g, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
return text || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============ Hawser Edge Exec Messages ============
|
// ============ Hawser Edge Exec Messages ============
|
||||||
|
|
||||||
@@ -396,9 +371,29 @@ async function resizeExecForWs(execId: string, cols: number, rows: number, targe
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function resizeContainerForWs(containerId: string, cols: number, rows: number, target: ReturnType<typeof getDockerTarget>): Promise<void> {
|
||||||
|
try {
|
||||||
|
await dockerHttpRequest('POST', `/containers/${encodeURIComponent(containerId)}/resize?h=${rows}&w=${cols}`, target);
|
||||||
|
} catch {
|
||||||
|
// Ignore resize errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getContainerTtyForWs(containerId: string, target: ReturnType<typeof getDockerTarget>): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const response = await dockerHttpRequest('GET', `/containers/${encodeURIComponent(containerId)}/json`, target);
|
||||||
|
if (response.statusCode === 200) {
|
||||||
|
return Boolean(JSON.parse(response.body).Config?.Tty);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Keep multiplexing enabled if the TTY setting cannot be read.
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// Map to track Docker streams per WebSocket (keyed by unique connection ID)
|
// Map to track Docker streams per WebSocket (keyed by unique connection ID)
|
||||||
// Includes WebSocket reference for orphan detection
|
// Includes WebSocket reference for orphan detection
|
||||||
const dockerStreams = new Map<string, { stream: any; execId: string; target: ReturnType<typeof getDockerTarget>; state: { isChunked: boolean }; ws: any }>();
|
const dockerStreams = new Map<string, { stream: any; execId: string | null; containerId: string; mode: 'exec' | 'attach'; target: ReturnType<typeof getDockerTarget>; state: DockerStreamState; ws: any }>();
|
||||||
|
|
||||||
// Counter for unique WebSocket connection IDs
|
// Counter for unique WebSocket connection IDs
|
||||||
let wsConnectionCounter = 0;
|
let wsConnectionCounter = 0;
|
||||||
@@ -597,6 +592,7 @@ function webSocketPlugin(): Plugin {
|
|||||||
const containerIdIndex = pathParts.indexOf('containers') + 1;
|
const containerIdIndex = pathParts.indexOf('containers') + 1;
|
||||||
const containerId = pathParts[containerIdIndex];
|
const containerId = pathParts[containerIdIndex];
|
||||||
|
|
||||||
|
const mode = url.searchParams.get('mode') === 'attach' ? 'attach' : 'exec';
|
||||||
const shell = url.searchParams.get('shell') || '/bin/sh';
|
const shell = url.searchParams.get('shell') || '/bin/sh';
|
||||||
const user = url.searchParams.get('user') || 'root';
|
const user = url.searchParams.get('user') || 'root';
|
||||||
const envIdParam = url.searchParams.get('envId');
|
const envIdParam = url.searchParams.get('envId');
|
||||||
@@ -634,8 +630,13 @@ function webSocketPlugin(): Plugin {
|
|||||||
const target = getDockerTarget(envId);
|
const target = getDockerTarget(envId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Handle Hawser Edge mode differently - use WebSocket protocol
|
// Hawser Edge currently exposes an exec-only terminal protocol.
|
||||||
if (target.type === 'hawser-edge') {
|
if (target.type === 'hawser-edge') {
|
||||||
|
if (mode === 'attach') {
|
||||||
|
ws.send(JSON.stringify({ type: 'error', message: 'Container attach is not supported for Edge environments' }));
|
||||||
|
ws.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const conn = edgeConnections.get(target.environmentId);
|
const conn = edgeConnections.get(target.environmentId);
|
||||||
if (!conn) {
|
if (!conn) {
|
||||||
ws.send(JSON.stringify({ type: 'error', message: 'Edge agent not connected' }));
|
ws.send(JSON.stringify({ type: 'error', message: 'Edge agent not connected' }));
|
||||||
@@ -653,11 +654,21 @@ function webSocketPlugin(): Plugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Direct Docker connection (unix or tcp/hawser-standard)
|
// Direct Docker connection (unix or tcp/hawser-standard)
|
||||||
const exec = await createExecForWs(containerId, [shell], user, target);
|
let execId: string | null = null;
|
||||||
const execId = exec.Id;
|
let streamPath: string;
|
||||||
|
let streamBody = '';
|
||||||
let headersStripped = false;
|
let multiplexed = false;
|
||||||
const state = { isChunked: false };
|
if (mode === 'attach') {
|
||||||
|
const containerTty = await getContainerTtyForWs(containerId, target);
|
||||||
|
multiplexed = !containerTty;
|
||||||
|
streamPath = `/containers/${encodeURIComponent(containerId)}/attach?stream=1&stdin=1&stdout=1&stderr=1`;
|
||||||
|
} else {
|
||||||
|
const exec = await createExecForWs(containerId, [shell], user, target);
|
||||||
|
execId = exec.Id;
|
||||||
|
streamPath = `/exec/${execId}/start`;
|
||||||
|
streamBody = JSON.stringify({ Detach: false, Tty: true });
|
||||||
|
}
|
||||||
|
const state = createDockerStreamState(multiplexed);
|
||||||
|
|
||||||
// Create Node.js TCP/Unix socket connection to Docker
|
// Create Node.js TCP/Unix socket connection to Docker
|
||||||
let dockerStream: net.Socket;
|
let dockerStream: net.Socket;
|
||||||
@@ -679,30 +690,13 @@ function webSocketPlugin(): Plugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dockerStream.on('connect', () => {
|
dockerStream.on('connect', () => {
|
||||||
const httpRequest = buildExecStartHttpRequest(execId, target);
|
dockerStream.write(buildDockerStreamHttpRequest(streamPath, target, streamBody));
|
||||||
dockerStream.write(httpRequest);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
dockerStream.on('data', (data: Buffer) => {
|
dockerStream.on('data', (data: Buffer) => {
|
||||||
if (ws.readyState === WsWebSocket.OPEN) {
|
if (ws.readyState === WsWebSocket.OPEN) {
|
||||||
let text = data.toString('utf-8');
|
for (const text of processDockerStreamChunk(data, state)) {
|
||||||
if (!headersStripped) {
|
if (text) ws.send(JSON.stringify({ type: 'output', data: text }));
|
||||||
if (text.toLowerCase().includes('transfer-encoding: chunked')) {
|
|
||||||
state.isChunked = true;
|
|
||||||
}
|
|
||||||
const headerEnd = text.indexOf('\r\n\r\n');
|
|
||||||
if (headerEnd > -1) {
|
|
||||||
text = text.slice(headerEnd + 4);
|
|
||||||
headersStripped = true;
|
|
||||||
} else if (text.startsWith('HTTP/')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (state.isChunked && text) {
|
|
||||||
text = text.replace(/^[0-9a-fA-F]+\r\n/gm, '').replace(/\r\n$/g, '');
|
|
||||||
}
|
|
||||||
if (text) {
|
|
||||||
ws.send(JSON.stringify({ type: 'output', data: text }));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -721,7 +715,7 @@ function webSocketPlugin(): Plugin {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
dockerStreams.set(connId, { stream: dockerStream, execId, target, state, ws });
|
dockerStreams.set(connId, { stream: dockerStream, execId, containerId, mode, target, state, ws });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('[Terminal WS] Connection error:', error?.message || error);
|
console.error('[Terminal WS] Connection error:', error?.message || error);
|
||||||
ws.send(JSON.stringify({ type: 'error', message: error.message }));
|
ws.send(JSON.stringify({ type: 'error', message: error.message }));
|
||||||
@@ -780,8 +774,12 @@ function webSocketPlugin(): Plugin {
|
|||||||
const msg = JSON.parse(message.toString());
|
const msg = JSON.parse(message.toString());
|
||||||
if (msg.type === 'input' && d.stream) {
|
if (msg.type === 'input' && d.stream) {
|
||||||
d.stream.write(msg.data);
|
d.stream.write(msg.data);
|
||||||
} else if (msg.type === 'resize' && d.execId) {
|
} else if (msg.type === 'resize') {
|
||||||
resizeExecForWs(d.execId, msg.cols, msg.rows, d.target);
|
if (d.mode === 'attach') {
|
||||||
|
resizeContainerForWs(d.containerId, msg.cols, msg.rows, d.target);
|
||||||
|
} else if (d.execId) {
|
||||||
|
resizeExecForWs(d.execId, msg.cols, msg.rows, d.target);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
if (d.stream) {
|
if (d.stream) {
|
||||||
|
|||||||
Reference in new issue
Block a user