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
+594
-114
No files matched your search
@@ -2,7 +2,7 @@
|
||||
* Production Server Wrapper
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* Usage: node ./server.js
|
||||
@@ -235,11 +235,139 @@ wss.on('connection', (ws, req) => {
|
||||
* - __terminalGetTarget(envId) - resolves connection info from environment
|
||||
* - __terminalCreateExec(containerId, shell, user, envId) - creates exec via Docker API
|
||||
* - __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) {
|
||||
const pathParts = url.pathname.split('/');
|
||||
const containerIdIndex = pathParts.indexOf('containers') + 1;
|
||||
const containerId = pathParts[containerIdIndex];
|
||||
const mode = url.searchParams.get('mode') === 'attach' ? 'attach' : 'exec';
|
||||
const shell = url.searchParams.get('shell') || '/bin/sh';
|
||||
const user = url.searchParams.get('user') || 'root';
|
||||
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' };
|
||||
}
|
||||
|
||||
// Handle Hawser Edge mode - relay through agent WebSocket
|
||||
// Hawser Edge currently exposes an exec-only terminal protocol.
|
||||
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);
|
||||
return;
|
||||
}
|
||||
|
||||
let execId = null;
|
||||
let streamPath;
|
||||
let streamBody = '';
|
||||
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 {
|
||||
// Create exec instance via SvelteKit app (handles all connection types)
|
||||
let execId;
|
||||
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
|
||||
const startBody = JSON.stringify({ Detach: false, Tty: true });
|
||||
// Open raw bidirectional stream to Docker for the attach or exec session.
|
||||
let dockerStream;
|
||||
|
||||
if (target.type === 'socket') {
|
||||
@@ -333,45 +483,16 @@ async function handleTerminalConnection(ws, url, connId) {
|
||||
}
|
||||
|
||||
dockerStream.on('connect', () => {
|
||||
const host = target.host || 'localhost';
|
||||
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
|
||||
);
|
||||
dockerStream.write(buildDockerStreamRequest(streamPath, target, streamBody));
|
||||
});
|
||||
|
||||
let headersStripped = false;
|
||||
let isChunked = false;
|
||||
const streamState = createDockerStreamState(multiplexed);
|
||||
|
||||
dockerStream.on('data', (data) => {
|
||||
if (ws.readyState !== 1) return;
|
||||
|
||||
let text = data.toString('utf-8');
|
||||
if (!headersStripped) {
|
||||
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 }));
|
||||
for (const text of processDockerStreamChunk(data, streamState)) {
|
||||
if (text) ws.send(JSON.stringify({ type: 'output', data: text }));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -396,6 +517,20 @@ async function handleTerminalConnection(ws, url, connId) {
|
||||
if (msg.type === 'input' && msg.data) {
|
||||
dockerStream.write(msg.data);
|
||||
} else if (msg.type === 'resize' && msg.cols && msg.rows) {
|
||||
if (mode === 'attach') {
|
||||
if (typeof globalThis.__terminalResizeContainer === 'function') {
|
||||
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 {
|
||||
// Use SvelteKit's resize function if available (works for all connection types)
|
||||
if (typeof globalThis.__terminalResizeExec === 'function') {
|
||||
globalThis.__terminalResizeExec(execId, msg.cols, msg.rows, envId).catch(() => {});
|
||||
@@ -411,6 +546,7 @@ async function handleTerminalConnection(ws, url, connId) {
|
||||
resizeReq.end();
|
||||
}
|
||||
}
|
||||
}
|
||||
} 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
|
||||
* 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);
|
||||
|
||||
@@ -14,6 +14,9 @@ export interface NewerVersion {
|
||||
digest?: string;
|
||||
}
|
||||
|
||||
/** Terminal connection mode: an interactive `docker exec` shell, or `docker attach` to PID 1. */
|
||||
export type TerminalMode = 'exec' | 'attach';
|
||||
|
||||
export interface ContainerInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
@@ -67,7 +67,8 @@
|
||||
Copy,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
Tag
|
||||
Tag,
|
||||
Unplug
|
||||
} from 'lucide-svelte';
|
||||
import { broom } from '@lucide/lab';
|
||||
import { copyToClipboard } from '$lib/utils/clipboard';
|
||||
@@ -83,7 +84,7 @@
|
||||
import BatchOperationModal from '$lib/components/BatchOperationModal.svelte';
|
||||
import VersionUpdateBadge from '$lib/components/VersionUpdateBadge.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 { currentEnvironment, environments, appendEnvParam, clearStaleEnvironment } from '$lib/stores/environment';
|
||||
import { containerStore } from '$lib/stores/containers';
|
||||
@@ -258,10 +259,12 @@
|
||||
containerName: string;
|
||||
shell: string;
|
||||
user: string;
|
||||
mode: TerminalMode;
|
||||
}
|
||||
let activeTerminals = $state<ActiveTerminal[]>([]);
|
||||
let currentTerminalContainerId = $state<string | null>(null);
|
||||
let terminalPopoverStates = $state<Record<string, boolean>>({});
|
||||
let terminalMode = $state<TerminalMode>('exec');
|
||||
let terminalShell = $state('/bin/bash');
|
||||
let terminalUser = $state('root');
|
||||
let terminalCustomUser = $state('');
|
||||
@@ -1121,19 +1124,23 @@
|
||||
const savedUser = getSavedUser(container.id);
|
||||
terminalUser = savedUser ?? 'root';
|
||||
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
|
||||
terminalPopoverStates[container.id] = true;
|
||||
}
|
||||
}
|
||||
|
||||
function startTerminal(container: ContainerInfo) {
|
||||
saveUserForContainer(container.id, terminalUser);
|
||||
if (terminalMode === 'exec') saveUserForContainer(container.id, terminalUser);
|
||||
terminalCustomUsers = getCustomUsers();
|
||||
const terminal: ActiveTerminal = {
|
||||
containerId: container.id,
|
||||
containerName: container.name,
|
||||
shell: terminalShell,
|
||||
user: terminalUser
|
||||
user: terminalUser,
|
||||
mode: terminalMode
|
||||
};
|
||||
activeTerminals = [...activeTerminals, terminal];
|
||||
currentTerminalContainerId = container.id;
|
||||
@@ -2160,7 +2167,7 @@
|
||||
{:else}
|
||||
<Popover.Root open={terminalPopoverStates[container.id] ?? false} onOpenChange={(open) => {
|
||||
terminalPopoverStates[container.id] = open;
|
||||
if (open) detectContainerShells(container.id);
|
||||
if (open && terminalMode === 'exec') detectContainerShells(container.id);
|
||||
}}>
|
||||
<Popover.Trigger
|
||||
onclick={(e: MouseEvent) => e.stopPropagation()}
|
||||
@@ -2175,12 +2182,12 @@
|
||||
<span class="text-xs font-medium truncate" title={container.name}>{container.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
{#if detectingShellsFor === container.id}
|
||||
{#if terminalMode === 'exec' && detectingShellsFor === container.id}
|
||||
<div class="p-4 text-center">
|
||||
<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>
|
||||
</div>
|
||||
{:else if !anyShellAvailableFor(container.id)}
|
||||
{:else if terminalMode === 'exec' && !anyShellAvailableFor(container.id)}
|
||||
<div class="p-4 text-center">
|
||||
<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>
|
||||
@@ -2188,6 +2195,34 @@
|
||||
</div>
|
||||
{:else}
|
||||
<div class="p-3 space-y-3">
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">Mode</Label>
|
||||
<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">
|
||||
{#if terminalMode === 'attach'}
|
||||
<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.Content>
|
||||
<Select.Item value="exec" label="Shell (exec)">
|
||||
<Shell class="w-3 h-3 mr-1.5 text-muted-foreground" />
|
||||
Shell (exec)
|
||||
</Select.Item>
|
||||
<Select.Item value="attach" label="Attach to process">
|
||||
<Unplug class="w-3 h-3 mr-1.5 text-muted-foreground" />
|
||||
Attach to process
|
||||
</Select.Item>
|
||||
</Select.Content>
|
||||
</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}>
|
||||
@@ -2258,6 +2293,7 @@
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
{/if}
|
||||
<Button size="sm" class="w-full h-7 text-xs" onclick={() => startTerminal(container)}>
|
||||
<Terminal class="w-3 h-3" />
|
||||
Connect
|
||||
@@ -2436,6 +2472,7 @@
|
||||
containerName={t.containerName}
|
||||
shell={t.shell}
|
||||
user={t.user}
|
||||
mode={t.mode}
|
||||
visible={t.containerId === currentTerminalContainerId}
|
||||
envId={envId}
|
||||
fillHeight={true}
|
||||
@@ -2473,6 +2510,7 @@
|
||||
containerName={t.containerName}
|
||||
shell={t.shell}
|
||||
user={t.user}
|
||||
mode={t.mode}
|
||||
visible={t.containerId === currentTerminalContainerId}
|
||||
envId={envId}
|
||||
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 PageHeader from '$lib/components/PageHeader.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 Terminal from './Terminal.svelte';
|
||||
import { NoEnvironment } from '$lib/components/ui/empty-state';
|
||||
@@ -34,6 +34,7 @@
|
||||
let detectingShells = $state(false);
|
||||
|
||||
// Shell/user options
|
||||
let terminalMode = $state<TerminalMode>('exec');
|
||||
let selectedShell = $state('/bin/bash');
|
||||
let selectedUser = $state('root');
|
||||
let customUserInput = $state('');
|
||||
@@ -193,7 +194,7 @@
|
||||
|
||||
// Watch for shell/user changes while connected and trigger reconnect
|
||||
$effect(() => {
|
||||
if (selectedContainer && connected && terminalComponent) {
|
||||
if (terminalMode === 'exec' && selectedContainer && connected && terminalComponent) {
|
||||
if (selectedShell !== prevShell || committedUser !== prevUser) {
|
||||
terminalComponent.reconnect();
|
||||
}
|
||||
@@ -215,6 +216,9 @@
|
||||
|
||||
onMount(async () => {
|
||||
customUsers = getCustomUsers();
|
||||
if ($page.url.searchParams.get('mode') === 'attach') {
|
||||
terminalMode = 'attach';
|
||||
}
|
||||
await fetchContainers();
|
||||
|
||||
// Check for container ID in URL query parameter
|
||||
@@ -307,6 +311,30 @@
|
||||
</div>
|
||||
{/if}
|
||||
</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}
|
||||
<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>
|
||||
{/if}
|
||||
|
||||
<!-- Shell selector - always visible -->
|
||||
<!-- Shell selector - only used by exec mode -->
|
||||
{#if terminalMode === 'exec'}
|
||||
<div class="flex items-center gap-2">
|
||||
<Label class="text-sm text-muted-foreground">Shell:</Label>
|
||||
{#if detectingShells}
|
||||
@@ -374,7 +403,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- User selector - always visible -->
|
||||
<!-- User selector - only used by exec mode -->
|
||||
<div class="flex items-center gap-2">
|
||||
<Label class="text-sm text-muted-foreground">User:</Label>
|
||||
<Select.Root type="single" bind:value={selectedUser} onValueChange={onUserSelectChange}>
|
||||
@@ -422,6 +451,7 @@
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Shell output - full height -->
|
||||
@@ -433,14 +463,14 @@
|
||||
<p>Select a container to open shell</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else if detectingShells}
|
||||
{:else if terminalMode === 'exec' && detectingShells}
|
||||
<div class="flex items-center justify-center h-full text-muted-foreground">
|
||||
<div class="text-center">
|
||||
<Loader2 class="w-12 h-12 mx-auto mb-3 opacity-50 animate-spin" />
|
||||
<p>Detecting available shells...</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else if !anyShellAvailable}
|
||||
{:else if terminalMode === 'exec' && !anyShellAvailable}
|
||||
<div class="flex items-center justify-center h-full text-muted-foreground">
|
||||
<div class="text-center">
|
||||
<AlertCircle class="w-12 h-12 mx-auto mb-3 opacity-50 text-amber-500" />
|
||||
@@ -499,13 +529,14 @@
|
||||
</div>
|
||||
</div>
|
||||
<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
|
||||
bind:this={terminalComponent}
|
||||
containerId={selectedContainer.id}
|
||||
containerName={selectedContainer.name}
|
||||
shell={selectedShell}
|
||||
user={committedUser}
|
||||
mode={terminalMode}
|
||||
{envId}
|
||||
fontSize={terminalFontSize}
|
||||
/>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { copyToClipboard } from '$lib/utils/clipboard';
|
||||
import { themeStore } from '$lib/stores/theme';
|
||||
import { getMonospaceFont } from '$lib/themes';
|
||||
import type { TerminalMode } from '$lib/types';
|
||||
|
||||
// Dynamic imports for browser-only xterm
|
||||
let TerminalClass: any;
|
||||
@@ -16,11 +17,12 @@
|
||||
shell: string;
|
||||
user: string;
|
||||
envId: number | null;
|
||||
mode?: TerminalMode;
|
||||
fontSize?: number;
|
||||
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 fitAddon: any = null;
|
||||
@@ -178,13 +180,24 @@
|
||||
// In production, connect to the same port as the app
|
||||
const isDev = import.meta.env.DEV;
|
||||
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) {
|
||||
wsUrl += `&envId=${envId}`;
|
||||
}
|
||||
|
||||
terminal.writeln(`\x1b[90mConnecting to ${containerName}...\x1b[0m`);
|
||||
terminal.writeln(`\x1b[90m${mode === 'attach' ? 'Attaching to' : 'Connecting to'} ${containerName}...\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('');
|
||||
|
||||
ws = new WebSocket(wsUrl);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { X, GripHorizontal, RefreshCw, Copy, Trash2 } from 'lucide-svelte';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import type { TerminalMode } from '$lib/types';
|
||||
import Terminal from './Terminal.svelte';
|
||||
|
||||
interface Props {
|
||||
@@ -9,13 +10,14 @@
|
||||
containerName: string;
|
||||
shell: string;
|
||||
user: string;
|
||||
mode?: TerminalMode;
|
||||
visible: boolean;
|
||||
envId: number | null;
|
||||
fillHeight?: boolean;
|
||||
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 panelRef: HTMLDivElement;
|
||||
@@ -167,7 +169,7 @@
|
||||
<!-- 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 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>
|
||||
{#if connected}
|
||||
<span class="inline-flex items-center gap-1 text-xs text-green-500">
|
||||
@@ -229,14 +231,17 @@
|
||||
|
||||
<!-- Terminal content -->
|
||||
<div class="flex-1 overflow-hidden p-1">
|
||||
{#key `${containerId}-${mode}-${shell}-${user}`}
|
||||
<Terminal
|
||||
bind:this={terminalComponent}
|
||||
{containerId}
|
||||
{containerName}
|
||||
{shell}
|
||||
{user}
|
||||
{mode}
|
||||
{envId}
|
||||
{fontSize}
|
||||
/>
|
||||
{/key}
|
||||
</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);
|
||||
});
|
||||
});
|
||||
+55
-57
@@ -171,45 +171,20 @@ function resolveDockerTarget(
|
||||
};
|
||||
}
|
||||
|
||||
// ============ Exec API Helpers ============
|
||||
// ============ Terminal Stream Helpers ============
|
||||
|
||||
function buildExecStartHttpRequest(execId: string, target: DockerTarget): string {
|
||||
const body = JSON.stringify({ Detach: false, Tty: true });
|
||||
function buildDockerStreamHttpRequest(path: string, target: DockerTarget, body = ''): string {
|
||||
const tokenHeader = target.type === 'tcp' && target.hawserToken
|
||||
? `X-Hawser-Token: ${target.hawserToken}\r\n`
|
||||
: '';
|
||||
// Use actual host for proper routing through reverse proxies like Caddy
|
||||
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 ============
|
||||
|
||||
function processTerminalOutput(
|
||||
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;
|
||||
}
|
||||
import { createDockerStreamState, processDockerStreamChunk, type DockerStreamState } from './src/lib/server/docker-stream-core';
|
||||
|
||||
// ============ 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)
|
||||
// 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
|
||||
let wsConnectionCounter = 0;
|
||||
@@ -597,6 +592,7 @@ function webSocketPlugin(): Plugin {
|
||||
const containerIdIndex = pathParts.indexOf('containers') + 1;
|
||||
const containerId = pathParts[containerIdIndex];
|
||||
|
||||
const mode = url.searchParams.get('mode') === 'attach' ? 'attach' : 'exec';
|
||||
const shell = url.searchParams.get('shell') || '/bin/sh';
|
||||
const user = url.searchParams.get('user') || 'root';
|
||||
const envIdParam = url.searchParams.get('envId');
|
||||
@@ -634,8 +630,13 @@ function webSocketPlugin(): Plugin {
|
||||
const target = getDockerTarget(envId);
|
||||
|
||||
try {
|
||||
// Handle Hawser Edge mode differently - use WebSocket protocol
|
||||
// Hawser Edge currently exposes an exec-only terminal protocol.
|
||||
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);
|
||||
if (!conn) {
|
||||
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)
|
||||
let execId: string | null = null;
|
||||
let streamPath: string;
|
||||
let streamBody = '';
|
||||
let multiplexed = 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);
|
||||
const execId = exec.Id;
|
||||
|
||||
let headersStripped = false;
|
||||
const state = { isChunked: false };
|
||||
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
|
||||
let dockerStream: net.Socket;
|
||||
@@ -679,30 +690,13 @@ function webSocketPlugin(): Plugin {
|
||||
}
|
||||
|
||||
dockerStream.on('connect', () => {
|
||||
const httpRequest = buildExecStartHttpRequest(execId, target);
|
||||
dockerStream.write(httpRequest);
|
||||
dockerStream.write(buildDockerStreamHttpRequest(streamPath, target, streamBody));
|
||||
});
|
||||
|
||||
dockerStream.on('data', (data: Buffer) => {
|
||||
if (ws.readyState === WsWebSocket.OPEN) {
|
||||
let text = data.toString('utf-8');
|
||||
if (!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);
|
||||
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 }));
|
||||
for (const text of processDockerStreamChunk(data, state)) {
|
||||
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) {
|
||||
console.error('[Terminal WS] Connection error:', error?.message || error);
|
||||
ws.send(JSON.stringify({ type: 'error', message: error.message }));
|
||||
@@ -780,9 +774,13 @@ function webSocketPlugin(): Plugin {
|
||||
const msg = JSON.parse(message.toString());
|
||||
if (msg.type === 'input' && d.stream) {
|
||||
d.stream.write(msg.data);
|
||||
} else if (msg.type === 'resize' && d.execId) {
|
||||
} else if (msg.type === 'resize') {
|
||||
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 {
|
||||
if (d.stream) {
|
||||
d.stream.write(message);
|
||||
|
||||
Reference in new issue
Block a user