Files

233 lines
6.9 KiB
TypeScript
Raw Permalink Normal View History

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<void> {
if (this.server) {
return;
}
this.server = plugins.http.createServer((request, response) => {
void this.handleRequest(request, response);
});
await new Promise<void>((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<void> {
if (!this.server) {
return;
}
const serverToClose = this.server;
this.server = undefined;
await new Promise<void>((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 <remotePath> <localPath>
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<void> {
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<void> {
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<string> {
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);
}
}