diff --git a/server.js b/server.js index 0e71485..5502457 100644 --- a/server.js +++ b/server.js @@ -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; } - // 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); + 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 { - // Fallback: create exec directly via local socket - execId = await createExecLocal(containerId, shell, user, target.socketPath || '/var/run/docker.sock'); + // Create exec instance via SvelteKit app (handles all connection types) + 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,19 +517,34 @@ 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) { - // 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(() => {}); + 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 { - // Fallback: resize via local socket - const socketPath = target.socketPath || '/var/run/docker.sock'; - const resizeReq = httpRequest({ - socketPath, - path: `/exec/${execId}/resize?h=${msg.rows}&w=${msg.cols}`, - method: 'POST', - }, () => {}); - resizeReq.on('error', () => {}); - resizeReq.end(); + // 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(() => {}); + } else { + // Fallback: resize via local socket + const socketPath = target.socketPath || '/var/run/docker.sock'; + const resizeReq = httpRequest({ + socketPath, + path: `/exec/${execId}/resize?h=${msg.rows}&w=${msg.cols}`, + method: 'POST', + }, () => {}); + resizeReq.on('error', () => {}); + resizeReq.end(); + } } } } catch {} diff --git a/src/lib/server/docker-stream-core.ts b/src/lib/server/docker-stream-core.ts new file mode 100644 index 0000000..f14d329 --- /dev/null +++ b/src/lib/server/docker-stream-core.ts @@ -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; +} diff --git a/src/lib/server/docker.ts b/src/lib/server/docker.ts index fe30910..773244e 100644 --- a/src/lib/server/docker.ts +++ b/src/lib/server/docker.ts @@ -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) | undefined; var __terminalResizeExec: ((execId: string, cols: number, rows: number, envId?: number) => Promise) | undefined; + var __terminalGetContainerTty: ((containerId: string, envId?: number) => Promise) | undefined; + var __terminalResizeContainer: ((containerId: string, cols: number, rows: number, envId?: number) => Promise) | 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); diff --git a/src/lib/types.ts b/src/lib/types.ts index 59daea8..253e13c 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -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; diff --git a/src/routes/containers/+page.svelte b/src/routes/containers/+page.svelte index 4549339..f890a26 100644 --- a/src/routes/containers/+page.svelte +++ b/src/routes/containers/+page.svelte @@ -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([]); let currentTerminalContainerId = $state(null); let terminalPopoverStates = $state>({}); + let terminalMode = $state('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; @@ -1434,7 +1441,7 @@ onkeydown={(e) => e.key === 'Escape' && (searchQuery = '')} class="pl-8 h-8 w-48 text-sm" /> - + @@ -2160,7 +2167,7 @@ {:else} { terminalPopoverStates[container.id] = open; - if (open) detectContainerShells(container.id); + if (open && terminalMode === 'exec') detectContainerShells(container.id); }}> e.stopPropagation()} @@ -2175,12 +2182,12 @@ {container.name} - {#if detectingShellsFor === container.id} + {#if terminalMode === 'exec' && detectingShellsFor === container.id}

Detecting shells...

- {:else if !anyShellAvailableFor(container.id)} + {:else if terminalMode === 'exec' && !anyShellAvailableFor(container.id)}

No shell available

