@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 +15 -2
- package/RELEASE_NOTES.md +32 -0
- package/package.json +4 -4
- package/src/args.mjs +4 -3
- package/src/backup.mjs +29 -0
- package/src/deploy.mjs +13 -6
- package/src/git.mjs +1 -1
- package/src/main.mjs +11 -0
- package/src/ssh.mjs +20 -13
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
|
|
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.
|
|
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.
|
|
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.
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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);
|