feat(core): add SSH data access proxy CLI and core managers
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user