-
-
Notifications
You must be signed in to change notification settings - Fork 109
Expand file tree
/
Copy pathSshClient.cfc
More file actions
365 lines (346 loc) · 13.7 KB
/
Copy pathSshClient.cfc
File metadata and controls
365 lines (346 loc) · 13.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
/**
* SshClient — single-host SSH facade over sshj 0.39.0.
*
* One instance == one remote host. `init()` opens the connection; call
* `.close()` when done. For parallel fan-out across multiple hosts, Task 9
* introduces `SshPool` which manages a pool of SshClient instances.
*
* All sshj classes are loaded through JarLoader's isolated URLClassLoader so
* BouncyCastle (sshj's crypto backend) doesn't collide with Lucee's bundled
* provider. Same pattern as Mustache.cfc / Yaml.cfc.
*
* Capabilities:
* - run(cmd, opts) remote shell + stdout/stderr/exitCode/durationMs
* - upload(local, remote, opts) SFTP put
* - uploadString(content, remote) write-to-temp + SFTP put
* - download(remote, local) SFTP get
* - close() disconnect if connected
*
* Sudo wrapping: pass `opts.sudo = true` to `run()`. If the user is not root,
* the command is prefixed with `sudo -n `. If sudo requires a password (not
* configured for NOPASSWD), sshj surfaces "a password is required" on stderr
* and we throw `SshClient.SudoNoPassword`.
*/
component {
/**
* Open the SSH connection.
*
* @host Remote host (DNS or IP).
* @opts Keys:
* - user default "root"
* - port default 22
* - privateKey path to private key file; if empty, falls back to ssh-agent
* - strictHostKeyChecking default true; when false, PromiscuousVerifier is used
* - timeoutMs connect + session timeout, default 30000
*/
public SshClient function init(string host = "", struct opts = {}) {
variables.$loader = new modules.wheels.services.deploy.lib.JarLoader();
// Resolved-secret values to scrub from RemoteExecutionFailed command
// summaries (#3159). Seeded empty; deploy verbs register the config's
// resolved secrets via $setSecretValues after loading. Set before the
// deferred-open early return so an unconnected client still redacts.
variables.$secretValues = [];
// Deferred-open pattern: `new SshClient()` with no args is a no-op so
// Lucee's implicit init-on-new doesn't try to connect. Callers use
// `new SshClient().init(host, opts)` to actually open a connection.
if (!len(arguments.host)) {
return this;
}
variables.$host = arguments.host;
variables.$opts = {
user: arguments.opts.user ?: "root",
port: arguments.opts.port ?: 22,
privateKey: arguments.opts.privateKey ?: "",
strictHostKeyChecking: arguments.opts.strictHostKeyChecking ?: true,
timeoutMs: arguments.opts.timeoutMs ?: 30000
};
// All sshj classes go through the isolated loader. TCCL swap ensures
// sshj's internal ServiceLoader lookups (JCE, key formats) resolve
// against our BouncyCastle, not Lucee's.
var loader = variables.$loader;
variables.$sshj = loader.newInstance("net.schmizz.sshj.SSHClient");
loader.withIsolatedTCCL(() => {
if (!variables.$opts.strictHostKeyChecking) {
var promiscuous = loader.newInstance(
"net.schmizz.sshj.transport.verification.PromiscuousVerifier"
);
variables.$sshj.addHostKeyVerifier(promiscuous);
} else {
variables.$sshj.loadKnownHosts();
}
variables.$sshj.setTimeout(javaCast("int", variables.$opts.timeoutMs));
variables.$sshj.connect(variables.$host, javaCast("int", variables.$opts.port));
if (len(variables.$opts.privateKey)) {
var keyProvider = variables.$sshj.loadKeys(variables.$opts.privateKey);
variables.$sshj.authPublickey(variables.$opts.user, [keyProvider]);
} else {
// Varargs-style fallback to ssh-agent. If the agent isn't running,
// sshj raises UserAuthException — the caller should pass a
// privateKey path instead.
variables.$sshj.authPublickey(variables.$opts.user);
}
});
return this;
}
/**
* Run `cmd` on the remote host via an SSH session.
*
* @cmd Shell command string.
* @opts Keys:
* - sudo boolean, default false. If true AND user != "root", prefixes
* the command with `sudo -n `. Throws SshClient.SudoNoPassword
* if NOPASSWD isn't configured on the remote host.
* - raise boolean, default false. If true and the remote exit code is
* nonzero, throws Wheels.Deploy.RemoteExecutionFailed with the
* host, exitCode, and a command summary in the message and the
* trimmed stderr in the detail. Mirrors the existing sudo-no-
* password throw shape, just for arbitrary remote failures.
* Regression #2696 — deploy verbs used to discard the result
* struct and silently treat any nonzero exit as success.
* - stdin string, default "". If non-empty, written UTF-8 to the
* remote process's stdin and then closed (EOF) before the
* output streams are drained. This is how secrets reach
* stdin-reading commands like `docker login
* --password-stdin` without ever entering the command
* string — keeping them out of dry-run output, exception
* summaries, and the remote process table (#2956).
*
* @return struct {exitCode, stdout, stderr, durationMs}
*/
public struct function run(required string cmd, struct opts = {}) {
var useSudo = (arguments.opts.sudo ?: false) && variables.$opts.user != "root";
var raise = arguments.opts.raise ?: false;
var stdinData = arguments.opts.stdin ?: "";
var effectiveCmd = useSudo ? "sudo -n " & arguments.cmd : arguments.cmd;
var loader = variables.$loader;
var hostRef = variables.$host;
var sshjRef = variables.$sshj;
var origCmd = arguments.cmd;
var start = getTickCount();
return loader.withIsolatedTCCL(() => {
// NOTE: variable name deliberately NOT "session" — that's a Lucee
// reserved scope. Using `sess` to stay out of the scope resolver.
var sess = sshjRef.startSession();
try {
var command = sess.exec(effectiveCmd);
// Feed stdin BEFORE draining stdout/stderr: commands like
// `docker login --password-stdin` block on stdin until EOF,
// so the stream must be written, flushed, and closed first.
if (len(stdinData)) {
var os = command.getOutputStream();
os.write(charsetDecode(stdinData, "UTF-8"));
os.flush();
os.close();
}
// Drain both streams before join() to avoid blocking on a full pipe buffer.
// We read via JDK streams rather than commons-io — sshj 0.39.0 doesn't
// ship IOUtils and we'd rather not bundle another JAR just for this.
var stdout = $readStream(command.getInputStream());
var stderr = $readStream(command.getErrorStream());
command.join();
var exitCode = command.getExitStatus();
// sshj returns null for exitCode if the process died mid-flight
// (closed by signal, network drop). Surface -1 for that case.
if (isNull(exitCode)) {
exitCode = -1;
}
var result = {
exitCode: exitCode,
stdout: stdout,
stderr: stderr,
durationMs: getTickCount() - start
};
if (useSudo && exitCode != 0 && findNoCase("a password is required", stderr)) {
throw(
type = "SshClient.SudoNoPassword",
message = "Passwordless sudo not configured on #hostRef#"
);
}
if (raise && exitCode != 0) {
$raiseRemoteFailure(hostRef, origCmd, result);
}
return result;
} finally {
sess.close();
}
});
}
/**
* Throw Wheels.Deploy.RemoteExecutionFailed with structured detail. Public
* so FakeSshPool can share the exact same throw shape; tests assert against
* this contract. Regression #2696.
*
* Trims the command summary to 200 chars and stderr to 500 chars so log
* output stays scannable when long shell pipelines or noisy stderr would
* otherwise dominate the surfaced error.
*
* Resolved secret values registered via $setSecretValues are scrubbed from
* the command summary BEFORE the trim, so a value sitting on the 200-char
* boundary can't leak a partial fragment (#3159). env.clear values
* interpolated from ${SECRET} tokens ride as `docker run ... -e KEY=value`,
* which would otherwise surface in this message and in CI logs.
*
* MIRROR: FakeSshPool.$raiseRemoteFailure (and $setSecretValues /
* $redactSecrets) must stay byte-identical to this method. If you change
* the trim limits, throw type, message template, or redaction behavior
* here, update the test double in lockstep — tests assert against this
* exact shape regardless of which pool the deploy layer is talking to.
*/
public void function $raiseRemoteFailure(
required string host,
required string cmd,
required struct result
) {
var stderr = arguments.result.stderr ?: "";
if (len(stderr) > 500) {
stderr = left(stderr, 500) & "…";
}
var cmdSummary = $redactSecrets(arguments.cmd);
if (len(cmdSummary) > 200) {
cmdSummary = left(cmdSummary, 200) & "…";
}
throw(
type = "Wheels.Deploy.RemoteExecutionFailed",
message = "Remote command failed on " & arguments.host
& " (exit " & arguments.result.exitCode & "): " & cmdSummary,
detail = stderr
);
}
/**
* Register the set of resolved secret values to redact from
* RemoteExecutionFailed command summaries (#3159). Deploy verbs call this
* with SecretResolver.all()'s values after loading config so env.clear
* values interpolated from ${SECRET} tokens never reach a thrown message.
*
* MIRROR: keep byte-identical with FakeSshPool.$setSecretValues.
*/
public void function $setSecretValues(required array values) {
variables.$secretValues = arguments.values;
}
/**
* Replace every occurrence of each registered secret value with
* [REDACTED]. Empty and trivially short values are skipped so they can't
* mangle unrelated text — a 1-char or 2-char "secret" would otherwise
* shred ordinary command bytes. A value may appear multiple times (several
* -e flags), so every occurrence is replaced (#3159).
*
* MIRROR: keep byte-identical with FakeSshPool.$redactSecrets.
*/
public string function $redactSecrets(required string text) {
var out = arguments.text;
var values = variables.$secretValues ?: [];
for (var v in values) {
if (isSimpleValue(v) && len(v) >= 4) {
out = replace(out, v, "[REDACTED]", "all");
}
}
return out;
}
/**
* SFTP upload a local file to `remotePath`.
*
* @opts Reserved for future SFTP flags (mode, preserve-times). Currently unused.
*/
public void function upload(
required string localPath,
required string remotePath,
struct opts = {}
) {
var sshjRef = variables.$sshj;
var localRef = arguments.localPath;
var remoteRef = arguments.remotePath;
variables.$loader.withIsolatedTCCL(() => {
var sftp = sshjRef.newSFTPClient();
try {
// sshj's SFTPFileTransfer defaults preserveAttributes=true,
// which "preserves" the LOCAL file's attributes onto the
// remote after the transfer — and FileSystemFile
// .getPermissions() hardcodes 0644 for regular files
// (verified against the bundled sshj-0.39.0 bytecode). Left
// on, every put() would chmod the remote file to 0644,
// silently undoing any stricter mode set before the upload
// (e.g. the chmod 600 the env-file delivery flow applies
// before secret content lands — #2957). An upload must never
// touch remote permissions, so switch it off.
//
// NOTE: FakeSshPool cannot regression-test this — it records
// upload/uploadString calls without SFTP attribute semantics,
// so a fake-pool spec stays green whether or not this line
// exists. The $deliverEnvFile callers also dispatch a
// post-upload `chmod 600` re-lock as belt-and-braces, which
// the fake-pool specs DO pin.
sftp.getFileTransfer().setPreserveAttributes(javaCast("boolean", false));
sftp.put(localRef, remoteRef);
} finally {
sftp.close();
}
});
}
/**
* Write `content` to a temp file and upload it to `remotePath`. Temp file
* is deleted in a finally block regardless of upload outcome.
*/
public void function uploadString(
required string content,
required string remotePath,
struct opts = {}
) {
var tmp = getTempFile(getTempDirectory(), "sshstr");
fileWrite(tmp, arguments.content);
try {
upload(tmp, arguments.remotePath, arguments.opts);
} finally {
if (fileExists(tmp)) {
fileDelete(tmp);
}
}
}
/**
* SFTP download `remotePath` to `localPath`.
*/
public void function download(required string remotePath, required string localPath) {
var sshjRef = variables.$sshj;
var remoteRef = arguments.remotePath;
var localRef = arguments.localPath;
variables.$loader.withIsolatedTCCL(() => {
var sftp = sshjRef.newSFTPClient();
try {
sftp.get(remoteRef, localRef);
} finally {
sftp.close();
}
});
}
/**
* Disconnect from the remote host. Idempotent — safe to call multiple times.
*/
public void function close() {
if (structKeyExists(variables, "$sshj") && variables.$sshj.isConnected()) {
variables.$sshj.disconnect();
}
}
// -----------------------------------------------------------------------
// Internals
// -----------------------------------------------------------------------
/**
* Drain an `InputStream` into a UTF-8 string using pure JDK classes.
* Returns empty string if the stream is null (sshj may return null streams
* when a remote process dies before producing output).
*/
public string function $readStream(required any stream) {
if (isNull(arguments.stream)) {
return "";
}
var baos = createObject("java", "java.io.ByteArrayOutputStream").init();
var buffer = createObject("java", "java.lang.reflect.Array")
.newInstance(createObject("java", "java.lang.Byte").TYPE, javaCast("int", 8192));
while (true) {
var n = arguments.stream.read(buffer);
if (n <= 0) {
break;
}
baos.write(buffer, javaCast("int", 0), javaCast("int", n));
}
return baos.toString("UTF-8");
}
}