mirror of
https://github.com/Finsys/dockhand.git
synced 2026-06-17 19:09:33 +03:00
91ef3e3c9b
Wrap globalThis.__hawserHandleMessage in try-catch to prevent unhandled promise rejections from closing WebSocket connections abruptly. Previously, if the async handler threw an error, the WebSocket would close with code 1006 (abnormal closure) without proper error logging. This caused connections to die after 1-2 seconds, triggering rapid reconnection storms and preventing stats from being retrieved. The inner try-catch ensures handler errors are logged but don't close the connection, allowing the agent to recover and continue processing messages.
464 lines
14 KiB
JavaScript
464 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`);
|
|
});
|
|
|