233 lines
6.9 KiB
TypeScript
233 lines
6.9 KiB
TypeScript
|
|
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);
|
||
|
|
}
|
||
|
|
}
|