@knowttl/ssh-axi 0.0.0-stage → 0.1.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 +175 -2
- package/artium-invocations.json +164 -0
- package/axi-credential.schema.json +44 -0
- package/dist/arguments.d.ts +17 -0
- package/dist/arguments.js +71 -0
- package/dist/authenticate.d.ts +30 -0
- package/dist/authenticate.js +209 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +74 -0
- package/dist/connection-worker.d.ts +1 -0
- package/dist/connection-worker.js +73 -0
- package/dist/doctor.d.ts +7 -0
- package/dist/doctor.js +63 -0
- package/dist/errors.d.ts +5 -0
- package/dist/errors.js +9 -0
- package/dist/hosted.d.ts +1 -0
- package/dist/hosted.js +75 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +7 -0
- package/dist/native-execution.d.ts +21 -0
- package/dist/native-execution.js +53 -0
- package/dist/output.d.ts +29 -0
- package/dist/output.js +98 -0
- package/dist/profile.d.ts +28 -0
- package/dist/profile.js +123 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +2 -0
- package/dist/worker.d.ts +1 -0
- package/dist/worker.js +33 -0
- package/package.json +28 -4
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { lookup } from 'node:dns/promises';
|
|
2
|
+
import { BlockList, isIP, connect } from 'node:net';
|
|
3
|
+
import ssh2 from 'ssh2';
|
|
4
|
+
import { SshError } from './errors.js';
|
|
5
|
+
import { resolveSecret } from './profile.js';
|
|
6
|
+
import { checkCommand, Output, secretPatterns } from './output.js';
|
|
7
|
+
export const AUTHENTICATION_DEADLINE_MS = 15_000;
|
|
8
|
+
export async function authenticate(profile) {
|
|
9
|
+
const connection = await openTransport(profile);
|
|
10
|
+
await connection.close();
|
|
11
|
+
return { authenticated: true, address: connection.address };
|
|
12
|
+
}
|
|
13
|
+
export async function execute(profile, settings, signal) {
|
|
14
|
+
validateSettings(settings);
|
|
15
|
+
checkCommand(settings.command, [resolveSecret(profile.privateKey, 'SSH_AXI_PRIVATE_KEY', true), resolveSecret(profile.passphrase, 'SSH_AXI_PASSPHRASE', false) ?? '']);
|
|
16
|
+
const started = performance.now();
|
|
17
|
+
const connection = await openTransport(profile, signal);
|
|
18
|
+
try {
|
|
19
|
+
return { ...await connection.execute(settings, signal), durationMs: Math.round(performance.now() - started) };
|
|
20
|
+
}
|
|
21
|
+
finally {
|
|
22
|
+
await connection.close();
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function validateSettings(settings) {
|
|
26
|
+
if (!Number.isInteger(settings.timeout) || settings.timeout < 1 || settings.timeout > 600
|
|
27
|
+
|| !Number.isInteger(settings.outputLimit) || settings.outputLimit < 0 || settings.outputLimit > 1024 * 1024) {
|
|
28
|
+
throw new SshError('USAGE', 'Timeout must be 1-600 seconds and output limit 0-1048576 bytes.', 2);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
// Each command starts a fresh channel. The private worker retains only the
|
|
32
|
+
// authenticated transport and its lifetime secret protection between commands.
|
|
33
|
+
export async function openTransport(profile, signal) {
|
|
34
|
+
const passphrase = resolveSecret(profile.passphrase, 'SSH_AXI_PASSPHRASE', false);
|
|
35
|
+
const privateKey = Buffer.from(resolveSecret(profile.privateKey, 'SSH_AXI_PRIVATE_KEY', true));
|
|
36
|
+
const secrets = [privateKey.toString(), passphrase ?? ''];
|
|
37
|
+
const patterns = secretPatterns(secrets);
|
|
38
|
+
const client = new ssh2.Client();
|
|
39
|
+
let socket;
|
|
40
|
+
let closed = false;
|
|
41
|
+
let trusted = false;
|
|
42
|
+
let address = '';
|
|
43
|
+
let active;
|
|
44
|
+
let closedResolve;
|
|
45
|
+
const disconnected = new Promise(resolve => { closedResolve = resolve; });
|
|
46
|
+
client.on('close', () => { closed = true; active?.(); closedResolve(); });
|
|
47
|
+
client.on('error', () => { closed = true; active?.(); });
|
|
48
|
+
const close = async () => {
|
|
49
|
+
closed = true;
|
|
50
|
+
client.destroy();
|
|
51
|
+
socket?.destroy();
|
|
52
|
+
active?.();
|
|
53
|
+
await disconnected;
|
|
54
|
+
};
|
|
55
|
+
try {
|
|
56
|
+
await new Promise((resolve, reject) => {
|
|
57
|
+
let finished = false;
|
|
58
|
+
const finish = (error) => {
|
|
59
|
+
if (finished)
|
|
60
|
+
return;
|
|
61
|
+
finished = true;
|
|
62
|
+
clearTimeout(deadline);
|
|
63
|
+
signal?.removeEventListener('abort', abort);
|
|
64
|
+
client.off('error', failure);
|
|
65
|
+
client.off('close', failure);
|
|
66
|
+
if (error) {
|
|
67
|
+
closed = true;
|
|
68
|
+
client.destroy();
|
|
69
|
+
socket?.destroy();
|
|
70
|
+
reject(error);
|
|
71
|
+
}
|
|
72
|
+
else
|
|
73
|
+
resolve();
|
|
74
|
+
};
|
|
75
|
+
const failure = () => finish(new SshError(trusted ? 'AUTH_FAILED' : 'TRUST_FAILED', trusted ? 'SSH key authentication failed; check the selected key and passphrase.' : 'SSH host trust could not be established.'));
|
|
76
|
+
const abort = () => finish(new SshError('COMMAND_CANCELLED', 'Connection cancelled before dispatch.'));
|
|
77
|
+
const deadline = setTimeout(() => finish(new SshError('AUTH_TIMEOUT', 'SSH authentication exceeded 15 seconds.')), AUTHENTICATION_DEADLINE_MS);
|
|
78
|
+
client.once('ready', () => finish());
|
|
79
|
+
client.once('error', failure);
|
|
80
|
+
client.once('close', failure);
|
|
81
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
82
|
+
if (signal?.aborted) {
|
|
83
|
+
abort();
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
void (async () => {
|
|
87
|
+
const addresses = isIP(profile.address) ? [{ address: profile.address, family: isIP(profile.address) }]
|
|
88
|
+
: await lookup(profile.address, { all: true, verbatim: true });
|
|
89
|
+
if (finished)
|
|
90
|
+
return;
|
|
91
|
+
const allowed = new BlockList();
|
|
92
|
+
for (const value of profile.allowedAddresses)
|
|
93
|
+
allowed.addAddress(value, isIP(value) === 6 ? 'ipv6' : 'ipv4');
|
|
94
|
+
if (!addresses.length || addresses.some(value => !allowed.check(value.address, value.family === 6 ? 'ipv6' : 'ipv4'))) {
|
|
95
|
+
finish(new SshError('ADDRESS_DENIED', 'The resolved endpoint is outside the approved address set.'));
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
address = addresses[0].address;
|
|
99
|
+
socket = connect({ host: address, port: profile.port, family: addresses[0].family });
|
|
100
|
+
socket.on('error', () => finish(new SshError('CONNECTION_FAILED', 'Cannot connect to the approved SSH endpoint.')));
|
|
101
|
+
client.connect({ sock: socket, username: profile.username, privateKey,
|
|
102
|
+
...(passphrase === undefined ? {} : { passphrase }), readyTimeout: AUTHENTICATION_DEADLINE_MS,
|
|
103
|
+
authHandler: ['publickey'], hostVerifier: (key) => {
|
|
104
|
+
trusted = profile.approvedHostKeys.some(value => Buffer.from(value.split(' ')[1], 'base64').equals(key));
|
|
105
|
+
return trusted;
|
|
106
|
+
} });
|
|
107
|
+
})().catch(() => finish(new SshError('AUTH_FAILED', 'Cannot authenticate with the selected endpoint and encrypted key.')));
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
privateKey.fill(0);
|
|
112
|
+
}
|
|
113
|
+
return { address, disconnected, close,
|
|
114
|
+
execute(settings, signal) {
|
|
115
|
+
if (closed)
|
|
116
|
+
throw new SshError('CONNECTION_CLOSED', 'The connection is closed. Open a new connection explicitly.');
|
|
117
|
+
if (active)
|
|
118
|
+
throw new SshError('CONNECTION_BUSY', 'A command is already active on this connection.');
|
|
119
|
+
validateSettings(settings);
|
|
120
|
+
checkCommand(settings.command, secrets);
|
|
121
|
+
if (signal?.aborted)
|
|
122
|
+
throw new SshError('COMMAND_CANCELLED', 'Execution cancelled before dispatch.');
|
|
123
|
+
return new Promise(resolve => {
|
|
124
|
+
let finished = false;
|
|
125
|
+
let channel;
|
|
126
|
+
let remoteExit = null;
|
|
127
|
+
let remoteSignal = null;
|
|
128
|
+
let outcome = 'unknown';
|
|
129
|
+
let diagnostic;
|
|
130
|
+
let grace;
|
|
131
|
+
const started = performance.now();
|
|
132
|
+
const stdout = new Output(patterns, settings.outputLimit);
|
|
133
|
+
const stderr = new Output(patterns, settings.outputLimit);
|
|
134
|
+
const finish = () => {
|
|
135
|
+
if (finished)
|
|
136
|
+
return;
|
|
137
|
+
finished = true;
|
|
138
|
+
clearTimeout(deadline);
|
|
139
|
+
clearTimeout(grace);
|
|
140
|
+
signal?.removeEventListener('abort', abort);
|
|
141
|
+
active = undefined;
|
|
142
|
+
const streams = { stdout: stdout.finish(), stderr: stderr.finish() };
|
|
143
|
+
if (streams.stdout.withheld || streams.stderr.withheld) {
|
|
144
|
+
for (const stream of Object.values(streams)) {
|
|
145
|
+
stream.text = '';
|
|
146
|
+
stream.retainedBytes = 0;
|
|
147
|
+
stream.withheld = true;
|
|
148
|
+
}
|
|
149
|
+
diagnostic = 'Protected output withheld.';
|
|
150
|
+
if (outcome === 'completed')
|
|
151
|
+
outcome = 'failed';
|
|
152
|
+
}
|
|
153
|
+
resolve({ address, outcome, remoteExit, remoteSignal,
|
|
154
|
+
termination: remoteExit !== null || remoteSignal !== null ? 'remote-exit' : 'unknown',
|
|
155
|
+
...streams, durationMs: Math.round(performance.now() - started), ...(diagnostic ? { diagnostic } : {}) });
|
|
156
|
+
};
|
|
157
|
+
const terminate = () => {
|
|
158
|
+
if (!channel)
|
|
159
|
+
return;
|
|
160
|
+
// ssh2's public signal() refuses after stdin EOF; SSH still permits
|
|
161
|
+
// channel requests until close. The pinned transport keeps stdin closed.
|
|
162
|
+
const transport = channel;
|
|
163
|
+
if (['open', 'eof'].includes(transport.outgoing.state))
|
|
164
|
+
transport._client._protocol.signal(transport.outgoing.id, 'TERM');
|
|
165
|
+
};
|
|
166
|
+
const cancel = (reason, message) => {
|
|
167
|
+
if (finished || grace)
|
|
168
|
+
return;
|
|
169
|
+
outcome = reason;
|
|
170
|
+
diagnostic = message;
|
|
171
|
+
terminate();
|
|
172
|
+
grace = setTimeout(() => { channel?.close(); void close(); finish(); }, 200);
|
|
173
|
+
};
|
|
174
|
+
const abort = () => cancel('cancelled');
|
|
175
|
+
const deadline = setTimeout(() => cancel('timed_out'), settings.timeout * 1000);
|
|
176
|
+
active = finish;
|
|
177
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
178
|
+
client.exec(settings.command, { pty: false }, (error, stream) => {
|
|
179
|
+
if (finished) {
|
|
180
|
+
stream?.destroy();
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (error) {
|
|
184
|
+
outcome = 'failed';
|
|
185
|
+
finish();
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
channel = stream;
|
|
189
|
+
if (grace)
|
|
190
|
+
terminate();
|
|
191
|
+
const receive = (output, bytes) => {
|
|
192
|
+
output.push(bytes);
|
|
193
|
+
if (stdout.receivedBytes + stderr.receivedBytes > 16 * 1024 * 1024)
|
|
194
|
+
cancel('unknown', 'Output flood limit exceeded; inspect remote state before retrying.');
|
|
195
|
+
};
|
|
196
|
+
stream.on('data', (bytes) => receive(stdout, bytes));
|
|
197
|
+
stream.stderr.on('data', (bytes) => receive(stderr, bytes));
|
|
198
|
+
stream.on('exit', (code, name) => { remoteExit = typeof code === 'number' ? code : null; remoteSignal = name ?? null; });
|
|
199
|
+
stream.on('error', () => { void close(); finish(); });
|
|
200
|
+
stream.on('close', () => {
|
|
201
|
+
if (!grace)
|
|
202
|
+
outcome = remoteExit === 0 ? 'completed' : remoteExit !== null || remoteSignal ? 'failed' : 'unknown';
|
|
203
|
+
finish();
|
|
204
|
+
});
|
|
205
|
+
stream.end();
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
} };
|
|
209
|
+
}
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function main(args: readonly string[]): Promise<void>;
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { encode } from '@toon-format/toon';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { resolve } from 'node:path';
|
|
5
|
+
import { configPath, loadConfiguration, selectProfile, resolveSecret } from './profile.js';
|
|
6
|
+
import { doctor } from './doctor.js';
|
|
7
|
+
import { SshError } from './errors.js';
|
|
8
|
+
import { executeNative } from './native-execution.js';
|
|
9
|
+
import { commands, flagHelp, parseArguments } from './arguments.js';
|
|
10
|
+
function projection(name, profile) {
|
|
11
|
+
return { id: name, address: profile.address, port: profile.port, username: profile.username };
|
|
12
|
+
}
|
|
13
|
+
export async function main(args) {
|
|
14
|
+
const json = args.includes('--json');
|
|
15
|
+
const output = (value) => console.log(json ? JSON.stringify(value) : encode(value));
|
|
16
|
+
try {
|
|
17
|
+
const parsed = parseArguments(args);
|
|
18
|
+
if (parsed.help) {
|
|
19
|
+
const usage = parsed.command === 'exec' ? 'exec <host> --command <text> --confirm'
|
|
20
|
+
: parsed.command === 'execution cancel' ? 'execution cancel <id> --confirm'
|
|
21
|
+
: parsed.command === 'execution show' ? 'execution show <id>'
|
|
22
|
+
: parsed.command === 'connection set' ? 'connection set <id> --idle-timeout 5m'
|
|
23
|
+
: ['connection show', 'connection close'].includes(parsed.command) ? `${parsed.command} <id>`
|
|
24
|
+
: `${parsed.command}${commands[parsed.command].arguments ? ' <host>' : ''}`;
|
|
25
|
+
output({ description: commands[parsed.command].description, flags: flagHelp + Object.keys(commands[parsed.command].flags ?? {}).map(key => `, --${key}`).join(''),
|
|
26
|
+
...(parsed.command === 'exec' ? { defaults: { timeout: '60s', outputLimit: '65536 bytes' } } : {}),
|
|
27
|
+
examples: parsed.command === '' ? ['ssh-axi host list', 'ssh-axi doctor <host>', 'ssh-axi config path']
|
|
28
|
+
: [`ssh-axi ${usage}`, `ssh-axi ${usage} --json`] });
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const path = configPath(parsed.config);
|
|
32
|
+
if (parsed.command === 'config path') {
|
|
33
|
+
output({ path });
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (parsed.command.startsWith('execution ') || parsed.command.startsWith('connection '))
|
|
37
|
+
throw new SshError('HOSTED_REQUIRED', 'Use artium-axi ssh-axi for managed connections and executions.');
|
|
38
|
+
const config = loadConfiguration(path);
|
|
39
|
+
if (['', 'host list', 'profile list'].includes(parsed.command)) {
|
|
40
|
+
const hosts = Object.entries(config.profiles).map(([name, profile]) => projection(name, profile));
|
|
41
|
+
output({ ...(parsed.command === '' ? { bin: resolve(fileURLToPath(new URL('./main.js', import.meta.url))).replace(homedir(), '~'),
|
|
42
|
+
description: 'Discover approved SSH hosts and run confirmed bounded commands.' } : {}),
|
|
43
|
+
hosts, count: hosts.length, ...(hosts.length ? {} : { message: 'No configured hosts found.' }),
|
|
44
|
+
help: hosts.length ? ['ssh-axi host show <host>', 'ssh-axi doctor <host>'] : ['ssh-axi config path', 'ssh-axi profile list --help'] });
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const selected = selectProfile(config, parsed.profile);
|
|
48
|
+
if (parsed.command === 'exec') {
|
|
49
|
+
const result = await executeNative(selected.name, selected.profile, parsed);
|
|
50
|
+
output(result);
|
|
51
|
+
process.exitCode = result.outcome === 'completed' ? 0 : result.remoteExit && result.remoteExit > 0 && result.remoteExit <= 255 ? result.remoteExit : 1;
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (parsed.command === 'host show') {
|
|
55
|
+
output({ host: projection(selected.name, selected.profile) });
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (parsed.command === 'auth status') {
|
|
59
|
+
const key = resolveSecret(selected.profile.privateKey, 'SSH_AXI_PRIVATE_KEY', true);
|
|
60
|
+
const passphrase = resolveSecret(selected.profile.passphrase, 'SSH_AXI_PASSPHRASE', false);
|
|
61
|
+
const source = (reference, fallback) => reference && 'file' in reference ? 'protected file' : `env:${reference?.env ?? fallback}`;
|
|
62
|
+
output({ profile: selected.name, privateKey: key ? 'available' : 'missing', passphrase: passphrase ? 'available' : 'absent',
|
|
63
|
+
privateKeySource: source(selected.profile.privateKey, 'SSH_AXI_PRIVATE_KEY'),
|
|
64
|
+
passphraseSource: source(selected.profile.passphrase, 'SSH_AXI_PASSPHRASE'), network: 'not checked' });
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
output({ host: selected.name, ...await doctor(selected.profile), commandsExecuted: 0 });
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
const safe = error instanceof SshError ? error : new SshError('INTERNAL_ERROR', 'The SSH operation failed.');
|
|
71
|
+
output({ error: { code: safe.code, message: safe.message }, help: ['ssh-axi --help'] });
|
|
72
|
+
process.exitCode = safe.exitCode;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { openTransport } from './authenticate.js';
|
|
2
|
+
import { configPath, loadConfiguration, selectProfile } from './profile.js';
|
|
3
|
+
import { SshError } from './errors.js';
|
|
4
|
+
// Only the host can opt in through inherited IPC. It owns profile custody.
|
|
5
|
+
if (!process.send || process.argv.length !== 2)
|
|
6
|
+
process.exit(2);
|
|
7
|
+
let connection;
|
|
8
|
+
let opening = false;
|
|
9
|
+
let fenced = false;
|
|
10
|
+
let active;
|
|
11
|
+
const authentication = new AbortController();
|
|
12
|
+
const send = (value) => { if (process.connected)
|
|
13
|
+
process.send(value); };
|
|
14
|
+
process.on('disconnect', () => process.exit(1));
|
|
15
|
+
process.on('message', async (value) => {
|
|
16
|
+
let id;
|
|
17
|
+
try {
|
|
18
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
19
|
+
throw new SshError('IPC_INVALID', 'Invalid connection request.');
|
|
20
|
+
const request = value;
|
|
21
|
+
if (request.revision !== 2 || typeof request.id !== 'string'
|
|
22
|
+
|| !['open', 'exec', 'cancel', 'close'].includes(String(request.operation))
|
|
23
|
+
|| Object.keys(request).some(key => !['revision', 'id', 'operation', 'settings'].includes(key)))
|
|
24
|
+
throw new SshError('IPC_INVALID', 'Incompatible connection protocol.');
|
|
25
|
+
id = request.id;
|
|
26
|
+
if (request.operation === 'close') {
|
|
27
|
+
fenced = true;
|
|
28
|
+
authentication.abort();
|
|
29
|
+
active?.abort();
|
|
30
|
+
await connection?.close();
|
|
31
|
+
send({ id, ok: true, result: { closed: true } });
|
|
32
|
+
if (process.connected)
|
|
33
|
+
process.disconnect();
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (fenced)
|
|
37
|
+
throw new SshError('CONNECTION_CLOSED', 'Connection is fenced.');
|
|
38
|
+
if (request.operation === 'cancel') {
|
|
39
|
+
active?.abort();
|
|
40
|
+
send({ id, ok: true, result: { cancelled: true } });
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (request.operation === 'open') {
|
|
44
|
+
if (opening)
|
|
45
|
+
throw new SshError('IPC_INVALID', 'Connection already initialized.');
|
|
46
|
+
opening = true;
|
|
47
|
+
connection = await openTransport(selectProfile(loadConfiguration(configPath()), 'artium').profile, authentication.signal);
|
|
48
|
+
if (fenced) {
|
|
49
|
+
await connection.close();
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
void connection.disconnected.then(() => { if (!fenced && process.connected)
|
|
53
|
+
process.disconnect(); });
|
|
54
|
+
send({ id, ok: true, result: { authenticated: true, address: connection.address } });
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (!connection)
|
|
58
|
+
throw new SshError('CONNECTION_CLOSED', 'Connection is not authenticated.');
|
|
59
|
+
if (active)
|
|
60
|
+
throw new SshError('CONNECTION_BUSY', 'A command is already active on this connection.');
|
|
61
|
+
active = new AbortController();
|
|
62
|
+
try {
|
|
63
|
+
send({ id, ok: true, result: await connection.execute(request.settings, active.signal) });
|
|
64
|
+
}
|
|
65
|
+
finally {
|
|
66
|
+
active = undefined;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
const safe = error instanceof SshError ? error : new SshError('WORKER_FAILED', 'Private connection operation failed.');
|
|
71
|
+
send({ id, ok: false, error: { code: safe.code, message: safe.message } });
|
|
72
|
+
}
|
|
73
|
+
});
|
package/dist/doctor.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type Profile } from './profile.js';
|
|
2
|
+
import type { ExecutionResult, ExecutionSettings } from './authenticate.js';
|
|
3
|
+
export declare function doctor(profile: Profile): Promise<{
|
|
4
|
+
authenticated: true;
|
|
5
|
+
address: string;
|
|
6
|
+
}>;
|
|
7
|
+
export declare function executeCommand(profile: Profile, settings: ExecutionSettings, signal?: AbortSignal): Promise<ExecutionResult>;
|
package/dist/doctor.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { fork } from 'node:child_process';
|
|
2
|
+
import { mkdtemp, mkdir, rm } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { SshError } from './errors.js';
|
|
6
|
+
import { resolveSecret } from './profile.js';
|
|
7
|
+
import { checkCommand, containsSecret, secretPatterns } from './output.js';
|
|
8
|
+
// Both native and hosted callers use this one-shot supervisor. Artium supplies
|
|
9
|
+
// only the selected binding's profile; no public caller can supply worker state.
|
|
10
|
+
export async function doctor(profile) {
|
|
11
|
+
return supervise(profile);
|
|
12
|
+
}
|
|
13
|
+
export async function executeCommand(profile, settings, signal) {
|
|
14
|
+
return supervise(profile, settings, signal);
|
|
15
|
+
}
|
|
16
|
+
async function supervise(profile, settings, signal) {
|
|
17
|
+
const privateKey = resolveSecret(profile.privateKey, 'SSH_AXI_PRIVATE_KEY', true);
|
|
18
|
+
const passphrase = resolveSecret(profile.passphrase, 'SSH_AXI_PASSPHRASE', false);
|
|
19
|
+
if (settings)
|
|
20
|
+
checkCommand(settings.command, [privateKey, passphrase ?? '']);
|
|
21
|
+
const directory = await mkdtemp(join(tmpdir(), 'ssh-axi-'));
|
|
22
|
+
try {
|
|
23
|
+
for (const name of ['config', 'state', 'tmp'])
|
|
24
|
+
await mkdir(join(directory, name), { mode: 0o700 });
|
|
25
|
+
const environment = { HOME: directory, USERPROFILE: directory,
|
|
26
|
+
XDG_CONFIG_HOME: join(directory, 'config'), XDG_STATE_HOME: join(directory, 'state'),
|
|
27
|
+
APPDATA: join(directory, 'config'), LOCALAPPDATA: join(directory, 'state'),
|
|
28
|
+
TMPDIR: join(directory, 'tmp'), TMP: join(directory, 'tmp'), TEMP: join(directory, 'tmp'),
|
|
29
|
+
ARTIUM_SECRET_0: privateKey, ...(passphrase === undefined ? {} : { ARTIUM_SECRET_1: passphrase }) };
|
|
30
|
+
return await new Promise((resolve, reject) => {
|
|
31
|
+
const child = fork(new URL('./worker.js', import.meta.url), [], { env: environment,
|
|
32
|
+
execArgv: [], detached: true, stdio: ['ignore', 'ignore', 'ignore', 'ipc'] });
|
|
33
|
+
let response;
|
|
34
|
+
const deadline = setTimeout(() => child.kill('SIGKILL'), 17_000 + (settings?.timeout ?? 0) * 1000);
|
|
35
|
+
const cancel = () => { if (child.connected)
|
|
36
|
+
child.send({ cancel: true }); };
|
|
37
|
+
signal?.addEventListener('abort', cancel, { once: true });
|
|
38
|
+
child.once('error', () => reject(new SshError('WORKER_FAILED', 'Cannot start the private SSH probe.')));
|
|
39
|
+
child.once('message', value => { response = value; });
|
|
40
|
+
child.once('exit', () => {
|
|
41
|
+
clearTimeout(deadline);
|
|
42
|
+
signal?.removeEventListener('abort', cancel);
|
|
43
|
+
const value = response;
|
|
44
|
+
if (value?.ok && value.result) {
|
|
45
|
+
if (containsSecret(JSON.stringify(value.result), secretPatterns([privateKey, passphrase ?? '']))) {
|
|
46
|
+
reject(new SshError('SECRET_IN_OUTPUT', 'Protected worker output withheld.'));
|
|
47
|
+
}
|
|
48
|
+
else
|
|
49
|
+
resolve(value.result);
|
|
50
|
+
}
|
|
51
|
+
else
|
|
52
|
+
reject(value?.error ? new SshError(value.error.code, value.error.message) : new SshError('WORKER_FAILED', 'The private SSH probe stopped without a result.'));
|
|
53
|
+
});
|
|
54
|
+
child.send({ revision: 1, operation: settings ? 'exec' : 'doctor', ...(settings ? { settings } : {}), profile: { ...profile, privateKey: { env: 'ARTIUM_SECRET_0' },
|
|
55
|
+
...(passphrase === undefined ? {} : { passphrase: { env: 'ARTIUM_SECRET_1' } }) } });
|
|
56
|
+
if (signal?.aborted)
|
|
57
|
+
cancel();
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
await rm(directory, { recursive: true, force: true });
|
|
62
|
+
}
|
|
63
|
+
}
|
package/dist/errors.d.ts
ADDED
package/dist/errors.js
ADDED
package/dist/hosted.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/hosted.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { encode } from '@toon-format/toon';
|
|
2
|
+
import { parseArguments } from './arguments.js';
|
|
3
|
+
import { configPath, loadConfiguration, selectProfile } from './profile.js';
|
|
4
|
+
import { doctor, executeCommand } from './doctor.js';
|
|
5
|
+
import { SshError } from './errors.js';
|
|
6
|
+
import { VERSION } from './version.js';
|
|
7
|
+
if (!process.send)
|
|
8
|
+
process.exit(2);
|
|
9
|
+
process.once('message', async (message) => {
|
|
10
|
+
const output = (value) => console.log(process.argv.includes('--json') ? JSON.stringify(value) : encode(value));
|
|
11
|
+
try {
|
|
12
|
+
if (process.argv.length === 3 && ['--version', '-v', '-V'].includes(process.argv[2])) {
|
|
13
|
+
console.log(VERSION);
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
const parsed = parseArguments(process.argv.slice(2));
|
|
17
|
+
if (parsed.config || process.argv.includes('--profile'))
|
|
18
|
+
throw new SshError('USAGE', 'Hosted profile overrides are refused.', 2);
|
|
19
|
+
const context = message;
|
|
20
|
+
if (context.revision !== 1 || !Array.isArray(context.hosts))
|
|
21
|
+
throw new SshError('IPC_INVALID', 'Incompatible host context.');
|
|
22
|
+
if (context.execution) {
|
|
23
|
+
if (context.hosts.length !== 1)
|
|
24
|
+
throw new SshError('IPC_INVALID', 'A single selected execution host is required.');
|
|
25
|
+
const cancellation = new AbortController();
|
|
26
|
+
const cancel = () => cancellation.abort();
|
|
27
|
+
process.on('message', (value) => {
|
|
28
|
+
if (value && typeof value === 'object' && 'cancel' in value && value.cancel === true)
|
|
29
|
+
cancel();
|
|
30
|
+
});
|
|
31
|
+
process.once('SIGTERM', cancel);
|
|
32
|
+
process.once('SIGINT', cancel);
|
|
33
|
+
try {
|
|
34
|
+
const selected = selectProfile(loadConfiguration(configPath()), 'artium');
|
|
35
|
+
const result = await executeCommand(selected.profile, context.execution, cancellation.signal);
|
|
36
|
+
output(result);
|
|
37
|
+
process.exitCode = result.outcome === 'completed' ? 0 : 1;
|
|
38
|
+
}
|
|
39
|
+
finally {
|
|
40
|
+
process.off('SIGTERM', cancel);
|
|
41
|
+
process.off('SIGINT', cancel);
|
|
42
|
+
}
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (parsed.help) {
|
|
46
|
+
output({ description: 'Discover granted hosts and run confirmed bounded commands.', commands: ['host list', 'host show <host>', 'doctor <host>', 'exec <host> --command <text> --confirm', 'execution list', 'execution show <id>', 'execution cancel <id> --confirm'], flags: '--help, --json' });
|
|
47
|
+
}
|
|
48
|
+
else if (parsed.command === '' || parsed.command === 'host list') {
|
|
49
|
+
output({ hosts: context.hosts, count: context.hosts.length,
|
|
50
|
+
...(context.hosts.length ? {} : { message: 'No granted hosts found.' }), help: ['ssh-axi host show <host>', 'ssh-axi doctor <host>'] });
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
const host = context.hosts.find(host => host.id === parsed.profile);
|
|
54
|
+
if (!host)
|
|
55
|
+
throw new SshError('NOT_FOUND', 'Host not found.');
|
|
56
|
+
if (parsed.command === 'host show')
|
|
57
|
+
output({ host });
|
|
58
|
+
else if (parsed.command === 'doctor') {
|
|
59
|
+
const selected = selectProfile(loadConfiguration(configPath()), 'artium');
|
|
60
|
+
output({ host: host.id, ...await doctor(selected.profile), commandsExecuted: 0 });
|
|
61
|
+
}
|
|
62
|
+
else
|
|
63
|
+
throw new SshError('USAGE', 'Use host list, host show <host> or doctor <host>.', 2);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
const safe = error instanceof SshError ? error : new SshError('HOSTED_FAILED', 'The hosted SSH check failed.');
|
|
68
|
+
output({ error: { code: safe.code, message: safe.message } });
|
|
69
|
+
process.exitCode = safe.exitCode;
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
if (process.connected)
|
|
73
|
+
process.disconnect();
|
|
74
|
+
}
|
|
75
|
+
});
|
package/dist/main.d.ts
ADDED
package/dist/main.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { VERSION } from './version.js';
|
|
3
|
+
import { tryFastPath } from 'axi-sdk-js/fast-path';
|
|
4
|
+
if (!tryFastPath(process.argv.slice(2), { version: VERSION })) {
|
|
5
|
+
const { main } = await import('./cli.js');
|
|
6
|
+
await main(process.argv.slice(2));
|
|
7
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type Profile } from './profile.js';
|
|
2
|
+
import type { parseArguments } from './arguments.js';
|
|
3
|
+
export declare function executeNative(host: string, profile: Profile, parsed: ReturnType<typeof parseArguments>): Promise<{
|
|
4
|
+
finishedAt: string;
|
|
5
|
+
help: string[];
|
|
6
|
+
address: string;
|
|
7
|
+
outcome: "completed" | "failed" | "cancelled" | "timed_out" | "unknown";
|
|
8
|
+
remoteExit: number | null;
|
|
9
|
+
remoteSignal: string | null;
|
|
10
|
+
termination: "remote-exit" | "unknown";
|
|
11
|
+
stdout: ReturnType<import("./output.js").Output["finish"]>;
|
|
12
|
+
stderr: ReturnType<import("./output.js").Output["finish"]>;
|
|
13
|
+
durationMs: number;
|
|
14
|
+
diagnostic?: string;
|
|
15
|
+
id: string;
|
|
16
|
+
host: string;
|
|
17
|
+
account: string;
|
|
18
|
+
sudo: boolean;
|
|
19
|
+
connectionId: null;
|
|
20
|
+
startedAt: string;
|
|
21
|
+
}>;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { constants, openSync, closeSync, fsyncSync, writeFileSync, fstatSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { dirname, isAbsolute, join } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { executeCommand } from './doctor.js';
|
|
6
|
+
import { checkCommand } from './output.js';
|
|
7
|
+
import { readPrivateFile, resolveSecret } from './profile.js';
|
|
8
|
+
import { SshError } from './errors.js';
|
|
9
|
+
export async function executeNative(host, profile, parsed) {
|
|
10
|
+
if (parsed.values.confirm !== true)
|
|
11
|
+
throw new SshError('CONFIRM_REQUIRED', 'Every arbitrary command requires --confirm.', 2);
|
|
12
|
+
if (process.env.SSH_AXI_READ_ONLY && process.env.SSH_AXI_READ_ONLY !== 'false' && process.env.SSH_AXI_READ_ONLY !== '0')
|
|
13
|
+
throw new SshError('READ_ONLY', 'Arbitrary execution is disabled by read-only policy.');
|
|
14
|
+
if (parsed.values.sudo || parsed.values.detach || parsed.values['request-key'] || parsed.values.connection)
|
|
15
|
+
throw new SshError('HOSTED_REQUIRED', 'This option requires a managed execution and matching host grants.');
|
|
16
|
+
if (typeof parsed.values.command !== 'string')
|
|
17
|
+
throw new SshError('USAGE', 'Use --command <text> --confirm.', 2);
|
|
18
|
+
const command = parsed.values.command;
|
|
19
|
+
checkCommand(command, [resolveSecret(profile.privateKey, 'SSH_AXI_PRIVATE_KEY', true), resolveSecret(profile.passphrase, 'SSH_AXI_PASSPHRASE', false) ?? '']);
|
|
20
|
+
const state = process.platform === 'win32' ? process.env.LOCALAPPDATA : process.env.XDG_STATE_HOME && isAbsolute(process.env.XDG_STATE_HOME)
|
|
21
|
+
? process.env.XDG_STATE_HOME : join(homedir(), '.local', 'state');
|
|
22
|
+
if (!state || process.platform === 'win32')
|
|
23
|
+
throw new SshError('PLATFORM_PENDING', 'Native Windows custody validation is pending.');
|
|
24
|
+
const file = process.env.SSH_AXI_WRITE_LOG ?? join(state, 'ssh-axi', 'writes.log');
|
|
25
|
+
if (!isAbsolute(file))
|
|
26
|
+
throw new SshError('CONFIG_INSECURE', 'Write journal path must be absolute.');
|
|
27
|
+
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
|
28
|
+
// Reuse the private-file ownership/parent checks before appending.
|
|
29
|
+
const descriptor = openSync(file, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW | constants.O_NONBLOCK, 0o600);
|
|
30
|
+
const id = `exec_${randomUUID()}`;
|
|
31
|
+
const startedAt = new Date().toISOString();
|
|
32
|
+
try {
|
|
33
|
+
readPrivateFile(file, true);
|
|
34
|
+
if (!fstatSync(descriptor).isFile())
|
|
35
|
+
throw new SshError('CONFIG_INSECURE', 'Journal must be a private regular file.');
|
|
36
|
+
writeFileSync(descriptor, JSON.stringify({ id, host, command, confirmed: true, startedAt, outcome: 'unknown' }) + '\n');
|
|
37
|
+
fsyncSync(descriptor);
|
|
38
|
+
}
|
|
39
|
+
finally {
|
|
40
|
+
closeSync(descriptor);
|
|
41
|
+
}
|
|
42
|
+
const cancellation = new AbortController();
|
|
43
|
+
const cancel = () => cancellation.abort();
|
|
44
|
+
process.once('SIGINT', cancel);
|
|
45
|
+
try {
|
|
46
|
+
return { id, host, account: profile.username, sudo: false, connectionId: null, startedAt,
|
|
47
|
+
...await executeCommand(profile, { command, timeout: parsed.timeout, outputLimit: parsed.outputLimit }, cancellation.signal),
|
|
48
|
+
finishedAt: new Date().toISOString(), help: ['Inspect remote state before retrying an uncertain execution. No automatic replay.'] };
|
|
49
|
+
}
|
|
50
|
+
finally {
|
|
51
|
+
process.off('SIGINT', cancel);
|
|
52
|
+
}
|
|
53
|
+
}
|
package/dist/output.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export declare function secretPatterns(secrets: readonly string[]): string[];
|
|
2
|
+
export declare function containsSecret(value: string, patterns: readonly string[]): boolean;
|
|
3
|
+
export declare function checkCommand(command: string, secrets: readonly string[]): void;
|
|
4
|
+
export declare class Output {
|
|
5
|
+
private readonly patterns;
|
|
6
|
+
private readonly limit;
|
|
7
|
+
private readonly decoder;
|
|
8
|
+
private tail;
|
|
9
|
+
private text;
|
|
10
|
+
private terminal;
|
|
11
|
+
private readonly window;
|
|
12
|
+
receivedBytes: number;
|
|
13
|
+
retainedBytes: number;
|
|
14
|
+
truncated: boolean;
|
|
15
|
+
encodingLoss: boolean;
|
|
16
|
+
withheld: boolean;
|
|
17
|
+
constructor(patterns: readonly string[], limit: number);
|
|
18
|
+
push(bytes: Buffer): void;
|
|
19
|
+
private consume;
|
|
20
|
+
finish(): {
|
|
21
|
+
text: string;
|
|
22
|
+
receivedBytes: number;
|
|
23
|
+
retainedBytes: number;
|
|
24
|
+
truncated: boolean;
|
|
25
|
+
encodingLoss: boolean;
|
|
26
|
+
withheld: boolean;
|
|
27
|
+
unit: "bytes";
|
|
28
|
+
};
|
|
29
|
+
}
|