import * as plugins from './plugins.js'; import { MountManager } from './classes.mountmanager.js'; import { SshClient } from './classes.sshclient.js'; export interface ISessionBridgeOptions { host: string; cwd?: string; mountManager?: MountManager; } export class SessionBridge { public readonly host: string; public readonly cwd: string; public readonly token: string; public readonly remotePort: number; private server?: plugins.Server; private localPort?: number; private mountManager: MountManager; constructor(options: ISessionBridgeOptions) { this.host = options.host; this.cwd = options.cwd ?? process.cwd(); this.token = plugins.crypto.randomBytes(32).toString('hex'); this.remotePort = plugins.crypto.randomInt(45000, 55000); this.mountManager = options.mountManager ?? new MountManager(); } public async start(): Promise { if (this.server) { return; } this.server = plugins.http.createServer((request, response) => { void this.handleRequest(request, response); }); await new Promise((resolve, reject) => { this.server?.once('error', reject); this.server?.listen(0, '127.0.0.1', () => { const address = this.server?.address(); if (!address || typeof address === 'string') { reject(new Error('Could not determine DAP session bridge port')); return; } this.localPort = address.port; resolve(); }); }); } public async stop(): Promise { if (!this.server) { return; } const serverToClose = this.server; this.server = undefined; await new Promise((resolve, reject) => { serverToClose.close((error) => { if (error) { reject(error); return; } resolve(); }); }); } public getReverseForwardSpec(): string { if (!this.localPort) { throw new Error('Session bridge has not been started yet'); } return `127.0.0.1:${this.remotePort}:127.0.0.1:${this.localPort}`; } public buildRemoteBootstrapCommand(): string { const script = this.buildRemoteBootstrapScript(); const exports = [ `DAP_SESSION_TOKEN=${SshClient.quoteForSh(this.token)}`, `DAP_SESSION_PORT=${SshClient.quoteForSh(String(this.remotePort))}`, `DAP_SESSION_HOST=${SshClient.quoteForSh(this.host)}`, ].join(' '); return `${exports} sh -lc ${SshClient.quoteForSh(script)}`; } private buildRemoteBootstrapScript(): string { return ` tmpdir="$(mktemp -d "\${TMPDIR:-/tmp}/dap-session.XXXXXX")" || exit 1 cleanup() { rm -rf "$tmpdir" } trap cleanup EXIT INT HUP TERM cat > "$tmpdir/dap" <<'DAP_SHIM' #!/bin/sh set -u command_name="\${1:-help}" print_help() { cat <<'DAP_HELP' dap remote session commands: dap info dap mount The remote dap command exists only inside this SSH session. DAP_HELP } case "$command_name" in info|session) echo "dap session host: \${DAP_SESSION_HOST:-unknown}" echo "dap bridge: 127.0.0.1:\${DAP_SESSION_PORT:-unknown}" ;; mount) remote_path="\${2:-$PWD}" local_path="\${3:-}" if [ -z "\${DAP_SESSION_PORT:-}" ] || [ -z "\${DAP_SESSION_TOKEN:-}" ]; then echo "dap session bridge is not available" >&2 exit 1 fi if ! command -v curl >/dev/null 2>&1; then echo "curl is required on the remote host for bridged dap commands" >&2 exit 1 fi { printf '%s\n' "$remote_path" printf '%s\n' "$local_path" } | curl -fsS -X POST \ -H "Authorization: Bearer $DAP_SESSION_TOKEN" \ --data-binary @- \ "http://127.0.0.1:$DAP_SESSION_PORT/mount" ;; help|--help|-h) print_help ;; *) echo "Unknown remote dap command: $command_name" >&2 print_help >&2 exit 1 ;; esac DAP_SHIM chmod +x "$tmpdir/dap" export PATH="$tmpdir:$PATH" export DAP_SESSION_TOKEN DAP_SESSION_PORT DAP_SESSION_HOST echo "dap session bridge active. Try: dap info" "\${SHELL:-/bin/sh}" -l status=$? exit "$status" `; } private async handleRequest( request: plugins.IncomingMessage, response: plugins.ServerResponse ): Promise { if (!this.isAuthorized(request)) { this.writeResponse(response, 401, 'unauthorized\n'); return; } if (request.method === 'GET' && request.url === '/info') { this.writeJson(response, 200, { host: this.host, remotePort: this.remotePort, }); return; } if (request.method === 'POST' && request.url === '/mount') { await this.handleMountRequest(request, response); return; } this.writeResponse(response, 404, 'not found\n'); } private async handleMountRequest( request: plugins.IncomingMessage, response: plugins.ServerResponse ): Promise { try { const body = await this.readRequestBody(request); const [remotePathLine, localPathLine] = body.split('\n'); const remotePath = remotePathLine?.trim() || '.'; const localPath = localPathLine?.trim() || this.defaultLocalMountPath(remotePath); const exitCode = await this.mountManager.mountDetached({ host: this.host, remotePath, localPath, }); if (exitCode === 0) { this.writeResponse(response, 200, `mounted ${this.host}:${remotePath} at ${localPath}\n`); return; } this.writeResponse(response, 500, `mount command exited with ${exitCode}\n`); } catch (error) { this.writeResponse(response, 500, `${(error as Error).message}\n`); } } private defaultLocalMountPath(remotePath: string): string { const cleanHost = this.host.replace(/[^a-zA-Z0-9._-]/g, '_'); const baseName = plugins.path.basename(remotePath === '.' ? this.host : remotePath) || 'root'; return plugins.path.resolve(this.cwd, 'dap-mounts', cleanHost, baseName); } private isAuthorized(request: plugins.IncomingMessage): boolean { const authorizationHeader = request.headers.authorization; return authorizationHeader === `Bearer ${this.token}`; } private async readRequestBody(request: plugins.IncomingMessage): Promise { const chunks: Buffer[] = []; let totalLength = 0; for await (const chunk of request) { const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); totalLength += buffer.byteLength; if (totalLength > 1024 * 1024) { throw new Error('Request body is too large'); } chunks.push(buffer); } return Buffer.concat(chunks).toString('utf8'); } private writeJson(response: plugins.ServerResponse, statusCode: number, data: unknown): void { response.writeHead(statusCode, { 'content-type': 'application/json; charset=utf-8' }); response.end(`${JSON.stringify(data)}\n`); } private writeResponse(response: plugins.ServerResponse, statusCode: number, body: string): void { response.writeHead(statusCode, { 'content-type': 'text/plain; charset=utf-8' }); response.end(body); } }