feat(core): add SSH data access proxy CLI and core managers
This commit is contained in:
@@ -0,0 +1,476 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import { HostManager } from './classes.hostmanager.js';
|
||||
import { MountManager } from './classes.mountmanager.js';
|
||||
import { PortProxy } from './classes.portproxy.js';
|
||||
import { SessionBridge } from './classes.sessionbridge.js';
|
||||
import { SshClient } from './classes.sshclient.js';
|
||||
import type { IDoctorCheck, IHostDefinition, IParsedArgs, ISshConfigHost, TDapMountBackend } from './types.js';
|
||||
|
||||
export class DapCli {
|
||||
private hostManager = new HostManager();
|
||||
private mountManager = new MountManager();
|
||||
private portProxy = new PortProxy();
|
||||
private sshClient = new SshClient();
|
||||
|
||||
public async run(argv = process.argv.slice(2)): Promise<number> {
|
||||
const parsedArgs = this.parseArgs(argv);
|
||||
const [command, ...positionals] = parsedArgs.positional;
|
||||
|
||||
try {
|
||||
switch (command) {
|
||||
case undefined:
|
||||
return this.runInteractiveDashboard();
|
||||
case 'help':
|
||||
case '--help':
|
||||
case '-h':
|
||||
this.printHelp();
|
||||
return 0;
|
||||
case 'list':
|
||||
return this.runList();
|
||||
case 'add':
|
||||
return this.runAdd(positionals, parsedArgs);
|
||||
case 'edit':
|
||||
return this.runEdit(positionals, parsedArgs);
|
||||
case 'ssh':
|
||||
return this.runSsh(positionals, parsedArgs);
|
||||
case 'proxy':
|
||||
return this.runProxy(positionals, parsedArgs);
|
||||
case 'mount':
|
||||
return this.runMount(positionals, parsedArgs);
|
||||
case 'unmount':
|
||||
return this.runUnmount(positionals);
|
||||
case 'doctor':
|
||||
return this.runDoctor();
|
||||
default:
|
||||
console.error(`Unknown command: ${command}`);
|
||||
this.printHelp();
|
||||
return 1;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error((error as Error).message);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
public parseArgs(argv: string[]): IParsedArgs {
|
||||
const positional: string[] = [];
|
||||
const flags: Record<string, string | boolean | string[]> = {};
|
||||
const passthroughIndex = argv.indexOf('--');
|
||||
const dapArgs = passthroughIndex >= 0 ? argv.slice(0, passthroughIndex) : argv;
|
||||
const passthrough = passthroughIndex >= 0 ? argv.slice(passthroughIndex + 1) : [];
|
||||
|
||||
for (let index = 0; index < dapArgs.length; index++) {
|
||||
const arg = dapArgs[index];
|
||||
if (!arg.startsWith('--')) {
|
||||
positional.push(arg);
|
||||
continue;
|
||||
}
|
||||
|
||||
const flagBody = arg.slice(2);
|
||||
const equalsIndex = flagBody.indexOf('=');
|
||||
let flagName: string;
|
||||
let flagValue: string | boolean;
|
||||
if (equalsIndex >= 0) {
|
||||
flagName = flagBody.slice(0, equalsIndex);
|
||||
flagValue = flagBody.slice(equalsIndex + 1);
|
||||
} else {
|
||||
flagName = flagBody;
|
||||
const nextArg = dapArgs[index + 1];
|
||||
if (nextArg && !nextArg.startsWith('--')) {
|
||||
flagValue = nextArg;
|
||||
index++;
|
||||
} else {
|
||||
flagValue = true;
|
||||
}
|
||||
}
|
||||
this.addFlag(flags, flagName, flagValue);
|
||||
}
|
||||
|
||||
return { positional, flags, passthrough };
|
||||
}
|
||||
|
||||
private async runInteractiveDashboard(): Promise<number> {
|
||||
const rl = this.createReadline();
|
||||
try {
|
||||
while (true) {
|
||||
console.log('');
|
||||
console.log('dap');
|
||||
console.log('1. List hosts');
|
||||
console.log('2. Add host');
|
||||
console.log('3. Edit host');
|
||||
console.log('4. SSH into host');
|
||||
console.log('5. Proxy port');
|
||||
console.log('6. Mount remote path');
|
||||
console.log('7. Doctor');
|
||||
console.log('q. Quit');
|
||||
const choice = (await rl.question('Choose: ')).trim();
|
||||
if (choice === 'q' || choice === 'quit' || choice === 'exit') {
|
||||
return 0;
|
||||
}
|
||||
if (choice === '1') await this.runList();
|
||||
if (choice === '2') await this.runAdd([], { positional: [], flags: {}, passthrough: [] });
|
||||
if (choice === '3') {
|
||||
const alias = await rl.question('Host alias: ');
|
||||
await this.runEdit([alias.trim()], { positional: [], flags: {}, passthrough: [] });
|
||||
}
|
||||
if (choice === '4') {
|
||||
const alias = await rl.question('Host alias: ');
|
||||
await this.runSsh([alias.trim()], { positional: [], flags: {}, passthrough: [] });
|
||||
}
|
||||
if (choice === '5') {
|
||||
const alias = await rl.question('Host alias: ');
|
||||
const localForward = await rl.question('Local forward (<localPort>:<remoteHost>:<remotePort>): ');
|
||||
await this.runProxy([alias.trim()], {
|
||||
positional: [],
|
||||
flags: { local: localForward.trim() },
|
||||
passthrough: [],
|
||||
});
|
||||
}
|
||||
if (choice === '6') {
|
||||
const remoteSpec = await rl.question('Remote spec (<host>:<remotePath>): ');
|
||||
const localPath = await rl.question('Local mount path: ');
|
||||
await this.runMount([remoteSpec.trim(), localPath.trim()], { positional: [], flags: {}, passthrough: [] });
|
||||
}
|
||||
if (choice === '7') await this.runDoctor();
|
||||
}
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
private async runList(): Promise<number> {
|
||||
const hosts = await this.hostManager.listHosts();
|
||||
if (!hosts.length) {
|
||||
console.log('No SSH hosts found.');
|
||||
return 0;
|
||||
}
|
||||
for (const host of hosts) {
|
||||
this.printHost(host);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private async runAdd(positionals: string[], parsedArgs: IParsedArgs): Promise<number> {
|
||||
const hostDefinition = await this.collectHostDefinition(positionals[0], parsedArgs.flags);
|
||||
const result = await this.hostManager.addHost(hostDefinition);
|
||||
console.log(result.changed ? `Wrote ${result.filePath}` : `No changes in ${result.filePath}`);
|
||||
if (result.backupPath) {
|
||||
console.log(`Backup: ${result.backupPath}`);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private async runEdit(positionals: string[], parsedArgs: IParsedArgs): Promise<number> {
|
||||
const alias = positionals[0];
|
||||
if (!alias) {
|
||||
throw new Error('Usage: dap edit <host> [--hostname value] [--user value] [--port value] [--identity-file path] [--proxy-jump host] [--local-forward spec] [--remote-forward spec]');
|
||||
}
|
||||
|
||||
const { alias: ignoredAlias, ...changes } = this.hostDefinitionFromFlags(alias, parsedArgs.flags);
|
||||
const hasFlagChanges = Object.values(changes).some(Boolean);
|
||||
const finalChanges = hasFlagChanges ? changes : await this.promptForHostChanges(alias);
|
||||
const preview = await this.hostManager.previewEditHost(alias, finalChanges);
|
||||
|
||||
if (preview.before === preview.after) {
|
||||
console.log('No changes.');
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!preview.dapManaged && !this.getBooleanFlag(parsedArgs.flags, 'yes')) {
|
||||
console.log(preview.diff);
|
||||
const confirmed = await this.confirm('Apply changes to existing non-DAP SSH config block?', false);
|
||||
if (!confirmed) {
|
||||
console.log('Aborted.');
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await this.hostManager.editHost(alias, finalChanges);
|
||||
console.log(result.changed ? `Wrote ${result.filePath}` : `No changes in ${result.filePath}`);
|
||||
if (result.backupPath) {
|
||||
console.log(`Backup: ${result.backupPath}`);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private async runSsh(positionals: string[], parsedArgs: IParsedArgs): Promise<number> {
|
||||
const host = positionals[0];
|
||||
if (!host) {
|
||||
throw new Error('Usage: dap ssh <host> [--no-bridge] [-- <ssh args>]');
|
||||
}
|
||||
|
||||
const useBridge = !this.getBooleanFlag(parsedArgs.flags, 'no-bridge') && parsedArgs.passthrough.length === 0;
|
||||
if (!useBridge) {
|
||||
return this.sshClient.ssh(host, { extraArgs: parsedArgs.passthrough });
|
||||
}
|
||||
|
||||
const bridge = new SessionBridge({ host, mountManager: this.mountManager });
|
||||
await bridge.start();
|
||||
try {
|
||||
return await this.sshClient.ssh(host, {
|
||||
extraArgs: ['-t'],
|
||||
reverseForwards: [bridge.getReverseForwardSpec()],
|
||||
remoteCommand: bridge.buildRemoteBootstrapCommand(),
|
||||
exitOnForwardFailure: true,
|
||||
});
|
||||
} finally {
|
||||
await bridge.stop();
|
||||
}
|
||||
}
|
||||
|
||||
private async runProxy(positionals: string[], parsedArgs: IParsedArgs): Promise<number> {
|
||||
const host = positionals[0];
|
||||
const localForward = this.getStringFlag(parsedArgs.flags, 'local') ?? positionals[1];
|
||||
if (!host || !localForward) {
|
||||
throw new Error('Usage: dap proxy <host> --local <localPort>:<remoteHost>:<remotePort>');
|
||||
}
|
||||
return this.portProxy.start({ host, localForward });
|
||||
}
|
||||
|
||||
private async runMount(positionals: string[], parsedArgs: IParsedArgs): Promise<number> {
|
||||
const remoteSpec = positionals[0];
|
||||
const localPath = positionals[1];
|
||||
if (!remoteSpec || !localPath) {
|
||||
throw new Error('Usage: dap mount <host>:<remotePath> <localPath> [--backend sshfs|rclone]');
|
||||
}
|
||||
const parsedRemote = this.mountManager.parseRemoteSpec(remoteSpec);
|
||||
const backend = this.getStringFlag(parsedArgs.flags, 'backend') as TDapMountBackend | undefined;
|
||||
if (backend && backend !== 'sshfs' && backend !== 'rclone') {
|
||||
throw new Error('Mount backend must be sshfs or rclone');
|
||||
}
|
||||
return this.mountManager.mount({
|
||||
host: parsedRemote.host,
|
||||
remotePath: parsedRemote.remotePath,
|
||||
localPath,
|
||||
backend,
|
||||
});
|
||||
}
|
||||
|
||||
private async runUnmount(positionals: string[]): Promise<number> {
|
||||
const localPath = positionals[0];
|
||||
if (!localPath) {
|
||||
throw new Error('Usage: dap unmount <localPath>');
|
||||
}
|
||||
return this.mountManager.unmount(localPath);
|
||||
}
|
||||
|
||||
private async runDoctor(): Promise<number> {
|
||||
const checks: IDoctorCheck[] = [];
|
||||
checks.push(await this.checkCommand('ssh', 'OpenSSH client'));
|
||||
checks.push(await this.checkCommand('sshfs', 'Preferred mount backend'));
|
||||
checks.push(await this.checkCommand('rclone', 'Fallback mount backend'));
|
||||
checks.push(await this.checkSshConfig());
|
||||
checks.push(await this.checkFuse());
|
||||
|
||||
for (const check of checks) {
|
||||
console.log(`${check.ok ? 'ok' : 'missing'} ${check.name} - ${check.detail}`);
|
||||
}
|
||||
return checks.some((check) => !check.ok && check.name === 'ssh') ? 1 : 0;
|
||||
}
|
||||
|
||||
private async collectHostDefinition(
|
||||
aliasFromPosition: string | undefined,
|
||||
flags: Record<string, string | boolean | Array<string | boolean>>
|
||||
): Promise<IHostDefinition> {
|
||||
const initialDefinition = this.hostDefinitionFromFlags(aliasFromPosition, flags);
|
||||
if (initialDefinition.alias && initialDefinition.hostName) {
|
||||
return {
|
||||
...initialDefinition,
|
||||
port: initialDefinition.port ?? '22',
|
||||
};
|
||||
}
|
||||
|
||||
const rl = this.createReadline();
|
||||
try {
|
||||
const alias = initialDefinition.alias || (await rl.question('Host alias: ')).trim();
|
||||
const hostName = initialDefinition.hostName || (await rl.question('HostName: ')).trim();
|
||||
const user = initialDefinition.user || (await rl.question('User: ')).trim();
|
||||
const port = initialDefinition.port || (await this.questionWithDefault(rl, 'Port', '22'));
|
||||
const identityFile = initialDefinition.identityFile || (await rl.question('IdentityFile (optional): ')).trim();
|
||||
const proxyJump = initialDefinition.proxyJump || (await rl.question('ProxyJump (optional): ')).trim();
|
||||
return {
|
||||
alias,
|
||||
hostName,
|
||||
user,
|
||||
port,
|
||||
identityFile: identityFile || undefined,
|
||||
proxyJump: proxyJump || undefined,
|
||||
};
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
private async promptForHostChanges(alias: string): Promise<Partial<IHostDefinition>> {
|
||||
const host = await this.hostManager.getHost(alias);
|
||||
const rl = this.createReadline();
|
||||
try {
|
||||
return {
|
||||
hostName: await this.questionWithDefault(rl, 'HostName', host?.options.hostname?.[0] ?? ''),
|
||||
user: await this.questionWithDefault(rl, 'User', host?.options.user?.[0] ?? ''),
|
||||
port: await this.questionWithDefault(rl, 'Port', host?.options.port?.[0] ?? ''),
|
||||
identityFile: await this.questionWithDefault(rl, 'IdentityFile', host?.options.identityfile?.[0] ?? ''),
|
||||
proxyJump: await this.questionWithDefault(rl, 'ProxyJump', host?.options.proxyjump?.[0] ?? ''),
|
||||
};
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
private hostDefinitionFromFlags(
|
||||
aliasFromPosition: string | undefined,
|
||||
flags: Record<string, string | boolean | Array<string | boolean>>
|
||||
): IHostDefinition {
|
||||
return {
|
||||
alias: this.getStringFlag(flags, 'alias') ?? this.getStringFlag(flags, 'host') ?? aliasFromPosition ?? '',
|
||||
hostName: this.getStringFlag(flags, 'hostname') ?? this.getStringFlag(flags, 'host-name'),
|
||||
user: this.getStringFlag(flags, 'user'),
|
||||
port: this.getStringFlag(flags, 'port'),
|
||||
identityFile: this.getStringFlag(flags, 'identity-file') ?? this.getStringFlag(flags, 'key'),
|
||||
proxyJump: this.getStringFlag(flags, 'proxy-jump'),
|
||||
localForwards: this.getStringArrayFlag(flags, 'local-forward'),
|
||||
remoteForwards: this.getStringArrayFlag(flags, 'remote-forward'),
|
||||
};
|
||||
}
|
||||
|
||||
private async questionWithDefault(
|
||||
rl: plugins.readline.Interface,
|
||||
label: string,
|
||||
defaultValue: string
|
||||
): Promise<string> {
|
||||
const suffix = defaultValue ? ` [${defaultValue}]` : '';
|
||||
const answer = (await rl.question(`${label}${suffix}: `)).trim();
|
||||
return answer || defaultValue;
|
||||
}
|
||||
|
||||
private async confirm(question: string, defaultValue: boolean): Promise<boolean> {
|
||||
const rl = this.createReadline();
|
||||
try {
|
||||
const suffix = defaultValue ? ' [Y/n]' : ' [y/N]';
|
||||
const answer = (await rl.question(`${question}${suffix} `)).trim().toLowerCase();
|
||||
if (!answer) {
|
||||
return defaultValue;
|
||||
}
|
||||
return answer === 'y' || answer === 'yes';
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
private printHost(host: ISshConfigHost): void {
|
||||
const alias = host.patterns.join(', ');
|
||||
const hostName = host.options.hostname?.[0] ?? '-';
|
||||
const user = host.options.user?.[0] ?? '-';
|
||||
const port = host.options.port?.[0] ?? '22';
|
||||
const managed = host.dapManaged ? 'dap' : 'ssh';
|
||||
console.log(`${alias}\t${user}@${hostName}:${port}\t${managed}\t${host.filePath}:${host.startLine}`);
|
||||
}
|
||||
|
||||
private async checkCommand(command: string, detail: string): Promise<IDoctorCheck> {
|
||||
const ok = await this.sshClient.commandExists(command);
|
||||
return {
|
||||
name: command,
|
||||
ok,
|
||||
detail: ok ? detail : `${detail} is not installed or not in PATH`,
|
||||
};
|
||||
}
|
||||
|
||||
private async checkSshConfig(): Promise<IDoctorCheck> {
|
||||
const configPath = plugins.path.join(plugins.os.homedir(), '.ssh', 'config');
|
||||
try {
|
||||
await plugins.fs.access(configPath);
|
||||
return { name: 'ssh config', ok: true, detail: configPath };
|
||||
} catch {
|
||||
return { name: 'ssh config', ok: false, detail: `${configPath} does not exist yet` };
|
||||
}
|
||||
}
|
||||
|
||||
private async checkFuse(): Promise<IDoctorCheck> {
|
||||
if (process.platform === 'darwin') {
|
||||
const macFusePath = '/Library/Filesystems/macfuse.fs';
|
||||
return {
|
||||
name: 'macFUSE',
|
||||
ok: plugins.fsSync.existsSync(macFusePath),
|
||||
detail: macFusePath,
|
||||
};
|
||||
}
|
||||
if (process.platform === 'linux') {
|
||||
return {
|
||||
name: 'FUSE',
|
||||
ok: plugins.fsSync.existsSync('/dev/fuse'),
|
||||
detail: '/dev/fuse',
|
||||
};
|
||||
}
|
||||
return { name: 'FUSE', ok: false, detail: `${process.platform} is not supported by dap yet` };
|
||||
}
|
||||
|
||||
private createReadline(): plugins.readline.Interface {
|
||||
return plugins.readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
}
|
||||
|
||||
private getStringFlag(
|
||||
flags: Record<string, string | boolean | Array<string | boolean>>,
|
||||
flagName: string
|
||||
): string | undefined {
|
||||
const value = flags[flagName];
|
||||
if (Array.isArray(value)) {
|
||||
const lastValue = value[value.length - 1];
|
||||
return typeof lastValue === 'string' ? lastValue : undefined;
|
||||
}
|
||||
return typeof value === 'string' ? value : undefined;
|
||||
}
|
||||
|
||||
private getStringArrayFlag(
|
||||
flags: Record<string, string | boolean | Array<string | boolean>>,
|
||||
flagName: string
|
||||
): string[] | undefined {
|
||||
const value = flags[flagName];
|
||||
if (Array.isArray(value)) {
|
||||
return value.filter((entry): entry is string => typeof entry === 'string');
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return [value];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private getBooleanFlag(
|
||||
flags: Record<string, string | boolean | Array<string | boolean>>,
|
||||
flagName: string
|
||||
): boolean {
|
||||
return flags[flagName] === true;
|
||||
}
|
||||
|
||||
private addFlag(
|
||||
flags: Record<string, string | boolean | Array<string | boolean>>,
|
||||
flagName: string,
|
||||
flagValue: string | boolean
|
||||
): void {
|
||||
const existingValue = flags[flagName];
|
||||
if (existingValue === undefined) {
|
||||
flags[flagName] = flagValue;
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(existingValue)) {
|
||||
existingValue.push(flagValue);
|
||||
return;
|
||||
}
|
||||
flags[flagName] = [existingValue, flagValue];
|
||||
}
|
||||
|
||||
private printHelp(): void {
|
||||
console.log(`dap - data access proxy for SSH machines
|
||||
|
||||
Usage:
|
||||
dap
|
||||
dap list
|
||||
dap add [alias] [--hostname value] [--user value] [--port 22] [--identity-file path] [--local-forward spec] [--remote-forward spec]
|
||||
dap edit <host> [--hostname value] [--user value] [--port value] [--identity-file path] [--proxy-jump host] [--local-forward spec] [--remote-forward spec]
|
||||
dap ssh <host> [--no-bridge] [-- <ssh args>]
|
||||
dap proxy <host> --local <localPort>:<remoteHost>:<remotePort>
|
||||
dap mount <host>:<remotePath> <localPath> [--backend sshfs|rclone]
|
||||
dap unmount <localPath>
|
||||
dap doctor
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { SshConfig } from './classes.sshconfig.js';
|
||||
import { SshConfigWriter } from './classes.sshconfigwriter.js';
|
||||
import type {
|
||||
IHostDefinition,
|
||||
IHostUpdatePreview,
|
||||
IHostWriteResult,
|
||||
ISshConfigHost,
|
||||
ISshConfigOptions,
|
||||
} from './types.js';
|
||||
|
||||
export class HostManager {
|
||||
private sshConfig: SshConfig;
|
||||
private sshConfigWriter: SshConfigWriter;
|
||||
|
||||
constructor(options: ISshConfigOptions = {}) {
|
||||
this.sshConfig = new SshConfig(options);
|
||||
this.sshConfigWriter = new SshConfigWriter(options);
|
||||
}
|
||||
|
||||
public async listHosts(): Promise<ISshConfigHost[]> {
|
||||
const result = await this.sshConfig.read();
|
||||
return this.sshConfig.getDisplayHosts(result);
|
||||
}
|
||||
|
||||
public async getHost(alias: string): Promise<ISshConfigHost | undefined> {
|
||||
return this.sshConfig.getHost(alias);
|
||||
}
|
||||
|
||||
public async addHost(hostDefinition: IHostDefinition): Promise<IHostWriteResult> {
|
||||
return this.sshConfigWriter.addOrReplaceManagedHost(hostDefinition);
|
||||
}
|
||||
|
||||
public async previewEditHost(
|
||||
alias: string,
|
||||
changes: Partial<IHostDefinition>
|
||||
): Promise<IHostUpdatePreview> {
|
||||
return this.sshConfigWriter.previewUpdateHost(alias, changes);
|
||||
}
|
||||
|
||||
public async editHost(alias: string, changes: Partial<IHostDefinition>): Promise<IHostWriteResult> {
|
||||
return this.sshConfigWriter.updateHost(alias, changes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import { SshClient } from './classes.sshclient.js';
|
||||
import type { IMountRequest, TDapMountBackend } from './types.js';
|
||||
|
||||
export class MountManager {
|
||||
constructor(private sshClient = new SshClient()) {}
|
||||
|
||||
public parseRemoteSpec(remoteSpec: string): { host: string; remotePath: string } {
|
||||
const separatorIndex = remoteSpec.indexOf(':');
|
||||
if (separatorIndex <= 0) {
|
||||
throw new Error('Remote mount spec must look like <host>:<remotePath>');
|
||||
}
|
||||
const host = remoteSpec.slice(0, separatorIndex);
|
||||
const remotePath = remoteSpec.slice(separatorIndex + 1) || '.';
|
||||
return { host, remotePath };
|
||||
}
|
||||
|
||||
public buildSshfsArgs(request: IMountRequest): string[] {
|
||||
return [`${request.host}:${request.remotePath}`, request.localPath];
|
||||
}
|
||||
|
||||
public buildRcloneArgs(request: IMountRequest): string[] {
|
||||
return [
|
||||
'mount',
|
||||
`:sftp:${request.remotePath}`,
|
||||
request.localPath,
|
||||
'--sftp-ssh',
|
||||
`ssh ${request.host}`,
|
||||
'--sftp-shell-type',
|
||||
'none',
|
||||
'--vfs-cache-mode',
|
||||
'writes',
|
||||
];
|
||||
}
|
||||
|
||||
public async mount(request: IMountRequest): Promise<number> {
|
||||
await plugins.fs.mkdir(request.localPath, { recursive: true });
|
||||
const backend = request.backend ?? (await this.detectBackend());
|
||||
if (backend === 'sshfs') {
|
||||
return this.sshClient.spawnInteractive('sshfs', this.buildSshfsArgs(request));
|
||||
}
|
||||
return this.sshClient.spawnInteractive('rclone', this.buildRcloneArgs(request));
|
||||
}
|
||||
|
||||
public async mountDetached(request: IMountRequest): Promise<number> {
|
||||
await plugins.fs.mkdir(request.localPath, { recursive: true });
|
||||
const backend = request.backend ?? (await this.detectBackend());
|
||||
if (backend === 'sshfs') {
|
||||
return this.sshClient.spawnInteractive('sshfs', this.buildSshfsArgs(request));
|
||||
}
|
||||
return this.sshClient.spawnDetached('rclone', this.buildRcloneArgs(request));
|
||||
}
|
||||
|
||||
public async unmount(localPath: string): Promise<number> {
|
||||
if (process.platform === 'darwin') {
|
||||
return this.sshClient.spawnInteractive('umount', [localPath]);
|
||||
}
|
||||
if (await this.sshClient.commandExists('fusermount3')) {
|
||||
return this.sshClient.spawnInteractive('fusermount3', ['-u', localPath]);
|
||||
}
|
||||
if (await this.sshClient.commandExists('fusermount')) {
|
||||
return this.sshClient.spawnInteractive('fusermount', ['-u', localPath]);
|
||||
}
|
||||
return this.sshClient.spawnInteractive('umount', [localPath]);
|
||||
}
|
||||
|
||||
public async detectBackend(): Promise<TDapMountBackend> {
|
||||
if (await this.sshClient.commandExists('sshfs')) {
|
||||
return 'sshfs';
|
||||
}
|
||||
if (await this.sshClient.commandExists('rclone')) {
|
||||
return 'rclone';
|
||||
}
|
||||
throw new Error('No mount backend found. Install sshfs, or install rclone with FUSE/macFUSE support.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { SshClient } from './classes.sshclient.js';
|
||||
import type { IProxyRequest } from './types.js';
|
||||
|
||||
export class PortProxy {
|
||||
constructor(private sshClient = new SshClient()) {}
|
||||
|
||||
public buildArgs(request: IProxyRequest): string[] {
|
||||
this.validateLocalForward(request.localForward);
|
||||
return this.sshClient.buildSshArgs(request.host, {
|
||||
localForwards: [request.localForward],
|
||||
extraArgs: ['-N'],
|
||||
exitOnForwardFailure: true,
|
||||
});
|
||||
}
|
||||
|
||||
public async start(request: IProxyRequest): Promise<number> {
|
||||
return this.sshClient.spawnInteractive('ssh', this.buildArgs(request));
|
||||
}
|
||||
|
||||
private validateLocalForward(localForward: string): void {
|
||||
const parts = localForward.split(':');
|
||||
if (parts.length < 3) {
|
||||
throw new Error('Local forward must look like <localPort>:<remoteHost>:<remotePort>');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import type { ICommandResult } from './types.js';
|
||||
|
||||
export interface ISshRunOptions {
|
||||
extraArgs?: string[];
|
||||
localForwards?: string[];
|
||||
reverseForwards?: string[];
|
||||
remoteCommand?: string;
|
||||
exitOnForwardFailure?: boolean;
|
||||
}
|
||||
|
||||
export class SshClient {
|
||||
public buildSshArgs(host: string, options: ISshRunOptions = {}): string[] {
|
||||
const args: string[] = [];
|
||||
if (options.exitOnForwardFailure || options.localForwards?.length || options.reverseForwards?.length) {
|
||||
args.push('-o', 'ExitOnForwardFailure=yes');
|
||||
}
|
||||
for (const localForward of options.localForwards ?? []) {
|
||||
args.push('-L', localForward);
|
||||
}
|
||||
for (const reverseForward of options.reverseForwards ?? []) {
|
||||
args.push('-R', reverseForward);
|
||||
}
|
||||
args.push(...(options.extraArgs ?? []));
|
||||
args.push(host);
|
||||
if (options.remoteCommand) {
|
||||
args.push(options.remoteCommand);
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
public async ssh(host: string, options: ISshRunOptions = {}): Promise<number> {
|
||||
return this.spawnInteractive('ssh', this.buildSshArgs(host, options));
|
||||
}
|
||||
|
||||
public async spawnInteractive(command: string, args: string[], options: plugins.SpawnOptions = {}): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = plugins.childProcess.spawn(command, args, {
|
||||
stdio: 'inherit',
|
||||
shell: false,
|
||||
...options,
|
||||
});
|
||||
|
||||
child.once('error', reject);
|
||||
child.once('exit', (code, signal) => {
|
||||
if (signal) {
|
||||
resolve(128);
|
||||
return;
|
||||
}
|
||||
resolve(code ?? 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public async spawnDetached(command: string, args: string[], options: plugins.SpawnOptions = {}): Promise<number> {
|
||||
const child = plugins.childProcess.spawn(command, args, {
|
||||
stdio: 'ignore',
|
||||
detached: true,
|
||||
shell: false,
|
||||
...options,
|
||||
});
|
||||
child.unref();
|
||||
return child.pid ?? 0;
|
||||
}
|
||||
|
||||
public async runCapture(command: string, args: string[], options: plugins.SpawnOptions = {}): Promise<ICommandResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = plugins.childProcess.spawn(command, args, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
shell: false,
|
||||
...options,
|
||||
});
|
||||
|
||||
const stdoutChunks: Buffer[] = [];
|
||||
const stderrChunks: Buffer[] = [];
|
||||
child.stdout?.on('data', (chunk) => stdoutChunks.push(Buffer.from(chunk)));
|
||||
child.stderr?.on('data', (chunk) => stderrChunks.push(Buffer.from(chunk)));
|
||||
child.once('error', reject);
|
||||
child.once('exit', (code) => {
|
||||
resolve({
|
||||
exitCode: code ?? 0,
|
||||
stdout: Buffer.concat(stdoutChunks).toString('utf8'),
|
||||
stderr: Buffer.concat(stderrChunks).toString('utf8'),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public async commandExists(command: string): Promise<boolean> {
|
||||
const result = await this.runCapture('/bin/sh', ['-lc', `command -v ${SshClient.quoteForSh(command)}`]);
|
||||
return result.exitCode === 0;
|
||||
}
|
||||
|
||||
public static quoteForSh(value: string): string {
|
||||
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import type {
|
||||
ISshConfigFile,
|
||||
ISshConfigHost,
|
||||
ISshConfigOptions,
|
||||
ISshConfigReadResult,
|
||||
} from './types.js';
|
||||
|
||||
export class SshConfig {
|
||||
public readonly homeDir: string;
|
||||
public readonly mainConfigPath: string;
|
||||
|
||||
constructor(options: ISshConfigOptions = {}) {
|
||||
this.homeDir = options.homeDir ?? plugins.os.homedir();
|
||||
this.mainConfigPath = this.expandHome(options.mainConfigPath ?? plugins.path.join(this.homeDir, '.ssh', 'config'));
|
||||
}
|
||||
|
||||
public async read(): Promise<ISshConfigReadResult> {
|
||||
const files: ISshConfigFile[] = [];
|
||||
const hosts: ISshConfigHost[] = [];
|
||||
const visitedFiles = new Set<string>();
|
||||
await this.readFileRecursive(this.mainConfigPath, visitedFiles, files, hosts, true);
|
||||
return {
|
||||
mainConfigPath: this.mainConfigPath,
|
||||
files,
|
||||
hosts,
|
||||
};
|
||||
}
|
||||
|
||||
public async getHost(alias: string): Promise<ISshConfigHost | undefined> {
|
||||
const result = await this.read();
|
||||
return result.hosts.find((host) => host.patterns.includes(alias));
|
||||
}
|
||||
|
||||
public getDisplayHosts(result: ISshConfigReadResult): ISshConfigHost[] {
|
||||
return result.hosts.filter((host) => host.patterns.some((pattern) => !this.patternHasWildcard(pattern)));
|
||||
}
|
||||
|
||||
public getFirstOption(host: ISshConfigHost, optionName: string): string | undefined {
|
||||
return host.options[optionName.toLowerCase()]?.[0];
|
||||
}
|
||||
|
||||
public expandHome(filePath: string): string {
|
||||
if (filePath === '~') {
|
||||
return this.homeDir;
|
||||
}
|
||||
if (filePath.startsWith('~/')) {
|
||||
return plugins.path.join(this.homeDir, filePath.slice(2));
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
private async readFileRecursive(
|
||||
filePath: string,
|
||||
visitedFiles: Set<string>,
|
||||
files: ISshConfigFile[],
|
||||
hosts: ISshConfigHost[],
|
||||
required: boolean
|
||||
): Promise<void> {
|
||||
const resolvedPath = plugins.path.resolve(this.expandHome(filePath));
|
||||
if (visitedFiles.has(resolvedPath)) {
|
||||
return;
|
||||
}
|
||||
visitedFiles.add(resolvedPath);
|
||||
|
||||
let content = '';
|
||||
try {
|
||||
content = await plugins.fs.readFile(resolvedPath, 'utf8');
|
||||
} catch (error) {
|
||||
const nodeError = error as NodeJS.ErrnoException;
|
||||
if (nodeError.code === 'ENOENT' && !required) {
|
||||
return;
|
||||
}
|
||||
if (nodeError.code === 'ENOENT' && required) {
|
||||
files.push({ filePath: resolvedPath, lines: [] });
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const lines = content.split(/\r?\n/);
|
||||
files.push({ filePath: resolvedPath, lines });
|
||||
const includePatterns: string[] = [];
|
||||
this.parseHostsInFile(resolvedPath, lines, hosts, includePatterns);
|
||||
|
||||
for (const includePattern of includePatterns) {
|
||||
const includeFiles = await this.expandIncludePattern(includePattern, resolvedPath);
|
||||
for (const includeFile of includeFiles) {
|
||||
await this.readFileRecursive(includeFile, visitedFiles, files, hosts, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private parseHostsInFile(
|
||||
filePath: string,
|
||||
lines: string[],
|
||||
hosts: ISshConfigHost[],
|
||||
includePatterns: string[]
|
||||
): void {
|
||||
let currentHost: ISshConfigHost | undefined;
|
||||
|
||||
const closeCurrentHost = (endLine: number) => {
|
||||
if (!currentHost) {
|
||||
return;
|
||||
}
|
||||
currentHost.endLine = endLine;
|
||||
currentHost.rawLines = lines.slice(currentHost.startLine - 1, endLine);
|
||||
hosts.push(currentHost);
|
||||
currentHost = undefined;
|
||||
};
|
||||
|
||||
lines.forEach((rawLine, index) => {
|
||||
const lineNumber = index + 1;
|
||||
const withoutComment = this.stripInlineComment(rawLine).trim();
|
||||
if (!withoutComment) {
|
||||
return;
|
||||
}
|
||||
|
||||
const directiveMatch = withoutComment.match(/^([^\s]+)\s+(.*)$/);
|
||||
if (!directiveMatch) {
|
||||
return;
|
||||
}
|
||||
|
||||
const directive = directiveMatch[1].toLowerCase();
|
||||
const value = directiveMatch[2].trim();
|
||||
|
||||
if (directive === 'include') {
|
||||
includePatterns.push(...this.shellSplit(value));
|
||||
return;
|
||||
}
|
||||
|
||||
if (directive === 'host' || directive === 'match') {
|
||||
closeCurrentHost(lineNumber - 1);
|
||||
}
|
||||
|
||||
if (directive === 'host') {
|
||||
const previousLine = lines[index - 1] ?? '';
|
||||
const dapBeginMatch = previousLine.match(/^\s*#\s*dap:begin\s+(.+)\s*$/i);
|
||||
currentHost = {
|
||||
patterns: this.shellSplit(value),
|
||||
filePath,
|
||||
startLine: lineNumber,
|
||||
endLine: lineNumber,
|
||||
options: {},
|
||||
rawLines: [],
|
||||
dapManaged: Boolean(dapBeginMatch),
|
||||
dapManagedName: dapBeginMatch?.[1]?.trim(),
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentHost) {
|
||||
const normalizedDirective = directive.toLowerCase();
|
||||
currentHost.options[normalizedDirective] = currentHost.options[normalizedDirective] ?? [];
|
||||
currentHost.options[normalizedDirective].push(value);
|
||||
}
|
||||
});
|
||||
|
||||
closeCurrentHost(lines.length);
|
||||
}
|
||||
|
||||
private stripInlineComment(line: string): string {
|
||||
let quote: string | undefined;
|
||||
let escaped = false;
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const char = line[i];
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (char === '\\') {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if ((char === '"' || char === "'") && !quote) {
|
||||
quote = char;
|
||||
continue;
|
||||
}
|
||||
if (char === quote) {
|
||||
quote = undefined;
|
||||
continue;
|
||||
}
|
||||
if (char === '#' && !quote && (i === 0 || /\s/.test(line[i - 1]))) {
|
||||
return line.slice(0, i);
|
||||
}
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
public shellSplit(input: string): string[] {
|
||||
const tokens: string[] = [];
|
||||
let current = '';
|
||||
let quote: string | undefined;
|
||||
let escaped = false;
|
||||
|
||||
for (const char of input) {
|
||||
if (escaped) {
|
||||
current += char;
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (char === '\\') {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if ((char === '"' || char === "'") && !quote) {
|
||||
quote = char;
|
||||
continue;
|
||||
}
|
||||
if (char === quote) {
|
||||
quote = undefined;
|
||||
continue;
|
||||
}
|
||||
if (/\s/.test(char) && !quote) {
|
||||
if (current) {
|
||||
tokens.push(current);
|
||||
current = '';
|
||||
}
|
||||
continue;
|
||||
}
|
||||
current += char;
|
||||
}
|
||||
|
||||
if (current) {
|
||||
tokens.push(current);
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
private async expandIncludePattern(pattern: string, includingFilePath: string): Promise<string[]> {
|
||||
const expandedPattern = this.expandHome(pattern);
|
||||
const absolutePattern = plugins.path.isAbsolute(expandedPattern)
|
||||
? expandedPattern
|
||||
: plugins.path.join(plugins.path.dirname(includingFilePath), expandedPattern);
|
||||
return this.expandGlob(absolutePattern);
|
||||
}
|
||||
|
||||
private async expandGlob(pattern: string): Promise<string[]> {
|
||||
const absolutePattern = plugins.path.resolve(pattern);
|
||||
if (!this.pathHasWildcard(absolutePattern)) {
|
||||
return plugins.fsSync.existsSync(absolutePattern) ? [absolutePattern] : [];
|
||||
}
|
||||
|
||||
const parsedPath = plugins.path.parse(absolutePattern);
|
||||
const segments = absolutePattern.slice(parsedPath.root.length).split(plugins.path.sep).filter(Boolean);
|
||||
const matches: string[] = [];
|
||||
|
||||
const walk = async (currentPath: string, segmentIndex: number): Promise<void> => {
|
||||
if (segmentIndex >= segments.length) {
|
||||
try {
|
||||
const stat = await plugins.fs.stat(currentPath);
|
||||
if (stat.isFile()) {
|
||||
matches.push(currentPath);
|
||||
}
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const segment = segments[segmentIndex];
|
||||
if (!this.pathHasWildcard(segment)) {
|
||||
await walk(plugins.path.join(currentPath, segment), segmentIndex + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
let entries: plugins.fsSync.Dirent[];
|
||||
try {
|
||||
entries = await plugins.fs.readdir(currentPath, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const matcher = this.globSegmentToRegExp(segment);
|
||||
for (const entry of entries) {
|
||||
if (!matcher.test(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
const nextPath = plugins.path.join(currentPath, entry.name);
|
||||
if (segmentIndex === segments.length - 1 || entry.isDirectory()) {
|
||||
await walk(nextPath, segmentIndex + 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await walk(parsedPath.root, 0);
|
||||
return matches.sort();
|
||||
}
|
||||
|
||||
private globSegmentToRegExp(segment: string): RegExp {
|
||||
const source = segment
|
||||
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
||||
.replace(/\*/g, '[^/]*')
|
||||
.replace(/\?/g, '[^/]');
|
||||
return new RegExp(`^${source}$`);
|
||||
}
|
||||
|
||||
private patternHasWildcard(pattern: string): boolean {
|
||||
return /[*?]/.test(pattern);
|
||||
}
|
||||
|
||||
private pathHasWildcard(filePath: string): boolean {
|
||||
return /[*?]/.test(filePath);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import { SshConfig } from './classes.sshconfig.js';
|
||||
import type {
|
||||
IHostDefinition,
|
||||
IHostUpdatePreview,
|
||||
IHostWriteResult,
|
||||
ISshConfigOptions,
|
||||
} from './types.js';
|
||||
|
||||
export class SshConfigWriter {
|
||||
public readonly sshConfig: SshConfig;
|
||||
public readonly mainConfigPath: string;
|
||||
|
||||
constructor(options: ISshConfigOptions = {}) {
|
||||
this.sshConfig = new SshConfig(options);
|
||||
this.mainConfigPath = this.sshConfig.mainConfigPath;
|
||||
}
|
||||
|
||||
public async addOrReplaceManagedHost(hostDefinition: IHostDefinition): Promise<IHostWriteResult> {
|
||||
this.validateAlias(hostDefinition.alias);
|
||||
const lines = await this.readMainLines();
|
||||
const nextBlock = this.buildManagedHostBlock(hostDefinition);
|
||||
const managedRange = this.findManagedRange(lines, hostDefinition.alias);
|
||||
const nextLines = [...lines];
|
||||
|
||||
if (managedRange) {
|
||||
nextLines.splice(managedRange.start, managedRange.end - managedRange.start + 1, ...nextBlock);
|
||||
} else {
|
||||
if (nextLines.length && nextLines[nextLines.length - 1].trim()) {
|
||||
nextLines.push('');
|
||||
}
|
||||
nextLines.push(...nextBlock);
|
||||
}
|
||||
|
||||
return this.writeIfChanged(lines, nextLines);
|
||||
}
|
||||
|
||||
public async previewUpdateHost(
|
||||
alias: string,
|
||||
changes: Partial<IHostDefinition>
|
||||
): Promise<IHostUpdatePreview> {
|
||||
this.validateAlias(alias);
|
||||
const lines = await this.readMainLines();
|
||||
const managedRange = this.findManagedRange(lines, alias);
|
||||
|
||||
if (managedRange) {
|
||||
const existingHost = await this.sshConfig.getHost(alias);
|
||||
const mergedDefinition = this.mergeHostDefinition(alias, existingHost?.options ?? {}, changes);
|
||||
const nextBlock = this.buildManagedHostBlock(mergedDefinition);
|
||||
const nextLines = [...lines];
|
||||
nextLines.splice(managedRange.start, managedRange.end - managedRange.start + 1, ...nextBlock);
|
||||
return {
|
||||
filePath: this.mainConfigPath,
|
||||
before: lines.join('\n'),
|
||||
after: nextLines.join('\n'),
|
||||
diff: this.createLineDiff(lines, nextLines),
|
||||
dapManaged: true,
|
||||
};
|
||||
}
|
||||
|
||||
const hostRange = this.findHostRange(lines, alias);
|
||||
if (!hostRange) {
|
||||
throw new Error(`Host "${alias}" was not found in ${this.mainConfigPath}`);
|
||||
}
|
||||
|
||||
const nextLines = [...lines];
|
||||
const block = lines.slice(hostRange.start, hostRange.end + 1);
|
||||
const updatedBlock = this.applyChangesToHostBlock(block, changes);
|
||||
nextLines.splice(hostRange.start, hostRange.end - hostRange.start + 1, ...updatedBlock);
|
||||
return {
|
||||
filePath: this.mainConfigPath,
|
||||
before: lines.join('\n'),
|
||||
after: nextLines.join('\n'),
|
||||
diff: this.createLineDiff(lines, nextLines),
|
||||
dapManaged: false,
|
||||
};
|
||||
}
|
||||
|
||||
public async updateHost(alias: string, changes: Partial<IHostDefinition>): Promise<IHostWriteResult> {
|
||||
const preview = await this.previewUpdateHost(alias, changes);
|
||||
return this.writeIfChanged(preview.before.split('\n'), preview.after.split('\n'));
|
||||
}
|
||||
|
||||
public buildManagedHostBlock(hostDefinition: IHostDefinition): string[] {
|
||||
this.validateAlias(hostDefinition.alias);
|
||||
const lines = [`# dap:begin ${hostDefinition.alias}`, `Host ${hostDefinition.alias}`];
|
||||
this.addOptionalLine(lines, 'HostName', hostDefinition.hostName);
|
||||
this.addOptionalLine(lines, 'User', hostDefinition.user);
|
||||
this.addOptionalLine(lines, 'Port', hostDefinition.port);
|
||||
this.addOptionalLine(lines, 'IdentityFile', hostDefinition.identityFile);
|
||||
this.addOptionalLine(lines, 'ProxyJump', hostDefinition.proxyJump);
|
||||
for (const localForward of hostDefinition.localForwards ?? []) {
|
||||
this.addOptionalLine(lines, 'LocalForward', localForward);
|
||||
}
|
||||
for (const remoteForward of hostDefinition.remoteForwards ?? []) {
|
||||
this.addOptionalLine(lines, 'RemoteForward', remoteForward);
|
||||
}
|
||||
lines.push(`# dap:end ${hostDefinition.alias}`);
|
||||
return lines;
|
||||
}
|
||||
|
||||
private addOptionalLine(lines: string[], optionName: string, value: string | undefined): void {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
lines.push(` ${optionName} ${this.formatSshValue(value)}`);
|
||||
}
|
||||
|
||||
private formatSshValue(value: string): string {
|
||||
if (!/\s/.test(value)) {
|
||||
return value;
|
||||
}
|
||||
return `"${value.replace(/["\\]/g, '\\$&')}"`;
|
||||
}
|
||||
|
||||
private mergeHostDefinition(
|
||||
alias: string,
|
||||
options: Record<string, string[]>,
|
||||
changes: Partial<IHostDefinition>
|
||||
): IHostDefinition {
|
||||
return {
|
||||
alias,
|
||||
hostName: changes.hostName ?? options.hostname?.[0],
|
||||
user: changes.user ?? options.user?.[0],
|
||||
port: changes.port ?? options.port?.[0],
|
||||
identityFile: changes.identityFile ?? options.identityfile?.[0],
|
||||
proxyJump: changes.proxyJump ?? options.proxyjump?.[0],
|
||||
localForwards: changes.localForwards ?? options.localforward,
|
||||
remoteForwards: changes.remoteForwards ?? options.remoteforward,
|
||||
};
|
||||
}
|
||||
|
||||
private applyChangesToHostBlock(block: string[], changes: Partial<IHostDefinition>): string[] {
|
||||
const optionMap = new Map<string, { name: string; values: string[] }>();
|
||||
if (changes.hostName) optionMap.set('hostname', { name: 'HostName', values: [changes.hostName] });
|
||||
if (changes.user) optionMap.set('user', { name: 'User', values: [changes.user] });
|
||||
if (changes.port) optionMap.set('port', { name: 'Port', values: [changes.port] });
|
||||
if (changes.identityFile) optionMap.set('identityfile', { name: 'IdentityFile', values: [changes.identityFile] });
|
||||
if (changes.proxyJump) optionMap.set('proxyjump', { name: 'ProxyJump', values: [changes.proxyJump] });
|
||||
if (changes.localForwards?.length) {
|
||||
optionMap.set('localforward', { name: 'LocalForward', values: changes.localForwards });
|
||||
}
|
||||
if (changes.remoteForwards?.length) {
|
||||
optionMap.set('remoteforward', { name: 'RemoteForward', values: changes.remoteForwards });
|
||||
}
|
||||
|
||||
const seenOptions = new Set<string>();
|
||||
const updatedBlock: string[] = [];
|
||||
let indent = ' ';
|
||||
|
||||
block.forEach((line, index) => {
|
||||
if (index > 0) {
|
||||
const optionMatch = line.match(/^(\s*)([^\s#]+)\s+(.+)$/);
|
||||
if (optionMatch) {
|
||||
indent = optionMatch[1] || indent;
|
||||
const normalizedOptionName = optionMatch[2].toLowerCase();
|
||||
const nextOption = optionMap.get(normalizedOptionName);
|
||||
if (nextOption) {
|
||||
if (!seenOptions.has(normalizedOptionName)) {
|
||||
for (const value of nextOption.values) {
|
||||
updatedBlock.push(`${indent}${nextOption.name} ${this.formatSshValue(value)}`);
|
||||
}
|
||||
seenOptions.add(normalizedOptionName);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
updatedBlock.push(line);
|
||||
});
|
||||
|
||||
const missingOptions = [...optionMap.entries()].filter(([optionName]) => !seenOptions.has(optionName));
|
||||
if (missingOptions.length) {
|
||||
const insertAt = Math.min(1, updatedBlock.length);
|
||||
const missingLines = missingOptions.flatMap(([, option]) =>
|
||||
option.values.map((value) => `${indent}${option.name} ${this.formatSshValue(value)}`)
|
||||
);
|
||||
updatedBlock.splice(insertAt, 0, ...missingLines);
|
||||
}
|
||||
|
||||
return updatedBlock;
|
||||
}
|
||||
|
||||
private findManagedRange(lines: string[], alias: string): { start: number; end: number } | undefined {
|
||||
const beginMatcher = new RegExp(`^\\s*#\\s*dap:begin\\s+${this.escapeRegExp(alias)}\\s*$`, 'i');
|
||||
const endMatcher = new RegExp(`^\\s*#\\s*dap:end\\s+${this.escapeRegExp(alias)}\\s*$`, 'i');
|
||||
const start = lines.findIndex((line) => beginMatcher.test(line));
|
||||
if (start < 0) {
|
||||
return undefined;
|
||||
}
|
||||
const relativeEnd = lines.slice(start + 1).findIndex((line) => endMatcher.test(line));
|
||||
if (relativeEnd < 0) {
|
||||
throw new Error(`DAP-managed block for "${alias}" is missing its dap:end marker`);
|
||||
}
|
||||
return { start, end: start + 1 + relativeEnd };
|
||||
}
|
||||
|
||||
private findHostRange(lines: string[], alias: string): { start: number; end: number } | undefined {
|
||||
let start = -1;
|
||||
const parser = new SshConfig({ mainConfigPath: this.mainConfigPath });
|
||||
|
||||
for (let index = 0; index < lines.length; index++) {
|
||||
const trimmedLine = lines[index].trim();
|
||||
const hostMatch = trimmedLine.match(/^Host\s+(.+)$/i);
|
||||
const sectionBoundary = /^(Host|Match)\s+/i.test(trimmedLine);
|
||||
|
||||
if (sectionBoundary && start >= 0) {
|
||||
return { start, end: index - 1 };
|
||||
}
|
||||
|
||||
if (hostMatch) {
|
||||
const patterns = parser.shellSplit(hostMatch[1]);
|
||||
if (patterns.includes(alias)) {
|
||||
start = index;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (start >= 0) {
|
||||
return { start, end: lines.length - 1 };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private async readMainLines(): Promise<string[]> {
|
||||
try {
|
||||
const content = await plugins.fs.readFile(this.mainConfigPath, 'utf8');
|
||||
return content.replace(/\r\n/g, '\n').split('\n');
|
||||
} catch (error) {
|
||||
const nodeError = error as NodeJS.ErrnoException;
|
||||
if (nodeError.code === 'ENOENT') {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async writeIfChanged(beforeLines: string[], afterLines: string[]): Promise<IHostWriteResult> {
|
||||
const before = beforeLines.join('\n');
|
||||
const after = this.ensureTrailingNewline(afterLines.join('\n'));
|
||||
if (this.ensureTrailingNewline(before) === after) {
|
||||
return { changed: false, filePath: this.mainConfigPath };
|
||||
}
|
||||
|
||||
await plugins.fs.mkdir(plugins.path.dirname(this.mainConfigPath), { recursive: true, mode: 0o700 });
|
||||
const backupPath = await this.createBackupIfExisting();
|
||||
await plugins.fs.writeFile(this.mainConfigPath, after, { mode: 0o600 });
|
||||
return { changed: true, backupPath, filePath: this.mainConfigPath };
|
||||
}
|
||||
|
||||
private async createBackupIfExisting(): Promise<string | undefined> {
|
||||
if (!plugins.fsSync.existsSync(this.mainConfigPath)) {
|
||||
return undefined;
|
||||
}
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const backupPath = `${this.mainConfigPath}.dap-backup-${timestamp}`;
|
||||
await plugins.fs.copyFile(this.mainConfigPath, backupPath);
|
||||
return backupPath;
|
||||
}
|
||||
|
||||
private ensureTrailingNewline(value: string): string {
|
||||
return value.endsWith('\n') ? value : `${value}\n`;
|
||||
}
|
||||
|
||||
private createLineDiff(beforeLines: string[], afterLines: string[]): string {
|
||||
const diffLines = [`--- ${this.mainConfigPath}`, `+++ ${this.mainConfigPath}`];
|
||||
const maxLength = Math.max(beforeLines.length, afterLines.length);
|
||||
for (let index = 0; index < maxLength; index++) {
|
||||
const beforeLine = beforeLines[index];
|
||||
const afterLine = afterLines[index];
|
||||
if (beforeLine === afterLine) {
|
||||
continue;
|
||||
}
|
||||
if (beforeLine !== undefined) {
|
||||
diffLines.push(`-${beforeLine}`);
|
||||
}
|
||||
if (afterLine !== undefined) {
|
||||
diffLines.push(`+${afterLine}`);
|
||||
}
|
||||
}
|
||||
return diffLines.join('\n');
|
||||
}
|
||||
|
||||
private validateAlias(alias: string): void {
|
||||
if (!alias || /\s/.test(alias)) {
|
||||
throw new Error('SSH host alias must be a non-empty value without whitespace');
|
||||
}
|
||||
}
|
||||
|
||||
private escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
import { DapCli } from './classes.dapcli.js';
|
||||
|
||||
export { DapCli } from './classes.dapcli.js';
|
||||
export { HostManager } from './classes.hostmanager.js';
|
||||
export { MountManager } from './classes.mountmanager.js';
|
||||
export { PortProxy } from './classes.portproxy.js';
|
||||
export { SessionBridge } from './classes.sessionbridge.js';
|
||||
export { SshClient } from './classes.sshclient.js';
|
||||
export { SshConfig } from './classes.sshconfig.js';
|
||||
export { SshConfigWriter } from './classes.sshconfigwriter.js';
|
||||
export type * from './types.js';
|
||||
|
||||
export const runCli = async (): Promise<void> => {
|
||||
const cli = new DapCli();
|
||||
const exitCode = await cli.run();
|
||||
process.exitCode = exitCode;
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
// node native scope
|
||||
import * as childProcess from 'node:child_process';
|
||||
import * as crypto from 'node:crypto';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as fsSync from 'node:fs';
|
||||
import * as http from 'node:http';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import * as readline from 'node:readline/promises';
|
||||
import * as stream from 'node:stream';
|
||||
|
||||
export { childProcess, crypto, fs, fsSync, http, os, path, readline, stream };
|
||||
|
||||
export type { ChildProcess, SpawnOptions } from 'node:child_process';
|
||||
export type { IncomingMessage, Server, ServerResponse } from 'node:http';
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
export type TDapMountBackend = 'sshfs' | 'rclone';
|
||||
|
||||
export interface ISshConfigOptions {
|
||||
homeDir?: string;
|
||||
mainConfigPath?: string;
|
||||
}
|
||||
|
||||
export interface ISshConfigFile {
|
||||
filePath: string;
|
||||
lines: string[];
|
||||
}
|
||||
|
||||
export interface ISshConfigHost {
|
||||
patterns: string[];
|
||||
filePath: string;
|
||||
startLine: number;
|
||||
endLine: number;
|
||||
options: Record<string, string[]>;
|
||||
rawLines: string[];
|
||||
dapManaged: boolean;
|
||||
dapManagedName?: string;
|
||||
}
|
||||
|
||||
export interface ISshConfigReadResult {
|
||||
mainConfigPath: string;
|
||||
files: ISshConfigFile[];
|
||||
hosts: ISshConfigHost[];
|
||||
}
|
||||
|
||||
export interface IHostDefinition {
|
||||
alias: string;
|
||||
hostName?: string;
|
||||
user?: string;
|
||||
port?: string;
|
||||
identityFile?: string;
|
||||
proxyJump?: string;
|
||||
localForwards?: string[];
|
||||
remoteForwards?: string[];
|
||||
}
|
||||
|
||||
export interface IHostWriteResult {
|
||||
changed: boolean;
|
||||
backupPath?: string;
|
||||
filePath: string;
|
||||
}
|
||||
|
||||
export interface IHostUpdatePreview {
|
||||
filePath: string;
|
||||
before: string;
|
||||
after: string;
|
||||
diff: string;
|
||||
dapManaged: boolean;
|
||||
}
|
||||
|
||||
export interface ICommandResult {
|
||||
exitCode: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
export interface IMountRequest {
|
||||
host: string;
|
||||
remotePath: string;
|
||||
localPath: string;
|
||||
backend?: TDapMountBackend;
|
||||
}
|
||||
|
||||
export interface IProxyRequest {
|
||||
host: string;
|
||||
localForward: string;
|
||||
}
|
||||
|
||||
export interface IDoctorCheck {
|
||||
name: string;
|
||||
ok: boolean;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface IParsedArgs {
|
||||
positional: string[];
|
||||
flags: Record<string, string | boolean | Array<string | boolean>>;
|
||||
passthrough: string[];
|
||||
}
|
||||
Reference in New Issue
Block a user