@eliware/vyops 1.0.13 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,7 +4,8 @@
4
4
 
5
5
  ## Features
6
6
 
7
- - SSH-key authentication via `ssh2`, with optional password bootstrap via stdin.
7
+ - SSH-key authentication via the shared `@eliware/ssh-client` library, with
8
+ optional password bootstrap via stdin.
8
9
  - Native VyOS curly-brace configuration validation.
9
10
  - Candidate load, `compare`, `commit-confirm`, confirmation, and save.
10
11
  - Complete `scripts/` tree synchronization, including post-commit hooks,
@@ -27,7 +28,7 @@ npm install
27
28
  The package provides:
28
29
 
29
30
  ```text
30
- vyops Deploy or dry-run a configuration.
31
+ vyops Preflight or release a configuration bundle.
31
32
  ```
32
33
 
33
34
  ## Configuration
@@ -35,42 +36,54 @@ vyops Deploy or dry-run a configuration.
35
36
  | Variable | Required | Default | Purpose |
36
37
  |---|---|---|---|
37
38
  | `VYOPS_SSH_KEY` | No | `$HOME/.ssh/id_rsa` | Private SSH key path. |
38
- | `SSH_AUTH_SOCK` | No | unset | SSH agent socket passed to `ssh2`. |
39
+ | `SSH_AUTH_SOCK` | No | unset | SSH agent socket passed to the shared SSH client. |
40
+ | `SSH_KNOWN_HOSTS` | No | `~/.ssh/known_hosts` | Known-hosts file used for host verification. |
41
+ | `SSH_HOST_CA` | No | unset | Trusted SSH host-CA public key for certificate verification. |
39
42
  | `LOG_LEVEL` | No | `info` | Winston log level (`error`, `warn`, `info`, `http`, `verbose`, `debug`, or `silly`). |
40
43
 
41
- The target must be supplied as `user@host`. The config must use native VyOS curly-brace syntax.
44
+ The config must use native VyOS curly-brace syntax. The bundle's `system host-name`
45
+ and single `system login user` entry provide the release target automatically.
42
46
 
43
47
  For a router that has not received its SSH key yet, provide the SSH password through stdin:
44
48
 
45
49
  ```sh
46
- printf '%s\n' "$VYOS_PASSWORD" | vyops --password-stdin vyos@new-router config.boot
50
+ printf '%s\n' "$VYOS_PASSWORD" | vyops release --password-stdin /path/to/config.boot
47
51
  ```
48
52
 
49
53
  Password mode still requires the target host key to be present in `known_hosts`; it does not disable host verification. Passwords are not accepted as command-line arguments or written to logs.
50
54
 
51
55
  ## Usage
52
56
 
53
- Deploy:
57
+ Preflight a bundle without connecting:
54
58
 
55
59
  ```sh
56
- ./vyops.mjs vyos@core1 /path/to/config.boot
60
+ vyops preflight /path/to/config.boot
61
+ ```
62
+
63
+ Release a bundle:
64
+
65
+ ```sh
66
+ vyops release /path/to/config.boot
57
67
  ```
58
68
 
59
69
  Console switches:
60
70
 
61
71
  ```sh
62
- ./vyops.mjs --help
63
- ./vyops.mjs --version
64
- ./vyops.mjs --dry-run vyos@core1 /path/to/config.boot
65
- ./vyops.mjs --force --debug vyos@core1 /path/to/config.boot
66
- ./vyops.mjs --backup vyos@core1 /path/to/backup
72
+ vyops --help
73
+ vyops --version
74
+ vyops --debug preflight /path/to/config.boot
75
+ vyops release --force --debug /path/to/config.boot
76
+ vyops backup vyos@core1 /path/to/backup
67
77
  ```
68
78
 
69
- `--backup` downloads the active `/config/config.boot` and the complete
79
+ `backup` downloads the active `/config/config.boot` and the complete
70
80
  `/config/scripts` tree into the destination directory. It does not enter
