@eliware/vyops 1.0.12 → 2.0.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,
@@ -35,7 +36,9 @@ 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
44
  The target must be supplied as `user@host`. The config must use native VyOS curly-brace syntax.
@@ -53,17 +56,17 @@ Password mode still requires the target host key to be present in `known_hosts`;
53
56
  Deploy:
54
57
 
55
58
  ```sh
56
- ./vyops.mjs vyos@core1 /path/to/config.boot
59
+ vyops vyos@core1 /path/to/config.boot
57
60
  ```
58
61
 
59
62
  Console switches:
60
63
 
61
64
  ```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
65
+ vyops --help
66
+ vyops --version
67
+ vyops --dry-run vyos@core1 /path/to/config.boot
68
+ vyops --force --debug vyos@core1 /path/to/config.boot
69
+ vyops --backup vyos@core1 /path/to/backup
67
70
  ```
68
71
 
69
72
  `--backup` downloads the active `/config/config.boot` and the complete
package/RELEASE_NOTES.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # Release Notes
2
2
 
3
+ ## 2.0.0
4
+
5
+ - **Breaking:** Replace the direct `ssh2` integration with the shared
6
+ `@eliware/ssh-client` library for SSH, SFTP, interactive sessions, and
7
+ SSH host-CA verification.
8
+ - Update `@eliware/ssh-client` to 2.0.0 and `@eliware/test` to 2.0.0.
9
+ - Add cross-platform Windows and Ubuntu CI validation, including package,
10
+ audit, lint, and CLI smoke checks.
11
+ - Close SFTP channels after uploads and downloads to prevent SSH channel exhaustion during script synchronization.
12
+ - Reconnect after interactive deployments before downloading live configuration and cleaning up remote files.
13
+ - Make Git path handling and the `test:gaps` coverage command portable across Windows and Linux.
14
+ - Expand Windows and real-VyOS integration coverage to maintain 100% statements, branches, functions, and lines coverage.
15
+
16
+ Verification:
17
+
18
+ - `npm test` (100% coverage across statements, branches, functions, and lines)
19
+ - `npm run lint`
20
+ - `npm audit --omit=dev --audit-level=moderate`
21
+ - `npm run validate:package`
22
+ - CLI smoke test on Ubuntu and Windows
23
+
3
24
  ## 1.0.11
4
25
 
5
26
  - Fix VyOS 1.5 `commit-confirm` handling by answering confirmation prompts
package/package.json CHANGED
@@ -1,16 +1,15 @@
1
1
  {
2
2
  "name": "@eliware/vyops",
3
- "version": "1.0.12",
3
+ "version": "2.0.0",
4
4
  "description": "Deploy native VyOS configuration files 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",
7
+ "lint": "eliware-test --lint",
8
+ "test": "eliware-test",
9
9
  "deploy": "./vyops.mjs",
10
- "test:gaps": "npm test 2>&1 | grep '|' | grep -vF '| 100 | 100 | 100 | 100 |'",
11
10
  "pack": "npm pack --dry-run",
12
11
  "validate:package": "npm pack --dry-run --json | node scripts/validate-package.mjs",
13
- "audit": "npm audit --omit=dev --audit-level=moderate"
12
+ "audit": "node scripts/audit.mjs"
14
13
  },
15
14
  "keywords": [
16
15
  "vyos",
@@ -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/deploy.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto';
2
+ import { join } from 'node:path';
2
3
  import { fs, path as eliwarePath, log } from '@eliware/common';
3
4
  import { close, connect, download, exec, interactive, upload } from './ssh.mjs';
4
5
 
@@ -15,7 +16,7 @@ async function listScripts(directory, relative = '') {
15
16
  const entries = await fs.promises.readdir(directory, { withFileTypes: true });
16
17
  const files = [];
17
18
  for (const entry of entries) {
18
- const path = relative ? eliwarePath(relative, entry.name) : entry.name;
19
+ const path = (relative ? eliwarePath(relative, entry.name) : entry.name).replaceAll(String.fromCharCode(92), '/');
19
20
  if (entry.isDirectory?.()) {
20
21
  files.push(...await listScripts(eliwarePath(directory, entry.name), path));
21
22
  } else if (entry.isFile?.()) {
@@ -49,14 +50,17 @@ async function installScripts(client, config, log, runId) {
49
50
  const remote = `${remoteDir}/${name}`;
50
51
  const installed = `${installDir}/${name}`;
51
52
  const backup = `${backupDir}/${name}`;
52
- const parent = eliwarePath(name, '..');
53
- const mode = (await fs.promises.stat(local)).mode & 0o777;
53
+ const parent = eliwarePath(name, '..').replaceAll(String.fromCharCode(92), '/');
54
+ const mode = /\.(?:sh|script)$/.test(name)
55
+ || /^(?:commit[/\\]post-hooks\.d[/\\])/.test(name)
56
+ ? 0o755
57
+ : (await fs.promises.stat(local)).mode & 0o777;
54
58
  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
59
  if (backedUp.code !== 0) throw new Error(`script backup failed (${name}): ${backedUp.stderr || backedUp.stdout}`.trim());
56
60
  if (parent !== '.') {
57
61
  const remoteParent = `${remoteDir}/${parent}`;
58
62
  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());
63
+ 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
64
  }
61
65
  await upload(client, local, remote);
62
66
  const installedResult = await exec(client, `sudo mkdir -p ${JSON.stringify(`${installDir}/${parent}`)} && sudo install -m ${mode.toString(8)} ${JSON.stringify(remote)} ${JSON.stringify(installed)}`);
@@ -112,6 +116,9 @@ export async function deploy({ target, config, password }) {
112
116
  deploymentCommitted = true;
113
117
  const compare = extractCompare(output);
114
118
  if (compare) log.info(compare);
119
+ debugLog('reconnecting after interactive deployment sequence');
120
+ await close(client);
121
+ client = password === undefined ? await connect(target) : await connect(target, { password });
115
122
  debugLog(`syncing live config: /config/config.boot -> ${config}`);
116
123
  await download(client, '/config/config.boot', config);
117
124
  if (finalizeHooks) {
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 + 1) : 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 + 1) : 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/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) {
@@ -122,6 +61,7 @@ export async function upload(client, local, remote, mode = 0o600) {
122
61
  if (error) { clearTimeout(timer); return reject(error); }
123
62
  sftp.writeFile(remote, data, { mode }, error2 => {
124
63
  clearTimeout(timer);
64
+ sftp.end?.();
125
65
  return error2 ? reject(error2) : resolve();
126
66
  });
127
67
  });
@@ -136,6 +76,7 @@ export function download(client, remote, local) {
136
76
  if (error) { clearTimeout(timer); return reject(error); }
137
77
  sftp.fastGet(remote, local, error2 => {
138
78
  clearTimeout(timer);
79
+ sftp.end?.();
139
80
  return error2 ? reject(error2) : resolve();
140
81
  });
141
82
  });