@pixlcore/xyplug-ssh 1.0.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/LICENSE.md +21 -0
- package/README.md +161 -0
- package/index.js +428 -0
- package/package.json +39 -0
- package/xyops.json +125 -0
package/LICENSE.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 PixlCore LLC
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
<p align="center"><img src="https://raw.githubusercontent.com/pixlcore/xyplug-ssh/refs/heads/main/logo.png" height="160" alt="Remote SSH"/></p>
|
|
2
|
+
<h1 align="center">Remote SSH</h1>
|
|
3
|
+
|
|
4
|
+
A remote SSH event plugin for the [xyOps Workflow Automation System](https://xyops.io). It connects to a remote host over SSH, and then runs either:
|
|
5
|
+
|
|
6
|
+
- A remote command that receives a script on STDIN
|
|
7
|
+
- A remote command that receives the full xyOps job JSON on STDIN
|
|
8
|
+
|
|
9
|
+
This makes it useful both as a simple remote shell runner and as a transport layer for your own remote XYWP-aware workers.
|
|
10
|
+
|
|
11
|
+
## Features
|
|
12
|
+
|
|
13
|
+
- Pure Node.js / `npx` plugin.
|
|
14
|
+
- No local `ssh` CLI required.
|
|
15
|
+
- Supports private key, passphrase, password, or local `ssh-agent`
|
|
16
|
+
- Optional host key fingerprint pinning
|
|
17
|
+
- Preserves remote XYWP output when the remote command emits `{"xy":1,...}` lines
|
|
18
|
+
|
|
19
|
+
## Requirements
|
|
20
|
+
|
|
21
|
+
- `npx`
|
|
22
|
+
- Network access from the xyOps runner host to the remote SSH server
|
|
23
|
+
- A POSIX-like remote host with `/bin/sh`
|
|
24
|
+
- `base64` or `openssl` available on the remote host for env var bootstrapping
|
|
25
|
+
- Whatever remote runtime your command needs, e.g. `bash`, `python`, `node`, etc.
|
|
26
|
+
|
|
27
|
+
## Secrets / Environment Variables
|
|
28
|
+
|
|
29
|
+
The plugin looks up SSH auth from environment variables or [xyOps Secrets](https://docs.xyops.io/secrets) using these fixed names:
|
|
30
|
+
|
|
31
|
+
- `SSH_PRIVATE_KEY`
|
|
32
|
+
- `SSH_PASSPHRASE`
|
|
33
|
+
- `SSH_PASSWORD`
|
|
34
|
+
|
|
35
|
+
Put those in your xyOps Secret Vault when needed.
|
|
36
|
+
|
|
37
|
+
Note that any secret variables that begin with `SSH_` are **not** forwarded to the remote server, by design. Any *other* assigned xyOps secret variables are forwarded to the remote process environment.
|
|
38
|
+
|
|
39
|
+
The plugin also exports a few helper variables remotely:
|
|
40
|
+
|
|
41
|
+
- `XYOPS_JOB_ID`
|
|
42
|
+
- `XYOPS_EVENT_ID`
|
|
43
|
+
- `XYOPS_PLUGIN_ID`
|
|
44
|
+
- `XYOPS_SERVER_ID`
|
|
45
|
+
- `XYOPS_BASE_URL`
|
|
46
|
+
- `XYOPS_RUN_MODE`
|
|
47
|
+
|
|
48
|
+
## Plugin Parameters
|
|
49
|
+
|
|
50
|
+
- `SSH Hostname`: Hostname or IP address
|
|
51
|
+
- `Username`: SSH username
|
|
52
|
+
- `Port`: SSH port
|
|
53
|
+
- `Host Fingerprint`: Optional host key pin, recommended for production
|
|
54
|
+
- `Connect Timeout`: SSH connect timeout in seconds
|
|
55
|
+
- `Remote Env`: Extra key/value pairs to pass to the remote side
|
|
56
|
+
- `Verbose Logging`: Adds connection/debug details to the job log
|
|
57
|
+
|
|
58
|
+
## Run Modes
|
|
59
|
+
|
|
60
|
+
### 1. Pipe Script to STDIN
|
|
61
|
+
|
|
62
|
+
Use this when the remote command is something like `bash -se`, `python -`, or another interpreter that reads source code from STDIN.
|
|
63
|
+
|
|
64
|
+
Typical example:
|
|
65
|
+
|
|
66
|
+
- Remote Command: `bash -se`
|
|
67
|
+
- Script Source: your shell script
|
|
68
|
+
|
|
69
|
+
The plugin exports env vars first, then executes the remote command, then pipes your script into its STDIN.
|
|
70
|
+
|
|
71
|
+
### 2. Pipe Full Job JSON
|
|
72
|
+
|
|
73
|
+
Use this when the remote command is your own program that expects the full xyOps job payload on STDIN.
|
|
74
|
+
|
|
75
|
+
Typical example:
|
|
76
|
+
|
|
77
|
+
- Remote Command: `node /path/to/my-remote-plugin.js`
|
|
78
|
+
|
|
79
|
+
If the remote program emits XYWP lines such as `{"xy":1,"progress":0.5}` or `{"xy":1,"code":0}`, the plugin passes them straight back to xyOps.
|
|
80
|
+
|
|
81
|
+
If the remote program does not emit a final XYWP completion message, the plugin falls back to the SSH exit code.
|
|
82
|
+
|
|
83
|
+
## Local Testing
|
|
84
|
+
|
|
85
|
+
Install dependencies first:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
npm install
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Then pipe a sample job JSON into the plugin.
|
|
92
|
+
|
|
93
|
+
### Script Mode Example
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
cat <<'JSON' | node index.js
|
|
97
|
+
{
|
|
98
|
+
"xy": 1,
|
|
99
|
+
"type": "event",
|
|
100
|
+
"id": "jtestssh001",
|
|
101
|
+
"event": "etestssh001",
|
|
102
|
+
"plugin": "ptestssh001",
|
|
103
|
+
"server": "local",
|
|
104
|
+
"base_url": "https://xyops.example.com",
|
|
105
|
+
"params": {
|
|
106
|
+
"hostname": "127.0.0.1",
|
|
107
|
+
"username": "deploy",
|
|
108
|
+
"port": 22,
|
|
109
|
+
"connect_timeout_sec": 10,
|
|
110
|
+
"remote_env": {
|
|
111
|
+
"APP_ENV": "dev"
|
|
112
|
+
},
|
|
113
|
+
"tool": "script",
|
|
114
|
+
"remote_command": "bash -se",
|
|
115
|
+
"script": "set -euo pipefail\\necho \\\"remote host: $(hostname)\\\"\\necho \\\"job id: $XYOPS_JOB_ID\\\"\\n"
|
|
116
|
+
},
|
|
117
|
+
"secrets": {
|
|
118
|
+
"SSH_PRIVATE_KEY": "-----BEGIN OPENSSH PRIVATE KEY-----\\nREPLACE_ME\\n-----END OPENSSH PRIVATE KEY-----"
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
JSON
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### Job JSON Mode Example
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
cat <<'JSON' | node index.js
|
|
128
|
+
{
|
|
129
|
+
"xy": 1,
|
|
130
|
+
"type": "event",
|
|
131
|
+
"id": "jtestssh002",
|
|
132
|
+
"event": "etestssh002",
|
|
133
|
+
"plugin": "ptestssh002",
|
|
134
|
+
"server": "local",
|
|
135
|
+
"base_url": "https://xyops.example.com",
|
|
136
|
+
"params": {
|
|
137
|
+
"hostname": "127.0.0.1",
|
|
138
|
+
"username": "deploy",
|
|
139
|
+
"port": 22,
|
|
140
|
+
"tool": "job_json",
|
|
141
|
+
"remote_command": "node /opt/xyops/remote-plugin.js"
|
|
142
|
+
},
|
|
143
|
+
"secrets": {
|
|
144
|
+
"SSH_PRIVATE_KEY": "-----BEGIN OPENSSH PRIVATE KEY-----\\nREPLACE_ME\\n-----END OPENSSH PRIVATE KEY-----"
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
JSON
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## Security Notes
|
|
151
|
+
|
|
152
|
+
- Prefer private key auth stored in xyOps Secrets over inline passwords.
|
|
153
|
+
- Set `Host Fingerprint` in production so the plugin pins the remote host key.
|
|
154
|
+
|
|
155
|
+
## Data Collection
|
|
156
|
+
|
|
157
|
+
This plugin does not intentionally collect telemetry, analytics, or usage metrics.
|
|
158
|
+
|
|
159
|
+
## License
|
|
160
|
+
|
|
161
|
+
MIT.
|
package/index.js
ADDED
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// SSH Plugin for xyOps
|
|
4
|
+
// Copyright (c) 2026 PixlCore LLC
|
|
5
|
+
// MIT License
|
|
6
|
+
|
|
7
|
+
const { createHash } = require('crypto');
|
|
8
|
+
const { Client } = require('ssh2');
|
|
9
|
+
|
|
10
|
+
class HandledError extends Error {}
|
|
11
|
+
|
|
12
|
+
const app = {
|
|
13
|
+
conn: null,
|
|
14
|
+
stream: null,
|
|
15
|
+
finalSent: false,
|
|
16
|
+
remoteSentFinal: false,
|
|
17
|
+
shuttingDown: false,
|
|
18
|
+
stdoutBuffer: '',
|
|
19
|
+
verbose: false,
|
|
20
|
+
exitCode: 0,
|
|
21
|
+
exitSignal: '',
|
|
22
|
+
|
|
23
|
+
async run() {
|
|
24
|
+
const { raw, job } = await readJob();
|
|
25
|
+
this.rawJob = raw;
|
|
26
|
+
this.job = job;
|
|
27
|
+
this.params = job.params || {};
|
|
28
|
+
this.verbose = toBool(this.params.verbose);
|
|
29
|
+
|
|
30
|
+
const mode = String(this.params.tool || 'script').trim() || 'script';
|
|
31
|
+
if (!TOOLS[mode]) fatal('params', `Unknown run mode: ${mode}`);
|
|
32
|
+
|
|
33
|
+
const secrets = collectSecrets(job);
|
|
34
|
+
const target = parseTarget(
|
|
35
|
+
String(this.params.hostname || '').trim(),
|
|
36
|
+
String(this.params.username || '').trim(),
|
|
37
|
+
this.params.port
|
|
38
|
+
);
|
|
39
|
+
const auth = resolveAuth(secrets);
|
|
40
|
+
const remoteCommand = String(this.params.remote_command || '').trim();
|
|
41
|
+
if (!remoteCommand) fatal('params', "Required parameter 'remote_command' was not provided.");
|
|
42
|
+
|
|
43
|
+
const connectTimeoutSec = toPositiveInt(this.params.connect_timeout_sec, 30);
|
|
44
|
+
const remoteEnv = buildRemoteEnv(job, this.params, secrets, mode);
|
|
45
|
+
const bootstrap = buildBootstrap(remoteEnv, remoteCommand);
|
|
46
|
+
const sshConfig = buildSshConfig(target, auth, this.params.host_fingerprint, connectTimeoutSec);
|
|
47
|
+
|
|
48
|
+
this.installSignalHandlers();
|
|
49
|
+
this.log(`Connecting to ${target.username}@${target.host}:${target.port} (${mode})`);
|
|
50
|
+
this.log(`Remote command: ${remoteCommand}`);
|
|
51
|
+
|
|
52
|
+
await this.connect(sshConfig);
|
|
53
|
+
await this.exec(bootstrap);
|
|
54
|
+
|
|
55
|
+
const payload = (mode === 'job_json')
|
|
56
|
+
? ensureTrailingNewline(this.rawJob)
|
|
57
|
+
: ensureTrailingNewline(String(this.params.script || ''));
|
|
58
|
+
|
|
59
|
+
if ((mode === 'script') && !payload.trim()) {
|
|
60
|
+
fatal('params', "Required parameter 'script' was not provided.");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
this.stream.end(payload);
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
connect(config) {
|
|
67
|
+
return new Promise((resolve, reject) => {
|
|
68
|
+
const conn = new Client();
|
|
69
|
+
this.conn = conn;
|
|
70
|
+
|
|
71
|
+
conn.once('ready', () => {
|
|
72
|
+
conn.on('error', (err) => {
|
|
73
|
+
if (this.shuttingDown || this.finalSent) return;
|
|
74
|
+
this.fail('ssh', err && err.message ? err.message : 'SSH connection error.');
|
|
75
|
+
});
|
|
76
|
+
resolve();
|
|
77
|
+
});
|
|
78
|
+
conn.once('error', reject);
|
|
79
|
+
|
|
80
|
+
conn.connect(config);
|
|
81
|
+
});
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
exec(bootstrap) {
|
|
85
|
+
return new Promise((resolve, reject) => {
|
|
86
|
+
const command = `/bin/sh -lc ${quoteShell(bootstrap)}`;
|
|
87
|
+
this.conn.exec(command, (err, stream) => {
|
|
88
|
+
if (err) return reject(err);
|
|
89
|
+
this.stream = stream;
|
|
90
|
+
|
|
91
|
+
stream.on('data', (chunk) => this.handleStdout(chunk));
|
|
92
|
+
stream.stderr.on('data', (chunk) => process.stderr.write(chunk));
|
|
93
|
+
stream.on('exit', (code, signal) => {
|
|
94
|
+
this.exitCode = (typeof code === 'number') ? code : 0;
|
|
95
|
+
this.exitSignal = signal || '';
|
|
96
|
+
});
|
|
97
|
+
stream.on('close', () => this.handleClose());
|
|
98
|
+
|
|
99
|
+
resolve(stream);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
handleStdout(chunk) {
|
|
105
|
+
const text = Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk);
|
|
106
|
+
process.stdout.write(text);
|
|
107
|
+
this.stdoutBuffer += text;
|
|
108
|
+
|
|
109
|
+
let idx = 0;
|
|
110
|
+
while ((idx = this.stdoutBuffer.indexOf('\n')) > -1) {
|
|
111
|
+
const line = this.stdoutBuffer.slice(0, idx).replace(/\r$/, '');
|
|
112
|
+
this.stdoutBuffer = this.stdoutBuffer.slice(idx + 1);
|
|
113
|
+
this.inspectLine(line);
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
inspectLine(line) {
|
|
118
|
+
if (!line || !line.trim()) return;
|
|
119
|
+
try {
|
|
120
|
+
const msg = JSON.parse(line);
|
|
121
|
+
if (msg && (msg.xy === 1) && Object.prototype.hasOwnProperty.call(msg, 'code')) {
|
|
122
|
+
this.remoteSentFinal = true;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
catch (err) {
|
|
126
|
+
// ignore plain text output
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
handleClose() {
|
|
131
|
+
if (this.stdoutBuffer) {
|
|
132
|
+
this.inspectLine(this.stdoutBuffer.replace(/\r$/, ''));
|
|
133
|
+
this.stdoutBuffer = '';
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (this.conn) {
|
|
137
|
+
try { this.conn.end(); }
|
|
138
|
+
catch (err) {;}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (this.shuttingDown || this.finalSent) return process.exit(0);
|
|
142
|
+
if (this.remoteSentFinal) return process.exit(0);
|
|
143
|
+
|
|
144
|
+
if (this.exitSignal) {
|
|
145
|
+
return this.sendFinal({
|
|
146
|
+
code: `signal:${this.exitSignal}`,
|
|
147
|
+
description: `Remote command exited due to signal ${this.exitSignal}.`
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (this.exitCode) {
|
|
152
|
+
return this.sendFinal({
|
|
153
|
+
code: this.exitCode,
|
|
154
|
+
description: `Remote command exited with code ${this.exitCode}.`
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return this.sendFinal({ code: 0 });
|
|
159
|
+
},
|
|
160
|
+
|
|
161
|
+
sendFinal(payload) {
|
|
162
|
+
if (this.finalSent) return;
|
|
163
|
+
this.finalSent = true;
|
|
164
|
+
payload.xy = 1;
|
|
165
|
+
process.stdout.write(`${JSON.stringify(payload)}\n`, () => process.exit(0));
|
|
166
|
+
},
|
|
167
|
+
|
|
168
|
+
fail(code, description) {
|
|
169
|
+
this.sendFinal({ code, description });
|
|
170
|
+
},
|
|
171
|
+
|
|
172
|
+
log(message) {
|
|
173
|
+
if (this.verbose) process.stderr.write(`[xyplug-ssh] ${message}\n`);
|
|
174
|
+
},
|
|
175
|
+
|
|
176
|
+
installSignalHandlers() {
|
|
177
|
+
const handler = (signal) => {
|
|
178
|
+
this.shuttingDown = true;
|
|
179
|
+
this.log(`Caught ${signal}, closing SSH session.`);
|
|
180
|
+
try {
|
|
181
|
+
if (this.stream) this.stream.close();
|
|
182
|
+
}
|
|
183
|
+
catch (err) {;}
|
|
184
|
+
try {
|
|
185
|
+
if (this.conn) this.conn.end();
|
|
186
|
+
}
|
|
187
|
+
catch (err) {;}
|
|
188
|
+
setTimeout(() => process.exit(0), 250).unref();
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
process.once('SIGTERM', () => handler('SIGTERM'));
|
|
192
|
+
process.once('SIGINT', () => handler('SIGINT'));
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const TOOLS = {
|
|
197
|
+
script: true,
|
|
198
|
+
job_json: true
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
async function readJob() {
|
|
202
|
+
const chunks = [];
|
|
203
|
+
for await (const chunk of process.stdin) {
|
|
204
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const raw = Buffer.concat(chunks).toString('utf8').trim();
|
|
208
|
+
if (!raw) fatal('input', 'No JSON input received on STDIN.');
|
|
209
|
+
|
|
210
|
+
try {
|
|
211
|
+
return { raw, job: JSON.parse(raw) };
|
|
212
|
+
}
|
|
213
|
+
catch (err) {
|
|
214
|
+
fatal('input', `Failed to parse JSON input: ${err.message}`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function parseTarget(hostname, username, portFallback) {
|
|
219
|
+
const host = String(hostname || '').trim();
|
|
220
|
+
const user = String(username || '').trim();
|
|
221
|
+
const port = toPositiveInt(portFallback, 22);
|
|
222
|
+
|
|
223
|
+
if (!host) fatal('params', "Required parameter 'hostname' was not provided.");
|
|
224
|
+
if (!user) fatal('params', "Required parameter 'username' was not provided.");
|
|
225
|
+
|
|
226
|
+
return { host, port, username: user };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function resolveAuth(secrets) {
|
|
230
|
+
const privateKey = resolveNamedValue('SSH_PRIVATE_KEY', secrets);
|
|
231
|
+
const passphrase = resolveNamedValue('SSH_PASSPHRASE', secrets);
|
|
232
|
+
const password = resolveNamedValue('SSH_PASSWORD', secrets);
|
|
233
|
+
const agent = process.env.SSH_AUTH_SOCK || '';
|
|
234
|
+
|
|
235
|
+
if (!privateKey && !password && !agent) {
|
|
236
|
+
fatal(
|
|
237
|
+
'auth',
|
|
238
|
+
'No SSH auth credentials were found. Set SSH_PRIVATE_KEY, SSH_PASSWORD, or provide an ssh-agent via SSH_AUTH_SOCK.'
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return { privateKey, passphrase, password, agent };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function resolveNamedValue(name, secrets) {
|
|
246
|
+
if (!name) return '';
|
|
247
|
+
if (Object.prototype.hasOwnProperty.call(secrets, name)) return String(secrets[name]);
|
|
248
|
+
if (Object.prototype.hasOwnProperty.call(process.env, name)) return String(process.env[name]);
|
|
249
|
+
return '';
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function buildSshConfig(target, auth, fingerprint, connectTimeoutSec) {
|
|
253
|
+
const config = {
|
|
254
|
+
host: target.host,
|
|
255
|
+
port: target.port,
|
|
256
|
+
username: target.username,
|
|
257
|
+
readyTimeout: connectTimeoutSec * 1000,
|
|
258
|
+
keepaliveInterval: 15000,
|
|
259
|
+
keepaliveCountMax: 4
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
if (auth.privateKey) config.privateKey = Buffer.from(auth.privateKey, 'utf8');
|
|
263
|
+
if (auth.passphrase) config.passphrase = auth.passphrase;
|
|
264
|
+
if (auth.password) config.password = auth.password;
|
|
265
|
+
if (!auth.privateKey && !auth.password && auth.agent) config.agent = auth.agent;
|
|
266
|
+
|
|
267
|
+
const expected = String(fingerprint || '').trim();
|
|
268
|
+
if (expected) {
|
|
269
|
+
config.hostVerifier = function(hostKey) {
|
|
270
|
+
const actualSha256 = `SHA256:${createHash('sha256').update(hostKey).digest('base64').replace(/=+$/, '')}`;
|
|
271
|
+
const actualMd5 = createHash('md5').update(hostKey).digest('hex').match(/.{2}/g).join(':');
|
|
272
|
+
return matchesFingerprint(expected, actualSha256, actualMd5);
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return config;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function matchesFingerprint(expected, actualSha256, actualMd5) {
|
|
280
|
+
const value = String(expected || '').trim();
|
|
281
|
+
if (!value) return true;
|
|
282
|
+
if (/^sha256:/i.test(value)) return value === actualSha256;
|
|
283
|
+
if (/^[a-f0-9]{2}(?::[a-f0-9]{2}){15}$/i.test(value)) return value.toLowerCase() === actualMd5.toLowerCase();
|
|
284
|
+
return (`SHA256:${value.replace(/^SHA256:/i, '').replace(/=+$/, '')}` === actualSha256);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function collectSecrets(job) {
|
|
288
|
+
const secrets = {};
|
|
289
|
+
if (!job || !job.secrets || (typeof job.secrets !== 'object') || Array.isArray(job.secrets)) return secrets;
|
|
290
|
+
|
|
291
|
+
for (const [key, value] of Object.entries(job.secrets)) {
|
|
292
|
+
if (!isValidEnvName(key) || (value === undefined) || (value === null)) continue;
|
|
293
|
+
secrets[key] = String(value);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return secrets;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function buildRemoteEnv(job, params, secrets, mode) {
|
|
300
|
+
const env = {};
|
|
301
|
+
|
|
302
|
+
for (const [key, value] of Object.entries(secrets)) {
|
|
303
|
+
if (!/^(SSH_)/.test(key)) continue;
|
|
304
|
+
env[key] = value;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
308
|
+
if (!/^(XYOPS_|JOB_)/.test(key)) continue;
|
|
309
|
+
if (!isValidEnvName(key)) continue;
|
|
310
|
+
if (value === undefined || value === null) continue;
|
|
311
|
+
env[key] = String(value);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const meta = {
|
|
315
|
+
XYOPS_JOB_ID: job.id || '',
|
|
316
|
+
XYOPS_EVENT_ID: job.event || '',
|
|
317
|
+
XYOPS_PLUGIN_ID: job.plugin || '',
|
|
318
|
+
XYOPS_SERVER_ID: job.server || '',
|
|
319
|
+
XYOPS_BASE_URL: job.base_url || '',
|
|
320
|
+
XYOPS_RUN_MODE: mode
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
for (const [key, value] of Object.entries(meta)) {
|
|
324
|
+
if (value !== undefined && value !== null && value !== '') env[key] = String(value);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
for (const [key, value] of Object.entries(params)) {
|
|
328
|
+
if (KNOWN_PARAM_KEYS.has(key)) continue;
|
|
329
|
+
if (!isValidEnvName(key)) continue;
|
|
330
|
+
if ((typeof value === 'string') || (typeof value === 'number')) {
|
|
331
|
+
env[key] = String(value);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const extra = params.remote_env;
|
|
336
|
+
if (extra !== undefined && extra !== null) {
|
|
337
|
+
if ((typeof extra !== 'object') || Array.isArray(extra)) {
|
|
338
|
+
fatal('params', "Parameter 'remote_env' must be a JSON object.");
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
for (const [key, value] of Object.entries(extra)) {
|
|
342
|
+
if (!isValidEnvName(key)) fatal('params', `Invalid remote_env key: ${key}`);
|
|
343
|
+
env[key] = normalizeEnvValue(value);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
return env;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function normalizeEnvValue(value) {
|
|
351
|
+
if (value === undefined || value === null) return '';
|
|
352
|
+
if (typeof value === 'string') return value;
|
|
353
|
+
if ((typeof value === 'number') || (typeof value === 'boolean')) return String(value);
|
|
354
|
+
return JSON.stringify(value);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function buildBootstrap(env, remoteCommand) {
|
|
358
|
+
const lines = [
|
|
359
|
+
'decode_b64() {',
|
|
360
|
+
' if command -v base64 >/dev/null 2>&1; then',
|
|
361
|
+
' base64 --decode 2>/dev/null || base64 -d 2>/dev/null || base64 -D 2>/dev/null;',
|
|
362
|
+
' elif command -v openssl >/dev/null 2>&1; then',
|
|
363
|
+
' openssl base64 -d -A;',
|
|
364
|
+
' else',
|
|
365
|
+
' echo "Missing base64 or openssl on remote host." >&2;',
|
|
366
|
+
' return 127;',
|
|
367
|
+
' fi',
|
|
368
|
+
'}'
|
|
369
|
+
];
|
|
370
|
+
|
|
371
|
+
for (const key of Object.keys(env).sort()) {
|
|
372
|
+
const encoded = Buffer.from(String(env[key]), 'utf8').toString('base64');
|
|
373
|
+
lines.push(`export ${key}="$(printf %s ${quoteShell(encoded)} | decode_b64)"`);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
lines.push(`exec ${remoteCommand}`);
|
|
377
|
+
return lines.join('\n');
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function isValidEnvName(name) {
|
|
381
|
+
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(String(name || ''));
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function toBool(value) {
|
|
385
|
+
if ((value === true) || (value === false)) return value;
|
|
386
|
+
const text = String(value || '').trim().toLowerCase();
|
|
387
|
+
return (text === '1') || (text === 'true') || (text === 'yes') || (text === 'on');
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function toPositiveInt(value, fallback) {
|
|
391
|
+
const num = parseInt(value, 10);
|
|
392
|
+
return Number.isFinite(num) && (num > 0) ? num : fallback;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function ensureTrailingNewline(text) {
|
|
396
|
+
const value = String(text || '');
|
|
397
|
+
return value.endsWith('\n') ? value : `${value}\n`;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function quoteShell(text) {
|
|
401
|
+
return `'${String(text).replace(/'/g, `'\"'\"'`)}'`;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const KNOWN_PARAM_KEYS = new Set([
|
|
405
|
+
'hostname',
|
|
406
|
+
'username',
|
|
407
|
+
'port',
|
|
408
|
+
'host_fingerprint',
|
|
409
|
+
'connect_timeout_sec',
|
|
410
|
+
'remote_env',
|
|
411
|
+
'verbose',
|
|
412
|
+
'tool',
|
|
413
|
+
'remote_command',
|
|
414
|
+
'script'
|
|
415
|
+
]);
|
|
416
|
+
|
|
417
|
+
function fatal(code, description) {
|
|
418
|
+
app.fail(code, description);
|
|
419
|
+
throw new HandledError(description);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
app.run().catch((err) => {
|
|
423
|
+
if (err instanceof HandledError) return;
|
|
424
|
+
if (app.verbose && err) {
|
|
425
|
+
process.stderr.write(`${err && err.stack ? err.stack : err}\n`);
|
|
426
|
+
}
|
|
427
|
+
app.fail('error', err && err.message ? err.message : 'Unknown error');
|
|
428
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pixlcore/xyplug-ssh",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "An SSH runner plugin for the xyOps workflow automation system.",
|
|
5
|
+
"author": "Joseph Huckaby <jhuckaby@pixlcore.com>",
|
|
6
|
+
"homepage": "https://github.com/pixlcore/xyplug-ssh",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"bin": {
|
|
9
|
+
"xyplug-ssh": "index.js"
|
|
10
|
+
},
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "https://github.com/pixlcore/xyplug-ssh"
|
|
14
|
+
},
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/pixlcore/xyplug-ssh/issues"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"xyops",
|
|
20
|
+
"ssh",
|
|
21
|
+
"runner",
|
|
22
|
+
"remote"
|
|
23
|
+
],
|
|
24
|
+
"files": [
|
|
25
|
+
"index.js",
|
|
26
|
+
"README.md",
|
|
27
|
+
"LICENSE.md",
|
|
28
|
+
"xyops.json"
|
|
29
|
+
],
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=18"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"ssh2": "^1.16.0"
|
|
35
|
+
},
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public"
|
|
38
|
+
}
|
|
39
|
+
}
|
package/xyops.json
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
{
|
|
2
|
+
"type": "xypdf",
|
|
3
|
+
"description": "xyOps Portable Data Object",
|
|
4
|
+
"version": "1.0",
|
|
5
|
+
"items": [
|
|
6
|
+
{
|
|
7
|
+
"type": "plugin",
|
|
8
|
+
"data": {
|
|
9
|
+
"id": "pmsshnak612ag9veb",
|
|
10
|
+
"title": "Remote SSH",
|
|
11
|
+
"enabled": true,
|
|
12
|
+
"type": "event",
|
|
13
|
+
"command": "npx -y @pixlcore/xyplug-ssh@1.0.0",
|
|
14
|
+
"script": "",
|
|
15
|
+
"kill": "parent",
|
|
16
|
+
"runner": true,
|
|
17
|
+
"notes": "Run remote commands or scripts over SSH.",
|
|
18
|
+
"icon": "console-network-outline",
|
|
19
|
+
"params": [
|
|
20
|
+
{
|
|
21
|
+
"id": "hostname",
|
|
22
|
+
"title": "SSH Hostname",
|
|
23
|
+
"type": "text",
|
|
24
|
+
"value": "",
|
|
25
|
+
"caption": "Remote hostname or IP address, e.g. `deploy.example.com` or `10.0.0.25`.",
|
|
26
|
+
"required": true
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"id": "username",
|
|
30
|
+
"title": "Username",
|
|
31
|
+
"type": "text",
|
|
32
|
+
"value": "",
|
|
33
|
+
"caption": "SSH username to connect with.",
|
|
34
|
+
"required": true
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"id": "port",
|
|
38
|
+
"title": "Port",
|
|
39
|
+
"type": "text",
|
|
40
|
+
"variant": "number",
|
|
41
|
+
"value": 22,
|
|
42
|
+
"caption": "SSH TCP port."
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"id": "host_fingerprint",
|
|
46
|
+
"title": "Host Fingerprint",
|
|
47
|
+
"type": "text",
|
|
48
|
+
"value": "",
|
|
49
|
+
"caption": "Optional host key pin. Accepts `SHA256:...` or MD5 fingerprint text. Recommended for production."
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
"id": "connect_timeout_sec",
|
|
53
|
+
"title": "Connect Timeout (sec)",
|
|
54
|
+
"type": "text",
|
|
55
|
+
"variant": "number",
|
|
56
|
+
"value": 30,
|
|
57
|
+
"caption": "How long to wait for the SSH session to connect."
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
"id": "remote_env",
|
|
61
|
+
"title": "Remote Env",
|
|
62
|
+
"type": "json",
|
|
63
|
+
"value": {},
|
|
64
|
+
"caption": "Optional extra key/value pairs to send to the remote side."
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
"id": "verbose",
|
|
68
|
+
"title": "Verbose Logging",
|
|
69
|
+
"type": "checkbox",
|
|
70
|
+
"value": false,
|
|
71
|
+
"caption": "Log SSH connection details to the job log."
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
"id": "tool",
|
|
75
|
+
"title": "Run Mode",
|
|
76
|
+
"type": "toolset",
|
|
77
|
+
"caption": "",
|
|
78
|
+
"data": {
|
|
79
|
+
"tools": [
|
|
80
|
+
{
|
|
81
|
+
"id": "script",
|
|
82
|
+
"title": "Pipe Script to STDIN",
|
|
83
|
+
"description": "Export env vars remotely and pipe your script into the selected command.",
|
|
84
|
+
"fields": [
|
|
85
|
+
{
|
|
86
|
+
"id": "remote_command",
|
|
87
|
+
"title": "Remote Command",
|
|
88
|
+
"type": "text",
|
|
89
|
+
"value": "bash -se",
|
|
90
|
+
"caption": "Command to execute remotely. It will receive the script on STDIN.",
|
|
91
|
+
"required": true
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
"id": "script",
|
|
95
|
+
"title": "Script Source",
|
|
96
|
+
"type": "code",
|
|
97
|
+
"value": "#!/usr/bin/env bash\nset -euo pipefail\n\necho \"Hello from $(hostname)\"\n",
|
|
98
|
+
"caption": "Script contents to pipe into the remote command.",
|
|
99
|
+
"required": true
|
|
100
|
+
}
|
|
101
|
+
]
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
"id": "job_json",
|
|
105
|
+
"title": "Pipe Full Job JSON",
|
|
106
|
+
"description": "Send the full xyOps job JSON to a remote program that knows how to read STDIN and optionally emit XYWP.",
|
|
107
|
+
"fields": [
|
|
108
|
+
{
|
|
109
|
+
"id": "remote_command",
|
|
110
|
+
"title": "Remote Command",
|
|
111
|
+
"type": "text",
|
|
112
|
+
"value": "node /path/to/my-remote-plugin.js",
|
|
113
|
+
"caption": "Command to execute remotely. It will receive the full job JSON on STDIN.",
|
|
114
|
+
"required": true
|
|
115
|
+
}
|
|
116
|
+
]
|
|
117
|
+
}
|
|
118
|
+
]
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
]
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
]
|
|
125
|
+
}
|