@@ -2189,76 +2196,105 @@ {:else}
- - + + { + terminalMode = value as TerminalMode; + if (terminalMode === 'exec') detectContainerShells(container.id); + }}> - - {shellDetectionCache[container.id]?.allShells.find(o => o.path === terminalShell)?.label || 'Select'} + {#if terminalMode === 'attach'} + + Attach to process + {:else} + + Shell (exec) + {/if} - {#if shellDetectionCache[container.id]} - {#each shellDetectionCache[container.id].allShells as option} - - - - {option.label} - {#if !option.available} - (unavailable) - {/if} - + + + Shell (exec) + + + + Attach to process + + + +
+ {#if terminalMode === 'exec'} +
+ + + + + {shellDetectionCache[container.id]?.allShells.find(o => o.path === terminalShell)?.label || 'Select'} + + + {#if shellDetectionCache[container.id]} + {#each shellDetectionCache[container.id].allShells as option} + + + + {option.label} + {#if !option.available} + (unavailable) + {/if} + + + {/each} + {/if} + + +
+
+ + + + + {userOptions.find(o => o.value === terminalUser)?.label || terminalUser || 'Select'} + + + {#each userOptions as option} + + + {option.label} {/each} - {/if} - - -
-
- - - - - {userOptions.find(o => o.value === terminalUser)?.label || terminalUser || 'Select'} - - - {#each userOptions as option} - - - {option.label} - - {/each} - {#if terminalCustomUsers.length > 0} + {#if terminalCustomUsers.length > 0} +
+ {#each terminalCustomUsers as cu} +
+ + + {cu} + + +
+ {/each} + {/if}
- {#each terminalCustomUsers as cu} -
- - - {cu} - - -
- {/each} - {/if} -
-
- { 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()} - /> -
-
-
-
-
+ {/if} + @@ -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)} diff --git a/src/routes/terminal/+page.svelte b/src/routes/terminal/+page.svelte index adb537e..32e9e46 100644 --- a/src/routes/terminal/+page.svelte +++ b/src/routes/terminal/+page.svelte @@ -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('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 @@
{/if} +
+ + terminalMode = value as TerminalMode}> + + {#if terminalMode === 'attach'} + + Attach to process + {:else} + + Shell (exec) + {/if} + + + + + Shell (exec) + + + + Attach to process + + + +
{#if selectedContainer} {/if} - + + {#if terminalMode === 'exec'}
{#if detectingShells} @@ -374,7 +403,7 @@ {/if}
- +
@@ -422,6 +451,7 @@
+ {/if} @@ -433,14 +463,14 @@

Select a container to open shell

- {:else if detectingShells} + {:else if terminalMode === 'exec' && detectingShells}

Detecting available shells...

- {:else if !anyShellAvailable} + {:else if terminalMode === 'exec' && !anyShellAvailable}
@@ -499,13 +529,14 @@
- {#key `${selectedContainer.id}-${selectedShell}-${committedUser}`} + {#key `${selectedContainer.id}-${terminalMode}-${terminalMode === 'exec' ? selectedShell : ''}-${terminalMode === 'exec' ? committedUser : ''}`} diff --git a/src/routes/terminal/Terminal.svelte b/src/routes/terminal/Terminal.svelte index 3e907d2..e320f2f 100644 --- a/src/routes/terminal/Terminal.svelte +++ b/src/routes/terminal/Terminal.svelte @@ -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[90mShell: ${shell}, User: ${user || 'default'}\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); diff --git a/src/routes/terminal/TerminalPanel.svelte b/src/routes/terminal/TerminalPanel.svelte index c7b2a68..c5c7a32 100644 --- a/src/routes/terminal/TerminalPanel.svelte +++ b/src/routes/terminal/TerminalPanel.svelte @@ -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; let panelRef: HTMLDivElement; @@ -167,7 +169,7 @@
- Terminal: + {mode === 'attach' ? 'Attach:' : 'Terminal:'} {containerName} {#if connected} @@ -229,14 +231,17 @@
- + {#key `${containerId}-${mode}-${shell}-${user}`} + + {/key}
diff --git a/tests/docker-stream-core.test.ts b/tests/docker-stream-core.test.ts new file mode 100644 index 0000000..114aae4 --- /dev/null +++ b/tests/docker-stream-core.test.ts @@ -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); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index c346338..4ca6d9e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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): Promise { + try { + await dockerHttpRequest('POST', `/containers/${encodeURIComponent(containerId)}/resize?h=${rows}&w=${cols}`, target); + } catch { + // Ignore resize errors + } +} + +async function getContainerTtyForWs(containerId: string, target: ReturnType): Promise { + 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; state: { isChunked: boolean }; ws: any }>(); +const dockerStreams = new Map; 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) - const exec = await createExecForWs(containerId, [shell], user, target); - const execId = exec.Id; - - let headersStripped = false; - const state = { isChunked: false }; + 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); + 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,8 +774,12 @@ 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) { - resizeExecForWs(d.execId, msg.cols, msg.rows, d.target); + } 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) {