@eliware/vyops 1.0.5 → 1.0.7

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,7 @@
4
4
 
5
5
  ## Features
6
6
 
7
- - SSH-key authentication via `ssh2`.
7
+ - SSH-key authentication via `ssh2`, with optional password bootstrap via stdin.
8
8
  - Native VyOS curly-brace configuration validation.
9
9
  - Candidate load, `compare`, `commit-confirm`, confirmation, and save.
10
10
  - Complete `scripts/` tree synchronization, including post-commit hooks,
@@ -40,6 +40,14 @@ vyops Deploy or dry-run a configuration.
40
40
 
41
41
  The target must be supplied as `user@host`. The config must use native VyOS curly-brace syntax.
42
42
 
43
+ For a router that has not received its SSH key yet, provide the SSH password through stdin:
44
+
45
+ ```sh
46
+ printf '%s\n' "$VYOS_PASSWORD" | vyops --password-stdin vyos@new-router config.boot
47
+ ```
48
+
49
+ 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
+
43
51
  ## Usage
44
52
 
45
53
  Deploy:
@@ -55,8 +63,13 @@ Console switches:
55
63
  ./vyops.mjs --version
56
64
  ./vyops.mjs --dry-run vyos@core1 /path/to/config.boot
57
65
  ./vyops.mjs --force --debug vyos@core1 /path/to/config.boot
66
+ ./vyops.mjs --backup vyos@core1 /path/to/backup
58
67
  ```
59
68
 
69
+ `--backup` downloads the active `/config/config.boot` and the complete
70
+ `/config/scripts` tree into the destination directory. It does not enter
71
+ configuration mode or modify the router.
72
+
60
73
  `--dry-run` validates the config and skips SSH, commit, save, download, and Git pushback.
61
74
 
62
75
  The deployment workflow:
@@ -105,7 +118,7 @@ Deployments require existing SSH keys and a reachable VyOS target. The workflow
105
118
 
106
119
  ## Support
107
120
 
108
- Repository: `git@github.com:eliware/vyops-js.git`
121
+ Repository: `git@github.com:eliware/vyops.git`
109
122
 
110
123
  Open an issue or provide a focused patch with tests and validation results.
111
124
 
package/RELEASE_NOTES.md CHANGED
@@ -1,5 +1,37 @@
1
1
  # Release Notes
2
2
 
3
+ ## 1.0.7
4
+
5
+ - Add password-based SSH bootstrap through the secure `--password-stdin` mode.
6
+ - Preserve strict `known_hosts` verification when using password authentication.
7
+ - Handle VyOS return-only pager prompts and disable paging before deployment compares.
8
+ - Add packaged CLI, password-authentication, backup failure, and deployment regression tests.
9
+ - Add opt-in real-VyOS integration test scaffolding for dry-run and backup workflows.
10
+
11
+ Verification:
12
+
13
+ - `npm test` (100% coverage across statements, branches, functions, and lines)
14
+ - `npm run lint`
15
+ - `npm run audit`
16
+ - `npm run validate:package`
17
+
18
+ ## 1.0.6
19
+
20
+ - Add `--backup` to download the active `config.boot` and complete
21
+ `/config/scripts` tree into a local backup directory.
22
+ - Fail promptly on VyOS load and commit errors instead of waiting indefinitely
23
+ for confirmation prompts.
24
+ - Preserve synchronized scripts when post-commit local config download fails.
25
+ - Include staged configuration changes in Git pushback detection.
26
+ - Expand CLI, deployment, backup, Git, and SSH regression coverage.
27
+
28
+ Verification:
29
+
30
+ - `npm test` (100% coverage across statements, branches, functions, and lines)
31
+ - `npm run lint`
32
+ - `npm run audit`
33
+ - `npm run validate:package`
34
+
3
35
  ## 1.0.5
4
36
 
5
37
  - Create nested temporary directories before uploading synchronized scripts.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eliware/vyops",
3
- "version": "1.0.5",
3
+ "version": "1.0.7",
4
4
  "description": "Deploy native VyOS configuration files over SSH.",
5
5
  "main": "vyops.mjs",
6
6
  "scripts": {
@@ -21,12 +21,12 @@
21
21
  ],
22
22
  "type": "module",
23
23
  "dependencies": {
24
- "@eliware/common": "^1.1.7",
24
+ "@eliware/common": "^1.1.8",
25
25
  "ssh2": "^1.17.0"
26
26
  },
27
27
  "devDependencies": {
28
28
  "jest": "^30.4.2",
29
- "oxlint": "^1.77.0"
29
+ "oxlint": "^1.79.0"
30
30
  },
31
31
  "bin": {
32
32
  "vyops": "./bin/vyops"
@@ -48,4 +48,4 @@
48
48
  "LICENSE",
49
49
  "RELEASE_NOTES.md"
50
50
  ]
51
- }
51
+ }
package/src/args.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  export const usage = `Usage:
2
- vyops [--dry-run] [--force] [--debug] <user@hostname-or-ip> <config.boot>
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>
3
4
  vyops --help