71
81
  configuration mode or modify the router.
72
82
 
73
- `--dry-run` validates the config and skips SSH, commit, save, download, and Git pushback.
83
+ `preflight` validates the config and recursively checks the `scripts/` tree before
84
+ any SSH connection. Executable scripts must use LF line endings, a supported
85
+ shell shebang, and valid executable intent. `release` runs the same checks before
86
+ uploading or changing the router.
74
87
 
75
88
  The deployment workflow:
76
89
 
package/RELEASE_NOTES.md CHANGED
@@ -1,12 +1,40 @@
1
1
  # Release Notes
2
2
 
3
- ## Unreleased
4
-
3
+ ## 2.1.0
4
+
5
+ - **Breaking:** Replace the legacy positional deployment and `--dry-run`
6
+ workflows with the first-class `preflight`, `release`, and `backup`
7
+ commands.
8
+ - Add bundle preflight validation for recursively synchronized scripts,
9
+ including CRLF detection, shell shebang validation, and executable intent.
10
+ - Derive the release SSH target from the configuration's `system host-name`
11
+ and single `system login user` entry.
12
+ - Normalize executable script deployment permissions to `0755` and clean up
13
+ script staging after post-commit synchronization failures.
14
+ - Allow command switches such as `--debug` and `--password-stdin` before or
15
+ after the command name.
16
+
17
+ ## 2.0.0
18
+
19
+ - **Breaking:** Replace the direct `ssh2` integration with the shared
20
+ `@eliware/ssh-client` library for SSH, SFTP, interactive sessions, and
21
+ SSH host-CA verification.
22
+ - Update `@eliware/ssh-client` to 2.0.0 and `@eliware/test` to 2.0.0.
23
+ - Add cross-platform Windows and Ubuntu CI validation, including package,
24
+ audit, lint, and CLI smoke checks.
5
25
  - Close SFTP channels after uploads and downloads to prevent SSH channel exhaustion during script synchronization.
6
26
  - Reconnect after interactive deployments before downloading live configuration and cleaning up remote files.
7
27
  - Make Git path handling and the `test:gaps` coverage command portable across Windows and Linux.
8
28
  - Expand Windows and real-VyOS integration coverage to maintain 100% statements, branches, functions, and lines coverage.
9
29
 
30
+ Verification:
31
+
32
+ - `npm test` (100% coverage across statements, branches, functions, and lines)
33
+ - `npm run lint`
34
+ - `npm audit --omit=dev --audit-level=moderate`
35
+ - `npm run validate:package`
36
+ - CLI smoke test on Ubuntu and Windows
37
+
10
38
  ## 1.0.11
11
39
 
12
40
  - Fix VyOS 1.5 `commit-confirm` handling by answering confirmation prompts
