mirror of
https://github.com/Finsys/dockhand.git
synced 2026-06-17 19:09:33 +03:00
465 lines
14 KiB
JavaScript
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`);
|
|
});
|
|
|
|
|