4
5
  vyops --version`;
5
6
 
@@ -8,8 +9,8 @@ export function parseArgs(argv) {
8
9
  if (argv.length === 1 && argv[0] === '--version') return { version: true };
9
10
 
10
11
  const options = new Set(argv.filter(value => value.startsWith('--')));
11
- if ([...options].some(option => !['--dry-run', '--force', '--debug'].includes(option))) throw new Error(usage);
12
+ if ([...options].some(option => !['--dry-run', '--force', '--debug', '--backup', '--password-stdin'].includes(option))) throw new Error(usage);
12
13
  const values = argv.filter(value => !value.startsWith('--'));
13
14
  if (values.length !== 2) throw new Error(usage);
14
- return { target: values[0], config: values[1], ...(options.has('--dry-run') ? { dryRun: true } : {}), ...(options.has('--force') ? { force: true } : {}), ...(options.has('--debug') ? { debug: true } : {}) };
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 } : {}) };
15
16
  }
package/src/backup.mjs ADDED
@@ -0,0 +1,29 @@
1
+ import { fs, path, log } from '@eliware/common';
2
+ import { close, connect, download, exec } from './ssh.mjs';
3
+
4
+ function remoteScriptPath(value) {
5
+ const name = value.replace(/^\/config\/scripts\/?/, '');
6
+ if (!name || name.startsWith('/') || name.split('/').includes('..')) throw new Error(`unsafe remote script path: ${value}`);
7
+ return name;
8
+ }
9
+
10
+ export async function backup({ target, config, password }) {
11
+ const client = password === undefined ? await connect(target) : await connect(target, { password });
12
+ try {
13
+ await fs.promises.mkdir(config, { recursive: true });
14
+ await fs.promises.mkdir(path(config, 'scripts'), { recursive: true });
15
+ await download(client, '/config/config.boot', path(config, 'config.boot'));
16
+ const result = await exec(client, "find /config/scripts -type f -print 2>/dev/null");
17
+ if (result.code !== 0) throw new Error(`could not list remote scripts: ${result.stderr || result.stdout}`.trim());
18
+ const files = result.stdout.split(/\r?\n/).map(value => value.trim()).filter(Boolean).map(remoteScriptPath);
19
+ for (const name of files) {
20
+ const local = path(config, 'scripts', name);
21
+ await fs.promises.mkdir(path(local, '..'), { recursive: true });
22
+ await download(client, `/config/scripts/${name}`, local);
23
+ log.debug(`[vyops] backed up script: ${name}`);
24
+ }
25
+ return 0;
26
+ } finally {
27
+ await close(client);
28
+ }
29
+ }
package/src/deploy.mjs CHANGED
@@ -76,12 +76,14 @@ async function installScripts(client, config, log, runId) {
76
76
  };
77
77
  }
78
78
 
79
- export async function deploy({ target, config }) {
79
+ export async function deploy({ target, config, password }) {
80
80
  const debugLog = message => log.debug(`[vyops] ${message}`);
81
81
  debugLog(`connecting: ${target}`);
82
- const client = await connect(target);
82
+ const client = password === undefined ? await connect(target) : await connect(target, { password });
83
83
  debugLog('SSH connected');
84
84
  let finalizeHooks;
85
+ let hooksFinalized = false;
86
+ let deploymentCommitted = false;
85
87
  const runId = randomUUID();
86
88
  const remote = `/home/vyos/.config.deploy.${runId}`;
87
89
  try {
@@ -92,25 +94,30 @@ export async function deploy({ target, config }) {
92
94
  debugLog('starting interactive deployment sequence');
93
95
  const output = await interactive(client, [
94
96
  'configure',
95
- { command: `load ${remote}`, reject: /(?:load failed|error|invalid)/i },
97
+ { command: `load ${remote}`, reject: /(?:load failed|commit failed|commit aborted|cannot commit|configuration (?:commit )?failed|invalid configuration|error|invalid)/i },
98
+ 'run set terminal length 0',
96
99
  "printf '%s\\n' '--- compare ---'",
97
100
  'compare',
98
101
  "printf '%s\\n' '--- end compare ---'",
99
- { command: 'commit-confirm 5', reject: /(?:commit failed|error|invalid)/i },
102
+ { command: 'commit-confirm 5', reject: /(?:commit failed|commit aborted|cannot commit|configuration (?:commit )?failed|invalid configuration|error|invalid)/i },
100
103
  { command: 'confirm', reject: /(?:confirm failed|error|invalid)/i },
101
104
  { command: 'save', reject: /(?:save failed|error|invalid)/i },
102
105
  'exit',
103
106
  'exit',
104
107
  ], debugLog);
105
108
  debugLog(`interactive sequence returned (${output.length} bytes)`);
109
+ deploymentCommitted = true;
106
110
  const compare = extractCompare(output);
107
111
  if (compare) log.info(compare);
108
- if (finalizeHooks) await finalizeHooks(true);
109
112
  debugLog(`syncing live config: /config/config.boot -> ${config}`);
110
113
  await download(client, '/config/config.boot', config);
114
+ if (finalizeHooks) {
115
+ hooksFinalized = true;
116
+ await finalizeHooks(true);
117
+ }
111
118
  return 0;
112
119
  } catch (error) {
113
- if (finalizeHooks) await finalizeHooks(false);
120
+ if (finalizeHooks && !hooksFinalized && !deploymentCommitted) await finalizeHooks(false);
114
121
  throw error;
115
122
  } finally {
116
123
  debugLog('cleaning up remote file');
package/src/git.mjs CHANGED
@@ -75,7 +75,7 @@ export async function pushBack(config, { force = false } = {}) {
75
75
  const repo = root.trim();
76
76
  return withRepositoryLock(repo, async () => {
77
77
  const relative = config.startsWith(`${repo}/`) ? config.slice(repo.length + 1) : config;
78
- const { stdout: diff } = await git(['diff', '--', relative], repo);
78
+ const { stdout: diff } = await git(['diff', 'HEAD', '--', relative], repo);
79
79
  if (!diff) return false;
80
80
  await git(['add', '--', relative], repo);
81
81
  const timestamp = new Date().toISOString().replace('T', ' ').slice(0, 19);
package/src/main.mjs CHANGED
@@ -1,10 +1,12 @@
1
1
  import { parseArgs, usage } from './args.mjs';
2
2
  import packageJson from '../package.json' with { type: 'json' };
3
3
  import { deploy } from './deploy.mjs';
4
+ import { backup } from './backup.mjs';
4
5
  import { readAndValidateConfig } from './validate.mjs';
5
6
  import { pushBack, shouldSkip } from './git.mjs';
6
7
  import { closeAll } from './ssh.mjs';
7
8
  import { log, registerHandlers, registerSignals } from '@eliware/common';
9
+ import { readFile } from 'node:fs/promises';
8
10
 
9
11
  const errors = registerHandlers({ log });
10
12
  const signals = registerSignals({ log, shutdownHook: async () => closeAll() });
@@ -20,6 +22,15 @@ try {
20
22
  log.info(packageJson.version);
21
23
  process.exit(0);
22
24
  }
25
+ if (args.passwordStdin) {
26
+ args.password = (await readFile(0, 'utf8')).replace(/\r?\n$/, '');
27
+ if (!args.password) throw new Error('password-stdin received an empty password');
28
+ }
29
+ if (args.backup) {
30
+ await backup(args);
31
+ log.info(`Backup successful: ${args.config}`);
32
+ process.exit(0);
33
+ }
23
34
  log.debug(`[vyops] validating config: ${args.config}`);
24
35
  await readAndValidateConfig(args.config);
25
36
  log.debug('[vyops] config validation complete');
package/src/ssh.mjs CHANGED
@@ -67,10 +67,12 @@ function verifyKnownHost(host, key, knownHosts) {
67
67
  return matched;
68
68
  }
69
69
 
70
- export async function connect(target) {
70
+ export async function connect(target, { password } = {}) {
71
71
  const { username, host } = parseTarget(target);
72
72
  log.debug(`[vyops] SSH connecting: ${username}@${host}`);
73
- const privateKey = await fs.promises.readFile(process.env.VYOPS_SSH_KEY || `${process.env.HOME}/.ssh/id_rsa`);
73
+ const privateKey = password === undefined
74
+ ? await fs.promises.readFile(process.env.VYOPS_SSH_KEY || `${process.env.HOME}/.ssh/id_rsa`)
75
+ : undefined;
74
76
  const knownHostsPath = `${process.env.HOME}/.ssh/known_hosts`;
75
77
  const knownHosts = await fs.promises.readFile(knownHostsPath, 'utf8');
76
78
  return new Promise((resolve, reject) => {
@@ -84,6 +86,7 @@ export async function connect(target) {
84
86
  username,
85
87
  agent: process.env.SSH_AUTH_SOCK,
86
88
  privateKey,
89
+ ...(password === undefined ? {} : { password }),
87
90
  hostVerifier: (key, callback) => callback(verifyKnownHost(host, key, knownHosts)),
88
91
  readyTimeout: CONNECT_TIMEOUT,
89
92
  authTimeout: CONNECT_TIMEOUT,
@@ -183,12 +186,23 @@ export function interactive(client, commands, log = () => {}) {
183
186
  response += text;
184
187
  log(`recv (${text.length} bytes): ${JSON.stringify(text)}`);
185
188
  const cleaned = clean(response);
186
- const pager = /(?:^|\n):\s*$/.test(cleaned) || /--More--\s*$/i.test(cleaned);
189
+ if (waiting && typeof currentItem !== 'string' && currentItem.reject?.test(cleaned)) {
190
+ settled = true;
191
+ clearTimeout(timer);
192
+ stream.close();
193
+ reject(new Error(`interactive command failed: ${currentItem.command}`));
194
+ return;
195
+ }
196
+ const pagerReturn = /No next tag\s*\(press RETURN\)/i.test(cleaned);
197
+ const pager = /(?:^|\n):\s*$/.test(cleaned)
198
+ || /--More--\s*$/i.test(cleaned)
199
+ || pagerReturn;
187
200
  log(`state after data: index=${index}, waiting=${waiting}, answering=${answering}, prompt=${prompt(cleaned)}, pager=${pager}, bytes=${text.length}`);
188
201
  if (pager) {
189
- log('pager prompt detected; sending space');
190
- stream.write(' ');
191
- log('write complete: space');
202
+ const key = pagerReturn ? '\n' : ' ';
203
+ log(`pager prompt detected; sending ${pagerReturn ? 'return' : 'space'}`);
204
+ stream.write(key);
205
+ log(`write complete: ${pagerReturn ? 'return' : 'space'}`);
192
206
  return;
193
207
  }
194
208
  if (/Proceed\s*\?\s*\[Y\/n\]/i.test(cleaned) && !answering) {
@@ -207,13 +221,6 @@ export function interactive(client, commands, log = () => {}) {
207
221
  answering = false;
208
222
  waiting = false;
209
223
  log(`response [${index}]:\n${cleaned}`);
210
- if (typeof currentItem !== 'string' && currentItem.reject?.test(cleaned)) {
211
- settled = true;
212
- clearTimeout(timer);
213
- stream.close();
214
- reject(new Error(`interactive command failed: ${currentItem.command}`));
215
- return;
216
- }
217
224
  if (index >= commands.length) {
218
225
  settled = true;
219
226
  clearTimeout(timer);