feat(core): add SSH data access proxy CLI and core managers
This commit is contained in:
+20
@@ -0,0 +1,20 @@
|
||||
.nogit/
|
||||
|
||||
# artifacts
|
||||
coverage/
|
||||
public/
|
||||
pages/
|
||||
|
||||
# installs
|
||||
node_modules/
|
||||
|
||||
# caches
|
||||
.yarn/
|
||||
.cache/
|
||||
.rpt2_cache
|
||||
|
||||
# builds
|
||||
dist/
|
||||
dist_*/
|
||||
|
||||
# custom
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"npmts": {},
|
||||
"@git.zone/cli": {
|
||||
"schemaVersion": 2,
|
||||
"projectType": "npm",
|
||||
"module": {
|
||||
"githost": "code.foss.global",
|
||||
"gitscope": "mainlevel",
|
||||
"gitrepo": "dap",
|
||||
"description": "Data access proxy for SSH hosts, port forwards, and remote filesystem mounts.",
|
||||
"npmPackagename": "dap",
|
||||
"license": "MIT"
|
||||
},
|
||||
"release": {
|
||||
"preflight": {
|
||||
"requireCleanTree": true,
|
||||
"test": false,
|
||||
"build": true
|
||||
},
|
||||
"targets": {
|
||||
"git": {
|
||||
"enabled": true,
|
||||
"remote": "origin",
|
||||
"pushBranch": true,
|
||||
"pushTags": true
|
||||
},
|
||||
"npm": {
|
||||
"enabled": true,
|
||||
"registries": [
|
||||
"https://registry.npmjs.org"
|
||||
],
|
||||
"accessLevel": "public",
|
||||
"alreadyPublished": "success"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"@ship.zone/szci": {
|
||||
"npmGlobalTools": []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
# Changelog
|
||||
|
||||
## Pending
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
- add SSH data access proxy CLI and core managers (core)
|
||||
- Adds CLI commands for listing, adding, editing, connecting to, proxying, mounting, and checking SSH hosts.
|
||||
- Adds SSH config parsing and writing with managed host blocks, previews, diffs, and backups.
|
||||
- Adds SSH client, port proxy, mount backend, and session bridge components.
|
||||
- Adds package configuration, CLI entrypoint, documentation, and tests.
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env node
|
||||
process.env.CLI_CALL = 'true';
|
||||
const cliTool = await import('./dist_ts/index.js');
|
||||
await cliTool.runCli();
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) Task Venture Capital GmbH
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"name": "dap",
|
||||
"version": "2.0.0",
|
||||
"private": false,
|
||||
"description": "Data access proxy for SSH hosts, port forwards, and remote filesystem mounts.",
|
||||
"main": "dist_ts/index.js",
|
||||
"typings": "dist_ts/index.d.ts",
|
||||
"exports": {
|
||||
".": "./dist_ts/index.js"
|
||||
},
|
||||
"type": "module",
|
||||
"author": "Task Venture Capital GmbH",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"dap": "./cli.js"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "tstest test/",
|
||||
"build": "tsbuild",
|
||||
"buildDocs": "tsdoc"
|
||||
},
|
||||
"keywords": [
|
||||
"ssh",
|
||||
"ssh-config",
|
||||
"proxy",
|
||||
"port-forward",
|
||||
"sshfs",
|
||||
"rclone",
|
||||
"sftp",
|
||||
"fuse",
|
||||
"cli"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "ssh://git@code.foss.global:29419/mainlevel/dap.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://code.foss.global/mainlevel/dap/issues"
|
||||
},
|
||||
"homepage": "https://code.foss.global/mainlevel/dap#readme",
|
||||
"devDependencies": {
|
||||
"@git.zone/tsbuild": "^4.4.2",
|
||||
"@git.zone/tsdoc": "^2.0.6",
|
||||
"@git.zone/tstest": "^3.6.6",
|
||||
"@types/node": "^25.9.1",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"files": [
|
||||
"ts/**/*",
|
||||
"dist/**/*",
|
||||
"dist_*/**/*",
|
||||
"dist_ts/**/*",
|
||||
"cli.js",
|
||||
".smartconfig.json",
|
||||
"readme.md",
|
||||
"license.md"
|
||||
],
|
||||
"browserslist": [
|
||||
"last 1 chrome versions"
|
||||
],
|
||||
"packageManager": "pnpm@10.28.2"
|
||||
}
|
||||
Generated
+8091
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,326 @@
|
||||
# dap
|
||||
|
||||
`dap` is a data access proxy for SSH-based machines. It scans your OpenSSH config, lists configured hosts, adds and edits SSH host blocks, connects to remote machines, proxies remote ports, and mounts remote folders into your local filesystem.
|
||||
|
||||
`dap` uses OpenSSH as the source of truth. Your `~/.ssh/config`, SSH agent, keys, `ProxyJump`, known hosts, and normal `ssh` behavior remain in control.
|
||||
|
||||
## Issue Reporting and Security
|
||||
|
||||
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
|
||||
|
||||
## Install
|
||||
|
||||
Install globally:
|
||||
|
||||
```bash
|
||||
pnpm install -g dap
|
||||
```
|
||||
|
||||
Or install in a project:
|
||||
|
||||
```bash
|
||||
pnpm install dap
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
Open the interactive dashboard:
|
||||
|
||||
```bash
|
||||
dap
|
||||
```
|
||||
|
||||
List configured SSH hosts:
|
||||
|
||||
```bash
|
||||
dap list
|
||||
```
|
||||
|
||||
Add a host:
|
||||
|
||||
```bash
|
||||
dap add production --hostname 203.0.113.10 --user root --identity-file ~/.ssh/id_ed25519
|
||||
```
|
||||
|
||||
Connect to a host:
|
||||
|
||||
```bash
|
||||
dap ssh production
|
||||
```
|
||||
|
||||
Proxy a remote PostgreSQL port to your local machine:
|
||||
|
||||
```bash
|
||||
dap proxy production --local 5433:127.0.0.1:5432
|
||||
```
|
||||
|
||||
Mount a remote folder locally:
|
||||
|
||||
```bash
|
||||
dap mount production:/var/www ./mounts/production-www
|
||||
```
|
||||
|
||||
Check local system support:
|
||||
|
||||
```bash
|
||||
dap doctor
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
### `dap`
|
||||
|
||||
Starts the interactive dashboard.
|
||||
|
||||
```bash
|
||||
dap
|
||||
```
|
||||
|
||||
The dashboard can list hosts, add hosts, edit hosts, connect over SSH, start port proxies, mount remote paths, and run diagnostics.
|
||||
|
||||
### `dap list`
|
||||
|
||||
Lists hosts found in your SSH config.
|
||||
|
||||
```bash
|
||||
dap list
|
||||
```
|
||||
|
||||
`dap` reads the main config and included files.
|
||||
|
||||
### `dap add`
|
||||
|
||||
Adds a DAP-managed host block to the main SSH config.
|
||||
|
||||
```bash
|
||||
dap add staging --hostname staging.example.com --user deploy --port 22 --identity-file ~/.ssh/id_ed25519
|
||||
```
|
||||
|
||||
Generated block:
|
||||
|
||||
```sshconfig
|
||||
# dap:begin staging
|
||||
Host staging
|
||||
HostName staging.example.com
|
||||
User deploy
|
||||
Port 22
|
||||
IdentityFile ~/.ssh/id_ed25519
|
||||
# dap:end staging
|
||||
```
|
||||
|
||||
If required fields are missing, `dap add` asks for them interactively.
|
||||
|
||||
### `dap edit <host>`
|
||||
|
||||
Edits a host in the main SSH config.
|
||||
|
||||
```bash
|
||||
dap edit production --user deploy --identity-file ~/.ssh/id_ed25519
|
||||
```
|
||||
|
||||
Editable fields include:
|
||||
|
||||
- `HostName`
|
||||
- `User`
|
||||
- `Port`
|
||||
- `IdentityFile`
|
||||
- `ProxyJump`
|
||||
- `LocalForward`
|
||||
- `RemoteForward`
|
||||
|
||||
DAP-managed blocks are updated directly. Existing non-DAP host blocks are shown as a diff before writing unless `--yes` is passed.
|
||||
|
||||
### `dap ssh <host>`
|
||||
|
||||
Connects to a host using the system `ssh` binary.
|
||||
|
||||
```bash
|
||||
dap ssh production
|
||||
```
|
||||
|
||||
By default this starts a session bridge and temporarily places a `dap` command into the remote session `PATH`. The command is created in a temporary remote directory and removed when the SSH session exits.
|
||||
|
||||
Disable the bridge when you want a plain SSH call:
|
||||
|
||||
```bash
|
||||
dap ssh production --no-bridge
|
||||
```
|
||||
|
||||
Pass raw SSH arguments after `--`:
|
||||
|
||||
```bash
|
||||
dap ssh production -- -A
|
||||
```
|
||||
|
||||
### Remote Session `dap`
|
||||
|
||||
When you connect with `dap ssh <host>`, the remote shell receives a temporary `dap` command.
|
||||
|
||||
Inside the remote session:
|
||||
|
||||
```bash
|
||||
dap info
|
||||
```
|
||||
|
||||
Mount the current remote directory into a local path:
|
||||
|
||||
```bash
|
||||
dap mount . ./dap-mounts/project
|
||||
```
|
||||
|
||||
The remote command talks back to the local DAP session through an SSH reverse forward. The bridge uses a one-time token and only exposes explicit DAP actions.
|
||||
|
||||
The remote machine needs `curl` for bridged remote commands.
|
||||
|
||||
### `dap proxy <host>`
|
||||
|
||||
Starts an SSH local forward.
|
||||
|
||||
```bash
|
||||
dap proxy production --local 5433:127.0.0.1:5432
|
||||
```
|
||||
|
||||
This maps:
|
||||
|
||||
```text
|
||||
localhost:5433 -> production:127.0.0.1:5432
|
||||
```
|
||||
|
||||
Internally this uses:
|
||||
|
||||
```bash
|
||||
ssh -N -L 5433:127.0.0.1:5432 production
|
||||
```
|
||||
|
||||
### `dap mount <host>:<remotePath> <localPath>`
|
||||
|
||||
Mounts a remote path into your local filesystem.
|
||||
|
||||
```bash
|
||||
dap mount production:/srv/project ./mounts/project
|
||||
```
|
||||
|
||||
Backends:
|
||||
|
||||
- `sshfs`, preferred when available.
|
||||
- `rclone mount`, used as a fallback with the documented `--sftp-ssh` option so OpenSSH host aliases can still be used.
|
||||
|
||||
Force a backend:
|
||||
|
||||
```bash
|
||||
dap mount production:/srv/project ./mounts/project --backend sshfs
|
||||
```
|
||||
|
||||
Linux requires FUSE. macOS requires macFUSE.
|
||||
|
||||
### `dap unmount <localPath>`
|
||||
|
||||
Unmounts a local mount path.
|
||||
|
||||
```bash
|
||||
dap unmount ./mounts/project
|
||||
```
|
||||
|
||||
On Linux, `dap` uses `fusermount3`, `fusermount`, or `umount`. On macOS, it uses `umount`.
|
||||
|
||||
### `dap doctor`
|
||||
|
||||
Checks the local system for required tools and platform support.
|
||||
|
||||
```bash
|
||||
dap doctor
|
||||
```
|
||||
|
||||
It checks:
|
||||
|
||||
- `ssh`
|
||||
- `sshfs`
|
||||
- `rclone`
|
||||
- `~/.ssh/config`
|
||||
- FUSE on Linux
|
||||
- macFUSE on macOS
|
||||
|
||||
## SSH Config Handling
|
||||
|
||||
`dap` uses the main SSH config:
|
||||
|
||||
```text
|
||||
~/.ssh/config
|
||||
```
|
||||
|
||||
It also scans files referenced by `Include` directives.
|
||||
|
||||
When adding new hosts, `dap` writes managed blocks directly to the main config. Managed blocks are wrapped with markers:
|
||||
|
||||
```sshconfig
|
||||
# dap:begin production
|
||||
Host production
|
||||
HostName 203.0.113.10
|
||||
User root
|
||||
IdentityFile ~/.ssh/id_ed25519
|
||||
# dap:end production
|
||||
```
|
||||
|
||||
Before changing the main config, `dap` creates a timestamped backup:
|
||||
|
||||
```text
|
||||
~/.ssh/config.dap-backup-2026-05-30T12-30-00-000Z
|
||||
```
|
||||
|
||||
For existing non-DAP host blocks, `dap edit` shows a diff before writing.
|
||||
|
||||
## Mounting Notes
|
||||
|
||||
`sshfs` is the simplest backend because it maps directly to OpenSSH host aliases:
|
||||
|
||||
```bash
|
||||
sshfs production:/srv/project ./mounts/project
|
||||
```
|
||||
|
||||
`rclone mount` is available as a fallback. DAP invokes it with the external SSH option so your SSH config alias remains the connection entry point:
|
||||
|
||||
```bash
|
||||
rclone mount :sftp:/srv/project ./mounts/project --sftp-ssh "ssh production" --sftp-shell-type none
|
||||
```
|
||||
|
||||
## Safety Model
|
||||
|
||||
`dap` follows these rules:
|
||||
|
||||
- OpenSSH remains authoritative.
|
||||
- The main SSH config is backed up before writes.
|
||||
- DAP-created blocks are clearly marked.
|
||||
- Non-DAP host edits require diff confirmation by default.
|
||||
- Remote `dap` exists only for the current `dap ssh` session.
|
||||
- The remote session bridge uses a one-time token.
|
||||
- The bridge exposes explicit DAP actions, not arbitrary local shell execution.
|
||||
|
||||
## Platform Support
|
||||
|
||||
`dap` targets:
|
||||
|
||||
- Linux
|
||||
- macOS
|
||||
|
||||
Windows is not part of the initial scope.
|
||||
|
||||
## License and Legal Information
|
||||
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the repository [license.md](./license.md) file.
|
||||
|
||||
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
||||
|
||||
### Trademarks
|
||||
|
||||
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
|
||||
|
||||
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
|
||||
|
||||
### Company Information
|
||||
|
||||
Task Venture Capital GmbH<br>
|
||||
Registered at District Court Bremen HRB 35230 HB, Germany
|
||||
|
||||
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
||||
|
||||
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.
|
||||
@@ -0,0 +1,49 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
|
||||
import { DapCli, MountManager, PortProxy, SshClient } from '../ts/index.js';
|
||||
|
||||
tap.test('should build ssh and proxy command args', async () => {
|
||||
const sshClient = new SshClient();
|
||||
const sshArgs = sshClient.buildSshArgs('production', {
|
||||
localForwards: ['5433:127.0.0.1:5432'],
|
||||
reverseForwards: ['127.0.0.1:45000:127.0.0.1:3000'],
|
||||
remoteCommand: 'echo ok',
|
||||
});
|
||||
expect(sshArgs).toEqual([
|
||||
'-o',
|
||||
'ExitOnForwardFailure=yes',
|
||||
'-L',
|
||||
'5433:127.0.0.1:5432',
|
||||
'-R',
|
||||
'127.0.0.1:45000:127.0.0.1:3000',
|
||||
'production',
|
||||
'echo ok',
|
||||
]);
|
||||
|
||||
const portProxy = new PortProxy(sshClient);
|
||||
expect(portProxy.buildArgs({ host: 'production', localForward: '8080:127.0.0.1:80' })).toEqual([
|
||||
'-o',
|
||||
'ExitOnForwardFailure=yes',
|
||||
'-L',
|
||||
'8080:127.0.0.1:80',
|
||||
'-N',
|
||||
'production',
|
||||
]);
|
||||
});
|
||||
|
||||
tap.test('should parse mount specs and cli flags', async () => {
|
||||
const mountManager = new MountManager();
|
||||
expect(mountManager.parseRemoteSpec('production:/srv/app')).toEqual({
|
||||
host: 'production',
|
||||
remotePath: '/srv/app',
|
||||
});
|
||||
expect(
|
||||
mountManager.buildSshfsArgs({ host: 'production', remotePath: '/srv/app', localPath: './mount' })
|
||||
).toEqual(['production:/srv/app', './mount']);
|
||||
|
||||
const cli = new DapCli();
|
||||
expect(cli.parseArgs(['ssh', 'production', '--no-bridge', '--', '-A']).passthrough).toEqual(['-A']);
|
||||
expect(cli.parseArgs(['add', 'prod', '--hostname', 'example.com']).flags.hostname).toEqual('example.com');
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,49 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import { SshConfig } from '../ts/index.js';
|
||||
|
||||
tap.test('should read main ssh config and included hosts', async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'dap-sshconfig-'));
|
||||
try {
|
||||
const sshDir = path.join(tempDir, '.ssh');
|
||||
const includeDir = path.join(sshDir, 'config.d');
|
||||
await fs.mkdir(includeDir, { recursive: true });
|
||||
const mainConfigPath = path.join(sshDir, 'config');
|
||||
await fs.writeFile(
|
||||
mainConfigPath,
|
||||
[
|
||||
'Include config.d/*',
|
||||
'',
|
||||
'Host alpha',
|
||||
' HostName alpha.example.com',
|
||||
' User deploy',
|
||||
'',
|
||||
'# dap:begin beta',
|
||||
'Host beta',
|
||||
' HostName beta.example.com',
|
||||
'# dap:end beta',
|
||||
'',
|
||||
].join('\n')
|
||||
);
|
||||
await fs.writeFile(path.join(includeDir, 'hosts'), ['Host gamma', ' HostName gamma.example.com'].join('\n'));
|
||||
|
||||
const sshConfig = new SshConfig({ homeDir: tempDir, mainConfigPath });
|
||||
const result = await sshConfig.read();
|
||||
const aliases = result.hosts.map((host) => host.patterns[0]);
|
||||
|
||||
expect(aliases.includes('alpha')).toEqual(true);
|
||||
expect(aliases.includes('beta')).toEqual(true);
|
||||
expect(aliases.includes('gamma')).toEqual(true);
|
||||
expect(result.hosts.find((host) => host.patterns[0] === 'beta')?.dapManaged).toEqual(true);
|
||||
expect(result.hosts.find((host) => host.patterns[0] === 'alpha')?.options.hostname?.[0]).toEqual(
|
||||
'alpha.example.com'
|
||||
);
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,45 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import { SshConfigWriter } from '../ts/index.js';
|
||||
|
||||
tap.test('should add managed host blocks and update existing hosts', async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'dap-sshconfigwriter-'));
|
||||
try {
|
||||
const sshDir = path.join(tempDir, '.ssh');
|
||||
await fs.mkdir(sshDir, { recursive: true });
|
||||
const mainConfigPath = path.join(sshDir, 'config');
|
||||
await fs.writeFile(mainConfigPath, ['Host old', ' HostName old.example.com', ''].join('\n'));
|
||||
|
||||
const writer = new SshConfigWriter({ homeDir: tempDir, mainConfigPath });
|
||||
const addResult = await writer.addOrReplaceManagedHost({
|
||||
alias: 'alpha',
|
||||
hostName: 'alpha.example.com',
|
||||
user: 'deploy',
|
||||
port: '22',
|
||||
identityFile: '~/.ssh/id_ed25519',
|
||||
});
|
||||
|
||||
const addedContent = await fs.readFile(mainConfigPath, 'utf8');
|
||||
expect(addResult.changed).toEqual(true);
|
||||
expect(addedContent.includes('# dap:begin alpha')).toEqual(true);
|
||||
expect(addedContent.includes('HostName alpha.example.com')).toEqual(true);
|
||||
|
||||
const preview = await writer.previewUpdateHost('old', { user: 'root', port: '2222' });
|
||||
expect(preview.dapManaged).toEqual(false);
|
||||
expect(preview.diff.includes('+ User root')).toEqual(true);
|
||||
|
||||
const editResult = await writer.updateHost('old', { user: 'root', port: '2222' });
|
||||
const editedContent = await fs.readFile(mainConfigPath, 'utf8');
|
||||
expect(editResult.changed).toEqual(true);
|
||||
expect(Boolean(editResult.backupPath)).toEqual(true);
|
||||
expect(editedContent.includes(' User root')).toEqual(true);
|
||||
expect(editedContent.includes(' Port 2222')).toEqual(true);
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"esModuleInterop": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"types": [
|
||||
"node"
|
||||
]
|
||||
},
|
||||
"exclude": [
|
||||
"dist_*/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user