@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 +9 -1
- package/RELEASE_NOTES.md +30 -0
- package/package.json +2 -2
- package/src/args.mjs +4 -4
- package/src/backup.mjs +2 -2
- package/src/deploy.mjs +3 -2
- package/src/main.mjs +5 -0
- package/src/ssh.mjs +21 -7
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
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 =
|
|
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
|
-
|
|
199
|
+
const detail = failureDetail(cleaned);
|
|
200
|
+
reject(new Error(`interactive command failed: ${currentItem.command}${detail ? ` (${detail})` : ''}`));
|
|
191
201
|
return;
|
|
192
202
|
}
|
|
193
|
-
const
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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) {
|