@eliware/vyops 1.0.6 → 1.0.8

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:
package/RELEASE_NOTES.md CHANGED
@@ -1,5 +1,35 @@
1
1
  # Release Notes
2
2
 
3
+ ## 1.0.8
4
+
5
+ - Fail immediately with a redacted VyOS error when confirmed commits reject a
6
+ configuration after the confirmation prompt.
7
+ - Prevent malformed PKI and other validation failures from appearing to hang
8
+ at the commit-confirm step.
9
+ - Add regression coverage for post-prompt validation errors and structured
10
+ failures without detail text.
11
+
12
+ Verification:
13
+
14
+ - `npm test` (100% coverage across statements, branches, functions, and lines)
15
+ - `npm run lint`
16
+ - `npm run audit`
17
+
18
+ ## 1.0.7
19
+
20
+ - Add password-based SSH bootstrap through the secure `--password-stdin` mode.
21
+ - Preserve strict `known_hosts` verification when using password authentication.
22
+ - Handle VyOS return-only pager prompts and disable paging before deployment compares.
23
+ - Add packaged CLI, password-authentication, backup failure, and deployment regression tests.
24
+ - Add opt-in real-VyOS integration test scaffolding for dry-run and backup workflows.
25
+
26
+ Verification:
27
+
28
+ - `npm test` (100% coverage across statements, branches, functions, and lines)
29
+ - `npm run lint`
30
+ - `npm run audit`
31
+ - `npm run validate:package`
32
+
3
33
  ## 1.0.6
4
34
 
5
35
  - Add `--backup` to download the active `config.boot` and complete
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eliware/vyops",
3
- "version": "1.0.6",
3
+ "version": "1.0.8",
4
4
  "description": "Deploy native VyOS configuration files over SSH.",
5
5
  "main": "vyops.mjs",
6
6
  "scripts": {
@@ -48,4 +48,4 @@
48
48
  "LICENSE",
49
49
  "RELEASE_NOTES.md"
50
50
  ]
51
- }
51
+ }
package/src/args.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  export const usage = `Usage:
2
- vyops [--dry-run] [--force] [--debug] <user@hostname-or-ip> <config.boot>
3
- vyops --backup [--debug] <user@hostname-or-ip> <destination-directory>
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>
4
4
  vyops --help
5
5
  vyops --version`;
6
6
 
@@ -9,8 +9,8 @@ export function parseArgs(argv) {
9
9
  if (argv.length === 1 && argv[0] === '--version') return { version: true };
10
10
 
11
11
  const options = new Set(argv.filter(value => value.startsWith('--')));
12
- if ([...options].some(option => !['--dry-run', '--force', '--debug', '--backup'].includes(option))) throw new Error(usage);
12
+ if ([...options].some(option => !['--dry-run', '--force', '--debug', '--backup', '--password-stdin'].includes(option))) throw new Error(usage);
13
13
  const values = argv.filter(value => !value.startsWith('--'));
14
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 } : {}) };
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 } : {}) };
16
16
  }
package/src/backup.mjs CHANGED
@@ -7,8 +7,8 @@ function remoteScriptPath(value) {
7
7
  return name;
8
8
  }
9
9
 
10
- export async function backup({ target, config }) {
11
- const client = await connect(target);
10
+ export async function backup({ target, config, password }) {
11
+ const client = password === undefined ? await connect(target) : await connect(target, { password });
12
12
  try {
13
13
  await fs.promises.mkdir(config, { recursive: true });
14
14
  await fs.promises.mkdir(path(config, 'scripts'), { recursive: true });
package/src/deploy.mjs CHANGED
@@ -76,10 +76,10 @@ 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
85
  let hooksFinalized = false;
@@ -95,6 +95,7 @@ export async function deploy({ target, config }) {
95
95
  const output = await interactive(client, [
96
96
  'configure',
97
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',
98
99
  "printf '%s\\n' '--- compare ---'",
99
100
  'compare',
100
101
  "printf '%s\\n' '--- end compare ---'",
package/src/main.mjs CHANGED
@@ -6,6 +6,7 @@ import { readAndValidateConfig } from './validate.mjs';
6
6
  import { pushBack, shouldSkip } from './git.mjs';
7
7
  import { closeAll } from './ssh.mjs';
8
8
  import { log, registerHandlers, registerSignals } from '@eliware/common';
9
+ import { readFile } from 'node:fs/promises';
9
10
 
10
11
  const errors = registerHandlers({ log });
11
12
  const signals = registerSignals({ log, shutdownHook: async () => closeAll() });
@@ -21,6 +22,10 @@ try {
21
22
  log.info(packageJson.version);
22
23
  process.exit(0);
23
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
+ }
24
29
  if (args.backup) {
25
30
  await backup(args);
26
31
  log.info(`Backup successful: ${args.config}`);
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,
@@ -164,6 +167,12 @@ export function interactive(client, commands, log = () => {}) {
164
167
  const ansi = new RegExp(`${String.fromCharCode(27)}\\[[0-9;?]*[ -/]*[@-~]`, 'g');
165
168
  const title = new RegExp(`${String.fromCharCode(27)}\\][^${String.fromCharCode(7)}]*(?:${String.fromCharCode(7)}|${String.fromCharCode(27)}\\\\)`, 'g');
166
169
  const clean = value => value.replace(ansi, '').replace(title, '').replace(/\r/g, '');
170
+ const failureDetail = value => value.split('\n')
171
+ .map(line => line.trim())
172
+ .find(line => /(?:failed|failure|invalid|error|aborted|pending)/i.test(line))
173
+ ?.replace(/(certificate|private key|key)\s+"[^"]+"/ig, '$1 "[redacted]"')
174
+ .replace(/\s+/g, ' ')
175
+ .slice(0, 240);
167
176
  const prompt = value => /(?:^|\n)[A-Za-z0-9._-]+@[A-Za-z0-9._:-]+(?::~\$|#)\s*$/.test(value);
168
177
  const sendNext = () => {
169
178
  if (waiting || index >= commands.length) return;
@@ -187,15 +196,20 @@ export function interactive(client, commands, log = () => {}) {
187
196
  settled = true;
188
197
  clearTimeout(timer);
189
198
  stream.close();
190
- reject(new Error(`interactive command failed: ${currentItem.command}`));
199
+ const detail = failureDetail(cleaned);
200
+ reject(new Error(`interactive command failed: ${currentItem.command}${detail ? ` (${detail})` : ''}`));
191
201
  return;
192
202
  }
193
- const pager = /(?:^|\n):\s*$/.test(cleaned) || /--More--\s*$/i.test(cleaned);
203
+ const pagerReturn = /No next tag\s*\(press RETURN\)/i.test(cleaned);
204
+ const pager = /(?:^|\n):\s*$/.test(cleaned)
205
+ || /--More--\s*$/i.test(cleaned)
206
+ || pagerReturn;
194
207
  log(`state after data: index=${index}, waiting=${waiting}, answering=${answering}, prompt=${prompt(cleaned)}, pager=${pager}, bytes=${text.length}`);
195
208
  if (pager) {
196
- log('pager prompt detected; sending space');
197
- stream.write(' ');
198
- log('write complete: space');
209
+ const key = pagerReturn ? '\n' : ' ';
210
+ log(`pager prompt detected; sending ${pagerReturn ? 'return' : 'space'}`);
211
+ stream.write(key);
212
+ log(`write complete: ${pagerReturn ? 'return' : 'space'}`);
199
213
  return;
200
214
  }
201
215
  if (/Proceed\s*\?\s*\[Y\/n\]/i.test(cleaned) && !answering) {