package/package.json CHANGED
@@ -1,13 +1,12 @@
1
1
  {
2
2
  "name": "@eliware/vyops",
3
- "version": "1.0.13",
4
- "description": "Deploy native VyOS configuration files over SSH.",
3
+ "version": "2.1.0",
4
+ "description": "Preflight, release, and back up native VyOS configuration bundles over SSH.",
5
5
  "main": "vyops.mjs",
6
6
  "scripts": {
7
- "lint": "npx oxlint .",
8
- "test": "npx --node-options=\"--experimental-vm-modules --no-warnings\" jest --detectOpenHandles --silent --coverage",
9
- "deploy": "./vyops.mjs",
10
- "test:gaps": "node scripts/test-gaps.mjs",
7
+ "lint": "eliware-test --lint",
8
+ "test": "eliware-test",
9
+ "deploy": "node vyops.mjs",
11
10
  "pack": "npm pack --dry-run",
12
11
  "validate:package": "npm pack --dry-run --json | node scripts/validate-package.mjs",
13
12
  "audit": "node scripts/audit.mjs"
@@ -22,11 +21,10 @@
22
21
  "type": "module",
23
22
  "dependencies": {
24
23
  "@eliware/common": "^1.1.8",
25
- "ssh2": "^1.17.0"
24
+ "@eliware/ssh-client": "^2.0.0"
26
25
  },
27
26
  "devDependencies": {
28
- "jest": "^30.4.2",
29
- "oxlint": "^1.79.0"
27
+ "@eliware/test": "^2.0.0"
30
28
  },
31
29
  "bin": {
32
30
  "vyops": "./bin/vyops"
package/src/args.mjs CHANGED
@@ -1,16 +1,18 @@
1
1
  export const usage = `Usage:
2
- vyops [--dry-run] [--force] [--debug] [--password-stdin] <user@hostname-or-ip> <config.boot>
3
- vyops --backup [--debug] [--password-stdin] <user@hostname-or-ip> <destination-directory>
2
+ vyops preflight <config.boot>
3
+ vyops release [--force] [--debug] [--password-stdin] <config.boot>
4
+ vyops backup [--debug] [--password-stdin] <user@hostname-or-ip> <destination-directory>
4
5
  vyops --help
5
6
  vyops --version`;
6
7
 
7
8
  export function parseArgs(argv) {
8
9
  if (argv.length === 1 && argv[0] === '--help') return { help: true };
9
10
  if (argv.length === 1 && argv[0] === '--version') return { version: true };
10
-
11
+ const command = argv.find(value => !value.startsWith('--'));
12
+ if (!['preflight', 'release', 'backup'].includes(command)) throw new Error(usage);
11
13
  const options = new Set(argv.filter(value => value.startsWith('--')));
12
- if ([...options].some(option => !['--dry-run', '--force', '--debug', '--backup', '--password-stdin'].includes(option))) throw new Error(usage);
13
- const values = argv.filter(value => !value.startsWith('--'));
14
- if (values.length !== 2) throw new Error(usage);
15
- return { target: values[0], config: values[1], ...(options.has('--backup') ? { backup: true } : {}), ...(options.has('--dry-run') ? { dryRun: true } : {}), ...(options.has('--force') ? { force: true } : {}), ...(options.has('--debug') ? { debug: true } : {}), ...(options.has('--password-stdin') ? { passwordStdin: true } : {}) };
14
+ const values = argv.filter(value => !value.startsWith('--')).slice(1);
15
+ const allowed = command === 'release' ? ['--force', '--debug', '--password-stdin'] : ['--debug', '--password-stdin'];
16
+ if ([...options].some(option => !allowed.includes(option)) || values.length !== (command === 'backup' ? 2 : 1)) throw new Error(usage);
17
+ return { command, ...(command === 'backup' ? { target: values[0], config: values[1] } : { config: values[0] }), ...(options.has('--force') ? { force: true } : {}), ...(options.has('--debug') ? { debug: true } : {}), ...(options.has('--password-stdin') ? { passwordStdin: true } : {}) };
16
18
  }
package/src/bundle.mjs ADDED
@@ -0,0 +1,43 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import { join, relative } from 'node:path';
3
+
4
+ async function filesIn(directory, root = directory) {
5
+ const result = [];
6
+ let entries;
7
+ try { entries = await fs.readdir(directory, { withFileTypes: true }); }
8
+ catch (error) { if (error.code === 'ENOENT') return result; throw error; }
9
+ for (const entry of entries) {
10
+ const file = join(directory, entry.name);
11
+ if (entry.isDirectory()) result.push(...await filesIn(file, root));
12
+ else if (entry.isFile()) result.push({ file, name: relative(root, file).replaceAll('\\', '/') });
13
+ }
14
+ return result;
15
+ }
16
+
17
+ export function targetFromConfig(text) {
18
+ const host = text.match(/(?:^|\n)\s*host-name\s+(?:"([^"]+)"|'([^']+)'|(\S+))/)?.slice(1).find(Boolean);
19
+ const users = [...text.matchAll(/(?:^|\n)\s*user\s+([A-Za-z0-9._-]+)\s*\{/g)].map(match => match[1]);
20
+ if (!host) throw new Error('preflight failed: config does not define system host-name');
21
+ if (users.length !== 1) throw new Error(`preflight failed: expected exactly one system login user; found ${users.length}`);
22
+ return `${users[0]}@${host}`;
23
+ }
24
+
25
+ function scriptError(name, message) { throw new Error(`preflight failed: scripts/${name} ${message}`); }
26
+
27
+ export async function validateBundle(config, text, { extractTarget = true } = {}) {
28
+ const scripts = await filesIn(join(config, '..', 'scripts'));
29
+ for (const { file, name } of scripts) {
30
+ const data = await fs.readFile(file);
31
+ const firstLine = data.toString('utf8').split(/\n/, 1)[0].replace(/\r$/, '');
32
+ const executable = firstLine.startsWith('#!') || /(?:\.sh|\.script)$/.test(name)
33
+ || /^(?:commit\/post-hooks\.d\/|vyos-(?:pre|post)config-bootup\.script$)/.test(name);
34
+ if (!executable) continue;
35
+ if (data.includes(13)) scriptError(name, 'uses CRLF line endings; convert to LF');
36
+ if (!firstLine.startsWith('#!')) scriptError(name, 'is executable but has no shebang');
37
+ const interpreter = firstLine.slice(2).trim().split(/\s+/, 1)[0];
38
+ if (!['/bin/sh', '/bin/bash', '/bin/vbash', '/usr/bin/env'].includes(interpreter)) {
39
+ scriptError(name, `uses unsupported interpreter ${interpreter}`);
40
+ }
41
+ }
42
+ return { ...(extractTarget ? { target: targetFromConfig(text) } : {}), scripts };
43
+ }
package/src/deploy.mjs CHANGED
@@ -1,4 +1,6 @@
1
1
  import { randomUUID } from 'node:crypto';
2
+ import { join } from 'node:path';
3
+ import { readFile as readLocalFile } from 'node:fs/promises';
2
4
  import { fs, path as eliwarePath, log } from '@eliware/common';
3
5
  import { close, connect, download, exec, interactive, upload } from './ssh.mjs';
4
6
 
@@ -15,7 +17,7 @@ async function listScripts(directory, relative = '') {
15
17
  const entries = await fs.promises.readdir(directory, { withFileTypes: true });
16
18
  const files = [];
17
19
  for (const entry of entries) {
18
- const path = relative ? eliwarePath(relative, entry.name) : entry.name;
20
+ const path = (relative ? eliwarePath(relative, entry.name) : entry.name).replaceAll(String.fromCharCode(92), '/');
19
21
  if (entry.isDirectory?.()) {
20
22
  files.push(...await listScripts(eliwarePath(directory, entry.name), path));
21
23
  } else if (entry.isFile?.()) {
@@ -49,14 +51,24 @@ async function installScripts(client, config, log, runId) {
49
51
  const remote = `${remoteDir}/${name}`;
50
52
  const installed = `${installDir}/${name}`;
51
53
  const backup = `${backupDir}/${name}`;
52
- const parent = eliwarePath(name, '..');
53
- const mode = (await fs.promises.stat(local)).mode & 0o777;
54
+ const parent = eliwarePath(name, '..').replaceAll(String.fromCharCode(92), '/');
55
+ let content = Buffer.alloc(0);
56
+ try { content = await readLocalFile(local); }
57
+ /* istanbul ignore next -- missing files are only possible with a mocked directory listing. */
58
+ catch (error) {
59
+ /* istanbul ignore next -- missing files are only possible with a mocked directory listing. */
60
+ if (error.code !== 'ENOENT') throw error;
61
+ }
62
+ const mode = content.toString('utf8').startsWith('#!') || /\.(?:sh|script)$/.test(name)
63
+ || /^(?:commit[/\\]post-hooks\.d[/\\])/.test(name)
64
+ ? 0o755
65
+ : (await fs.promises.stat(local)).mode & 0o777;
54
66
  const backedUp = await exec(client, `if sudo test -e ${JSON.stringify(installed)}; then sudo mkdir -p ${JSON.stringify(`${backupDir}/${parent}`)} && sudo cp -p -- ${JSON.stringify(installed)} ${JSON.stringify(backup)}; fi`);
55
67
  if (backedUp.code !== 0) throw new Error(`script backup failed (${name}): ${backedUp.stderr || backedUp.stdout}`.trim());
56
68
  if (parent !== '.') {
57
69
  const remoteParent = `${remoteDir}/${parent}`;
58
70
  const remoteParentResult = await exec(client, `mkdir -p ${JSON.stringify(remoteParent)}`);
59
- if (remoteParentResult.code !== 0) throw new Error(`script upload directory setup failed (${name}): ${remoteParentResult.stderr || remoteParentResult.stdout}`.trim());
71
+ if (remoteParentResult.code !== 0) throw new Error(`script upload directory setup failed (${join(parent, name.slice(parent.length + 1))}): ${remoteParentResult.stderr || remoteParentResult.stdout}`.trim());
60
72
  }
61
73
  await upload(client, local, remote);
62
74
  const installedResult = await exec(client, `sudo mkdir -p ${JSON.stringify(`${installDir}/${parent}`)} && sudo install -m ${mode.toString(8)} ${JSON.stringify(remote)} ${JSON.stringify(installed)}`);
@@ -123,7 +135,10 @@ export async function deploy({ target, config, password }) {
123
135
  }
124
136
  return 0;
125
137
  } catch (error) {
126
- if (finalizeHooks && !hooksFinalized && !deploymentCommitted) await finalizeHooks(false, client);
138
+ if (finalizeHooks && !hooksFinalized) {
139
+ await finalizeHooks(deploymentCommitted, client);
140
+ hooksFinalized = true;
141
+ }
127
142
  throw error;
128
143
  } finally {
129
144
  debugLog('cleaning up remote file');
package/src/git.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  import { execFile } from 'node:child_process';
2
2
  import { promises as fs } from 'node:fs';
3
+ import { relative, resolve } from 'node:path';
3
4
  import { promisify } from 'node:util';
4
5
  import { path } from '@eliware/common';
5
6
 
@@ -59,6 +60,14 @@ function configDirectory(config) {
59
60
  return path(config, '..');
60
61
  }
61
62
 
63
+ async function relativeConfigPath(repo, config) {
64
+ const [canonicalRepo, canonicalConfig] = await Promise.all([
65
+ fs.realpath(repo),
66
+ fs.realpath(resolve(config)),
67
+ ]);
68
+ return relative(canonicalRepo, canonicalConfig);
69
+ }
70
+
62
71
  async function repositoryRoot(config) {
63
72
  try {
64
73
  const { stdout } = await git(['rev-parse', '--show-toplevel'], configDirectory(config));
@@ -72,8 +81,8 @@ async function repositoryRoot(config) {
72
81
  export async function shouldSkip(config) {
73
82
  const repo = await repositoryRoot(config);
74
83
  if (!repo) return false;
75
- const relative = config.startsWith(repo) ? config.slice(repo.length).replace(/^[/\\]/, '') : config;
76
- const { stdout: status } = await git(['status', '--porcelain', '--', relative], repo);
84
+ const relativePath = await relativeConfigPath(repo, config);
85
+ const { stdout: status } = await git(['status', '--porcelain', '--', relativePath], repo);
77
86
  if (status.trim()) return false;
78
87
  const { stdout: subject } = await git(['log', '-1', '--format=%s'], repo);
79
88
  return subject.trim().startsWith('Pushback ');
@@ -83,12 +92,12 @@ export async function pushBack(config, { force = false } = {}) {
83
92
  const repo = await repositoryRoot(config);
84
93
  if (!repo) return false;
85
94
  return withRepositoryLock(repo, async () => {
86
- const relative = config.startsWith(repo) ? config.slice(repo.length).replace(/^[/\\]/, '') : config;
87
- const { stdout: diff } = await git(['diff', 'HEAD', '--', relative], repo);
95
+ const relativePath = await relativeConfigPath(repo, config);
96
+ const { stdout: diff } = await git(['diff', 'HEAD', '--', relativePath], repo);
88
97
  if (!diff) return false;
89
- await git(['add', '--', relative], repo);
98
+ await git(['add', '--', relativePath], repo);
90
99
  const timestamp = new Date().toISOString().replace('T', ' ').slice(0, 19);
91
- await git(['commit', '--only', '-m', `Pushback ${timestamp}`, '--', relative], repo);
100
+ await git(['commit', '--only', '-m', `Pushback ${timestamp}`, '--', relativePath], repo);
92
101
  await git(['push'], repo);
93
102
  return true;
94
103
  }, force);
package/src/main.mjs CHANGED
@@ -3,6 +3,7 @@ import packageJson from '../package.json' with { type: 'json' };
3
3
  import { deploy } from './deploy.mjs';
4
4
  import { backup } from './backup.mjs';
5
5
  import { readAndValidateConfig } from './validate.mjs';
6
+ import { validateBundle, targetFromConfig } from './bundle.mjs';
6
7
  import { pushBack, shouldSkip } from './git.mjs';
7
8
  import { closeAll } from './ssh.mjs';
8
9
  import { log, registerHandlers, registerSignals } from '@eliware/common';
@@ -31,18 +32,26 @@ try {
31
32
  args.password = await readPasswordStdin();
32
33
  if (!args.password) throw new Error('password-stdin received an empty password');
33
34
  }
34
- if (args.backup) {
35
+ if (args.command === 'backup') {
35
36
  await backup(args);
36
37
  log.info(`Backup successful: ${args.config}`);
37
38
  process.exit(0);
38
39
  }
39
- log.debug(`[vyops] validating config: ${args.config}`);
40
- await readAndValidateConfig(args.config);
41
- log.debug('[vyops] config validation complete');
42
- if (args.dryRun) {
43
- log.info(`Configuration valid; dry run for ${args.target}`);
40
+ if (args.command === 'preflight') {
41
+ const text = await readAndValidateConfig(args.config);
42
+ const { target, scripts } = await validateBundle(args.config, text);
43
+ log.info(`Preflight successful: ${target} (${scripts.length} script files)`);
44
44
  process.exit(0);
45
45
  }
46
+ if (args.command === 'release') {
47
+ const text = await readAndValidateConfig(args.config);
48
+ await validateBundle(args.config, text);
49
+ args.target = targetFromConfig(text);
50
+ }
51
+ log.debug(`[vyops] validating config: ${args.config}`);
52
+ const configText = await readAndValidateConfig(args.config);
53
+ if (args.command === 'release') await validateBundle(args.config, configText, { extractTarget: true });
54
+ log.debug('[vyops] config validation complete');
46
55
  if (!args.force && await shouldSkip(args.config)) {
47
56
  log.info('Latest commit is Pushback and config is unchanged; skipping deployment');
48
57
  process.exit(0);
package/src/ssh.mjs CHANGED
@@ -1,8 +1,5 @@
1
- import { createHmac } from 'node:crypto';
2
1
  import { fs, log } from '@eliware/common';
3
- import ssh2 from 'ssh2';
4
-
5
- const { Client, utils } = ssh2;
2
+ import { connect as sharedConnect } from '@eliware/ssh-client';
6
3
 
7
4
  const CONNECT_TIMEOUT = 30000;
8
5
  const OPERATION_TIMEOUT = 60000;
@@ -23,75 +20,17 @@ export function parseTarget(target) {
23
20
  return { username, host };
24
21
  }
25
22
 
26
- function wildcardMatch(pattern, value) {
27
- const expression = `^${pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.')}$`;
28
- return new RegExp(expression).test(value);
29
- }
30
-
31
- function hostMatches(pattern, hosts) {
32
- if (pattern.startsWith('|1|')) {
33
- const [, , salt, digest] = pattern.split('|');
34
- if (!salt || !digest) return false;
35
- return hosts.some(host => createHmac('sha1', Buffer.from(salt, 'base64')).update(host).digest('base64') === digest);
36
- }
37
- return hosts.some(host => wildcardMatch(pattern, host));
38
- }
39
-
40
- function keyMatches(expected, actual) {
41
- try {
42
- const parsed = utils.parseKey(`${expected.type} ${expected.data}`);
43
- return parsed.getPublicSSH().equals(actual);
44
- } catch {
45
- return false;
46
- }
47
- }
48
-
49
- function verifyKnownHost(host, key, knownHosts) {
50
- const hosts = [host, `[${host}]:22`];
51
- let matched = false;
52
- for (const line of knownHosts.split(/\r?\n/)) {
53
- const trimmed = line.trim();
54
- if (!trimmed || trimmed.startsWith('#')) continue;
55
- const fields = trimmed.split(/\s+/);
56
- if (fields[0].startsWith('@')) {
57
- if (fields[0] === '@revoked' && fields[1]?.split(',').some(pattern => hostMatches(pattern, hosts))) return false;
58
- continue;
59
- }
60
- if (fields.length < 3) continue;
61
- const [hostList, type, data] = fields;
62
- const patterns = hostList.split(',');
63
- if (patterns.some(pattern => pattern.startsWith('!') && hostMatches(pattern.slice(1), hosts))) return false;
64
- if (patterns.some(pattern => !pattern.startsWith('!') && hostMatches(pattern, hosts))
65
- && keyMatches({ type, data }, key)) matched = true;
66
- }
67
- return matched;
68
- }
69
-
70
23
  export async function connect(target, { password } = {}) {
71
24
  const { username, host } = parseTarget(target);
72
25
  log.debug(`[vyops] SSH connecting: ${username}@${host}`);
73
- const privateKey = password === undefined
74
- ? await fs.promises.readFile(process.env.VYOPS_SSH_KEY || `${process.env.HOME}/.ssh/id_rsa`)
75
- : undefined;
76
- const knownHostsPath = `${process.env.HOME}/.ssh/known_hosts`;
77
- const knownHosts = await fs.promises.readFile(knownHostsPath, 'utf8');
78
- return new Promise((resolve, reject) => {
79
- const client = new Client();
80
- client.once('ready', () => { log.debug(`[vyops] SSH connected: ${username}@${host}`); resolve(client); });
81
- client.once('error', reject);
82
- client.once('close', () => activeClients.delete(client));
83
- activeClients.add(client);
84
- client.connect({
85
- host,
86
- username,
87
- agent: process.env.SSH_AUTH_SOCK,
88
- privateKey,
89
- ...(password === undefined ? {} : { password }),
90
- hostVerifier: (key, callback) => callback(verifyKnownHost(host, key, knownHosts)),
91
- readyTimeout: CONNECT_TIMEOUT,
92
- authTimeout: CONNECT_TIMEOUT,
93
- });
94
- });
26
+ const connection = await sharedConnect({ host, username,
27
+ privateKeyPath: password === undefined ? (process.env.VYOPS_SSH_KEY || undefined) : undefined,
28
+ agent: process.env.SSH_AUTH_SOCK, knownHostsPath: process.env.SSH_KNOWN_HOSTS || '~/.ssh/known_hosts',
29
+ hostCaPath: process.env.SSH_HOST_CA, password, connectTimeout: CONNECT_TIMEOUT });
30
+ const client = connection.raw;
31
+ activeClients.add(client);
32
+ log.debug(`[vyops] SSH connected: ${username}@${host}`);
33
+ return client;
95
34
  }
96
35
 
97
36
  export function exec(client, command) {