Files
dockhand/server.js
T
2026-05-30 08:42:21 +02:00

465 lines
14 KiB
JavaScript

/**
* Production Server Wrapper
*
* Wraps @sveltejs/adapter-node's output with WebSocket support for:
* - Terminal exec connections (xterm.js ↔ Docker exec)
* - Hawser Edge agent connections
*
* Usage: node ./server.js
*/
import { createServer, request as httpRequest } from 'node:http';
import { request as httpsRequest } from 'node:https';
import { createConnection } from 'node:net';
import { connect as tlsConnect, rootCertificates } from 'node:tls';
import { randomUUID } from 'node:crypto';
import { WebSocketServer } from 'ws';
import { handler } from './build/handler.js';
// Patch console to prepend ISO timestamps
const _log = console.log;
const _error = console.error;
const _warn = console.warn;
const ts = () => new Date().toISOString();
console.log = (...args) => _log(ts(), ...args);
console.error = (...args) => _error(ts(), ...args);
console.warn = (...args) => _warn(ts(), ...args);
const PORT = parseInt(process.env.PORT || '3000', 10);
const HOST = process.env.HOST || '0.0.0.0';
// Create HTTP server with SvelteKit handler
const server = createServer((req, res) => {
handler(req, res);
});
// Create WebSocket server attached to the HTTP server
const wss = new WebSocketServer({ noServer: true });
// Track connections
const wsConnections = new Map();
let wsConnectionCounter = 0;
// Track Edge exec sessions: execId -> { ws, environmentId }
const edgeExecSessions = new Map();
// Register global send function for Hawser Edge WebSocket messages.
// hawser.ts checks this first, and handleEdgeExec uses it for terminal relay.
// Reads from __hawserEdgeConnections which is populated by hawser.ts.
globalThis.__hawserSendMessage = (envId, message) => {
const connections = globalThis.__hawserEdgeConnections;
if (!connections) return false;
const conn = connections.get(envId);
if (!conn || !conn.ws) return false;
try {
conn.ws.send(message);
return true;
} catch (e) {
console.error('[Hawser WS] sendMessage error:', e);
return false;
}
};
// Register global handler for exec messages from Hawser Edge agents
// Called by hawser.ts when it receives exec_ready/exec_output/exec_end/error messages
globalThis.__terminalHandleExecMessage = (msg) => {
const execId = msg.execId || msg.requestId;
if (!execId) return;
const session = edgeExecSessions.get(execId);
if (!session || session.ws.readyState !== 1) return;
if (msg.type === 'exec_ready') {
// Agent is ready, frontend is already waiting for output
return;
}
if (msg.type === 'exec_output') {
const data = Buffer.from(msg.data, 'base64').toString('utf-8');
session.ws.send(JSON.stringify({ type: 'output', data }));
return;
}
if (msg.type === 'exec_end') {
session.ws.send(JSON.stringify({ type: 'exit' }));
session.ws.close();
edgeExecSessions.delete(execId);
return;
}
if (msg.type === 'error') {
session.ws.send(JSON.stringify({ type: 'error', message: msg.error || msg.message }));
session.ws.close();
edgeExecSessions.delete(execId);
}
};
// Handle WebSocket upgrade
server.on('upgrade', (req, socket, head) => {
const url = new URL(req.url || '/', `http://${req.headers.host}`);
// Only handle our specific WebSocket paths
const isTerminal = url.pathname.includes('/api/containers/') && url.pathname.includes('/exec');
const isHawser = url.pathname === '/api/hawser/connect';
if (!isTerminal && !isHawser) {
socket.destroy();
return;
}
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit('connection', ws, req);
});
});
wss.on('connection', (ws, req) => {
const url = new URL(req.url || '/', `http://${req.headers.host}`);
const connId = `ws-${++wsConnectionCounter}`;
const remoteIp = (req.headers['x-forwarded-for'] || '').split(',')[0].trim()
|| req.socket.remoteAddress
|| 'unknown';
if (url.pathname === '/api/hawser/connect') {
handleHawserConnection(ws, connId, remoteIp);
} else {
handleTerminalConnection(ws, url, connId);
}
});
/**
* Handle terminal exec WebSocket connections.
* Supports all connection types: socket, direct TCP/TLS, hawser-standard, hawser-edge.
*
* Uses globalThis functions exposed by the SvelteKit app (docker.ts):
* - __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
*/
async function handleTerminalConnection(ws, url, connId) {
const pathParts = url.pathname.split('/');
const containerIdIndex = pathParts.indexOf('containers') + 1;
const containerId = pathParts[containerIdIndex];
const shell = url.searchParams.get('shell') || '/bin/sh';
const user = url.searchParams.get('user') || 'root';
const envIdParam = url.searchParams.get('envId');
const envId = envIdParam ? parseInt(envIdParam, 10) : undefined;
if (!containerId) {
ws.send(JSON.stringify({ type: 'error', message: 'No container ID' }));
ws.close();
return;
}
try {
// Resolve Docker target via SvelteKit app's database
let target;
if (typeof globalThis.__terminalGetTarget === 'function') {
target = await globalThis.__terminalGetTarget(envId);
} else {
// Fallback: local socket only (SvelteKit not yet loaded)
target = { type: 'socket', connectionType: 'socket', socketPath: process.env.DOCKER_SOCKET || '/var/run/docker.sock' };
}
// Handle Hawser Edge mode - relay through agent WebSocket
if (target.connectionType === 'hawser-edge') {
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);
} else {
// Fallback: create exec directly via local socket
execId = await createExecLocal(containerId, shell, user, target.socketPath || '/var/run/docker.sock');
}
// Open raw bidirectional stream to Docker for the exec session
const startBody = JSON.stringify({ Detach: false, Tty: true });
let dockerStream;
if (target.type === 'socket') {
const socketPath = target.socketPath || '/var/run/docker.sock';
dockerStream = createConnection({ path: socketPath });
} else if (target.type === 'https' && target.tls) {
const tlsOpts = {
host: target.host,
port: target.port,
servername: target.host,
rejectUnauthorized: target.tls.rejectUnauthorized ?? true
};
if (target.tls.ca) tlsOpts.ca = [target.tls.ca, ...rootCertificates];
if (target.tls.cert) tlsOpts.cert = [target.tls.cert];
if (target.tls.key) tlsOpts.key = target.tls.key;
dockerStream = tlsConnect(tlsOpts);
} else {
// Plain HTTP (direct TCP or hawser-standard)
dockerStream = createConnection({ host: target.host, port: target.port });
}
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
);
});
let headersStripped = false;
let isChunked = false;
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 }));
}
});
dockerStream.on('close', () => {
if (ws.readyState === 1) {
ws.send(JSON.stringify({ type: 'exit' }));
ws.close();
}
});
dockerStream.on('error', (err) => {
console.error('[Terminal WS] Socket error:', err.message);
if (ws.readyState === 1) {
ws.send(JSON.stringify({ type: 'error', message: err.message }));
}
});
// Forward terminal input from browser to Docker
ws.on('message', (data) => {
try {
const msg = JSON.parse(data.toString());
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(() => {});
} 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 {}
});
ws.on('close', () => {
dockerStream.destroy();
});
wsConnections.set(connId, { stream: dockerStream, ws });
} catch (err) {
console.error('[Terminal WS] Error:', err.message);
if (ws.readyState === 1) {
ws.send(JSON.stringify({ type: 'error', message: err.message }));
ws.close();
}
}
ws.on('close', () => {
wsConnections.delete(connId);
});
}
/**
* Handle Hawser Edge exec session.
* Sends exec commands through the Hawser WebSocket relay.
*/
function handleEdgeExec(ws, connId, containerId, shell, user, environmentId) {
if (typeof globalThis.__hawserSendMessage !== 'function') {
ws.send(JSON.stringify({ type: 'error', message: 'Edge agent handler not ready' }));
ws.close();
return;
}
const execId = randomUUID();
edgeExecSessions.set(execId, { ws, execId, environmentId });
// Send exec_start to the Hawser agent
const execStartMsg = JSON.stringify({
type: 'exec_start',
execId,
containerId,
cmd: shell,
user,
cols: 120,
rows: 30
});
const sent = globalThis.__hawserSendMessage(environmentId, execStartMsg);
if (!sent) {
edgeExecSessions.delete(execId);
ws.send(JSON.stringify({ type: 'error', message: 'Edge agent not connected' }));
ws.close();
return;
}
// Forward terminal input/resize from browser to agent
ws.on('message', (data) => {
try {
const msg = JSON.parse(data.toString());
if (msg.type === 'input' && msg.data) {
const inputMsg = JSON.stringify({
type: 'exec_input',
execId,
data: Buffer.from(msg.data).toString('base64')
});
globalThis.__hawserSendMessage(environmentId, inputMsg);
} else if (msg.type === 'resize' && msg.cols && msg.rows) {
const resizeMsg = JSON.stringify({
type: 'exec_resize',
execId,
cols: msg.cols,
rows: msg.rows
});
globalThis.__hawserSendMessage(environmentId, resizeMsg);
}
} catch {}
});
ws.on('close', () => {
// Notify agent that exec session ended
if (typeof globalThis.__hawserSendMessage === 'function') {
const endMsg = JSON.stringify({
type: 'exec_end',
execId,
reason: 'user_closed'
});
globalThis.__hawserSendMessage(environmentId, endMsg);
}
edgeExecSessions.delete(execId);
wsConnections.delete(connId);
});
wsConnections.set(connId, { ws });
}
/**
* Fallback: Create exec via local Docker socket (used before SvelteKit app is loaded)
*/
function createExecLocal(containerId, shell, user, socketPath) {
const createBody = JSON.stringify({
AttachStdin: true,
AttachStdout: true,
AttachStderr: true,
Tty: true,
Cmd: [shell],
User: user
});
return new Promise((resolve, reject) => {
const req = httpRequest({
socketPath,
path: `/containers/${containerId}/exec`,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(createBody),
},
}, (res) => {
const chunks = [];
res.on('data', (chunk) => chunks.push(chunk));
res.on('end', () => {
try {
const body = JSON.parse(Buffer.concat(chunks).toString());
if (res.statusCode === 201 && body.Id) {
resolve(body.Id);
} else {
reject(new Error(body.message || `Exec create failed: ${res.statusCode}`));
}
} catch (e) {
reject(new Error('Failed to parse exec response'));
}
});
res.on('error', reject);
});
req.on('error', reject);
req.write(createBody);
req.end();
});
}
/**
* Handle Hawser Edge WebSocket connections.
* The full Hawser protocol is handled by the SvelteKit app
* via the global hawser connection manager.
*/
function handleHawserConnection(ws, connId, remoteIp) {
console.log('[Hawser WS] New connection pending authentication');
ws.on('message', async (data) => {
try {
const msg = JSON.parse(data.toString());
// Use the global hawser message handler injected by the SvelteKit app
if (typeof globalThis.__hawserHandleMessage === 'function') {
try {
await globalThis.__hawserHandleMessage(ws, msg, connId, remoteIp);
} catch (handlerError) {
console.error('[Hawser WS] Handler error:', handlerError);
// Don't close connection - let it recover
}
} else {
console.warn('[Hawser WS] No global handler registered');
ws.send(JSON.stringify({ type: 'error', message: 'Server not ready' }));
}
} catch (err) {
console.error('[Hawser WS] Message parse error:', err.message);
}
});
ws.on('close', () => {
if (typeof globalThis.__hawserHandleDisconnect === 'function') {
globalThis.__hawserHandleDisconnect(ws, connId);
}
});
ws.on('error', (err) => {
console.error('[Hawser WS] Connection error:', err.message);
});
}
// Start the server
server.listen(PORT, HOST, () => {
console.log(`Listening on http://${HOST}:${PORT}/ with WebSocket`);
});