+ * The credentials can be one of the two: + *
+ * Remember to {@link #close()} the client after a session is established and it's no longer used. You may use + * the try with resource statement block. + *
+ * try (SSHClient connected = notConnected.connect()) {
+ * // do things with the connected instance
+ * }
+ *
+ * + * This method can be called again if the the current session is closed. Otherwise if called on a connected + * instance, a JSchException will be thrown. + * + * @return the current instance so it can be used in try with resource block. + * @throws JSchException if the client is already connected or error occurs during the connection. + */ + public SSHClient connect() throws JSchException { + if (session != null && session.isConnected()) { + throw new JSchException("SSH session is already connected, close previous session first."); + } + session = jsch.getSession(credentials.getUsername(), host, port); + Properties config = new Properties(); + config.put("StrictHostKeyChecking", "no"); + session.setConfig(config); + if (credentials instanceof UsernamePasswordAuth) { + session.setPassword( + ((UsernamePasswordAuth) credentials).getPassword()); + } + session.connect(); + return this; + } + + /** + * Copy local file to the remote path. + * + * @param sourceFile the local file. + * @param remotePath the target remote path, can be either absolute or relative to the user home. + * @throws JSchException if the underlying SSH session fails. + */ + public void copyTo(final File sourceFile, final String remotePath) throws JSchException { + log("copy file {0} to {1}:{2}", sourceFile, host, remotePath); + withChannelSftp(new ChannelSftpConsumer() { + @Override + public void apply(ChannelSftp channel) throws JSchException, SftpException { + channel.put(sourceFile.getAbsolutePath(), remotePath); + } + }); + } + + /** + * Copy the contents from the {@code InputStream} to the remote path. + * + * @param in the {@code InputStream} containing source contents. + * @param remotePath the target remote path, can be either absolute or relative to the user home. + * @throws JSchException if the underlying SSH session fails. + */ + public void copyTo(final InputStream in, final String remotePath) throws JSchException { + try { + withChannelSftp(new ChannelSftpConsumer() { + @Override + public void apply(ChannelSftp channel) throws JSchException, SftpException { + channel.put(in, remotePath); + } + }); + } finally { + try { + in.close(); + } catch (IOException e) { + log("Failed to close input stream: {0}", e.getMessage()); + } + } + } + + /** + * Copy remote file to the local destination. + * + * @param remotePath the remote file path, can be either absolute or relative to the user home. + * @param destFile the local destination file path. + * @throws JSchException if the underlying SSH session fails. + */ + public void copyFrom(final String remotePath, final File destFile) throws JSchException { + log("copy file {0}:{1} to {2}", host, remotePath, destFile); + withChannelSftp(new ChannelSftpConsumer() { + @Override + public void apply(ChannelSftp channel) throws JSchException, SftpException { + channel.get(remotePath, destFile.getAbsolutePath()); + } + }); + } + + /** + * Copy remote file contents to the {@code OutputStream}. + * + * @param remotePath the remote file path, can be either absolute or relative to the user home. + * @param out the {@code OutputStream} where the file contents should be written to. + * @throws JSchException if the underlying SSH session fails. + */ + public void copyFrom(final String remotePath, final OutputStream out) throws JSchException { + withChannelSftp(new ChannelSftpConsumer() { + @Override + public void apply(ChannelSftp channel) throws JSchException, SftpException { + channel.get(remotePath, out); + } + }); + } + + protected void withChannelSftp(ChannelSftpConsumer consumer) throws JSchException { + ChannelSftp channel = null; + try { + channel = (ChannelSftp) session.openChannel("sftp"); + channel.connect(); + try { + consumer.apply(channel); + } catch (SftpException e) { + throw new JSchException("sftp error", e); + } + } finally { + if (channel != null) { + channel.disconnect(); + } + } + } + + /** + * Execute a command on the remote server and return the command standard output. + * + * @param command the command to be executed. + * @return the standard output of the command. + * @throws JSchException if the underlying SSH session fails. + * @throws IOException if it fails to read the output from the remote channel. + */ + public String execRemote(String command) throws JSchException, IOException, ExitStatusException { + return execRemote(command, true, true); + } + + public String execRemote(String command, + boolean showCommand, + boolean capture) throws JSchException, IOException, ExitStatusException { + ChannelExec channel = null; + try { + + channel = (ChannelExec) session.openChannel("exec"); + channel.setCommand(command); + + if (showCommand) { + log("===> exec: {0}", command); + } + ByteArrayOutputStream output = new ByteArrayOutputStream(); + byte[] buffer = new byte[READ_BUFFER_SIZE]; + + if (logger != null) { + channel.setErrStream(logger, true); + if (!capture) { + channel.setOutputStream(logger, true); + } + } + + channel.connect(); + + if (!capture) { + while (!channel.isClosed()) { + try { + final int waitPeriod = 200; + Thread.sleep(waitPeriod); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new JSchException("", e); + } + } + int exitCode = channel.getExitStatus(); + log("<=== command exit status: {0}", exitCode); + if (exitCode != 0) { + throw new ExitStatusException(exitCode, ""); + } + return ""; + } else { + InputStream in = channel.getInputStream(); + while (true) { + do { + // blocks on IO + int len = in.read(buffer, 0, buffer.length); + if (len < 0) { + break; + } + output.write(buffer, 0, len); + } while (in.available() > 0); + + if (channel.isClosed()) { + if (in.available() > 0) { + continue; + } + break; + } + } + int exitCode = channel.getExitStatus(); + log("<=== command exit status: {0}", exitCode); + String serverOutput = output.toString(StandardCharsets.UTF_8.name()); + log("<=== {0}", serverOutput); + if (exitCode != 0) { + throw new ExitStatusException(exitCode, serverOutput); + } + return serverOutput; + } + } catch (UnsupportedEncodingException e) { + throw new IllegalArgumentException("Failed to execute command", e); + } finally { + if (channel != null) { + channel.disconnect(); + } + } + } + + /** + * Forward another remote SSH port to local through the current client, and create a new client based on the local + * port. + *
+ * This method assumes that the SSH server on A and B accepts the same authentication credentials. + * + * @param remoteHost the target host name or IP address, which is accessible from the SSH target of the current + * SSHClient. + * @param remotePort the SSH service port on the target host. + * @return A new SSH client to the target host through the current SSH client. + * @throws JSchException if error occurs during the SSH operations. + */ + public SSHClient forwardSSH(String remoteHost, int remotePort) throws JSchException { + return forwardSSH(remoteHost, remotePort, credentials); + } + + /** + * Forward another remote SSH port to local through the current client, and create a new client based on the local + * port. + *
+ * Consider in the case with 2 or more remote severs, where: + *
+ * We can first establish an SSH connection to host A, and then use the port forwarding to forward the connection + * to the local port through the SSH connection of host A to reach the SSH server on host B. + *
+ * SSHClient connectionToA = new SSHClient(host_A, port_A, credentials_A);
+ * SSHClient tunnelConnectionToB = connectionToA.forwardSSH(host_B, port_B, credentials_B);
+ * tunnelConnectionToB.execRemote("ls"); // ls executed on host B
+ *
+ *
+ * @param remoteHost the target host name or IP address, which is accessible from the SSH target of the current
+ * SSHClient.
+ * @param remotePort the SSH service port on the target host.
+ * @param sshCredentials SSH authentication credentials
+ * @return A new SSH client to the target host through the current SSH client.
+ * @throws JSchException if error occurs during the SSH operations.
+ */
+ public SSHClient forwardSSH(String remoteHost, int remotePort, UsernameAuth sshCredentials) throws JSchException {
+ int localPort = session.setPortForwardingL(0, remoteHost, remotePort);
+ return new SSHClient("127.0.0.1", localPort, sshCredentials).withLogger(logger);
+ }
+
+ public String getHost() {
+ return host;
+ }
+
+ public int getPort() {
+ return port;
+ }
+
+ public String getUsername() {
+ return credentials.getUsername();
+ }
+
+ public UsernameAuth getCredentials() {
+ return credentials;
+ }
+
+ @Override
+ public void close() {
+ if (this.session != null) {
+ this.session.disconnect();
+ this.session = null;
+ }
+ }
+
+ @SuppressFBWarnings
+ private void log(String message) {
+ if (logger != null) {
+ logger.println(message);
+ }
+ }
+
+ private void log(String message, Object... args) {
+ if (logger != null) {
+ logger.println(String.format(message, args));
+ }
+ }
+
+ private interface ChannelSftpConsumer {
+ void apply(ChannelSftp channel) throws JSchException, SftpException;
+ }
+
+ public static class ExitStatusException extends Exception {
+ private final int exitStatus;
+ private final String output;
+
+ public ExitStatusException(int exitStatus, String output) {
+ super(String.format("Command exited with code: %d", exitStatus));
+ this.exitStatus = exitStatus;
+ this.output = output;
+ }
+
+ public int getExitStatus() {
+ return exitStatus;
+ }
+
+ public String getOutput() {
+ return output;
+ }
+ }
+}
diff --git a/src/main/java/com/microsoft/jenkins/containeragents/remote/SSHLauncher.java b/src/main/java/com/microsoft/jenkins/containeragents/remote/SSHLauncher.java
index ffe9adb..52dfbf0 100644
--- a/src/main/java/com/microsoft/jenkins/containeragents/remote/SSHLauncher.java
+++ b/src/main/java/com/microsoft/jenkins/containeragents/remote/SSHLauncher.java
@@ -7,7 +7,6 @@
import com.jcraft.jsch.JSch;
import com.jcraft.jsch.JSchException;
import com.jcraft.jsch.Session;
-import com.microsoft.jenkins.azurecommons.remote.SSHClient;
import com.microsoft.jenkins.containeragents.helper.RetryTask;
import hudson.model.Slave;
import hudson.model.TaskListener;
@@ -106,9 +105,7 @@ public Session call() throws Exception {
new Channel.Listener() {
@Override
public void onClosed(Channel channel, IOException cause) {
- if (channelExec != null) {
- channelExec.disconnect();
- }
+ channelExec.disconnect();
}
});
LOGGER.log(Level.INFO, "SSHLauncher: launched agent successfully");
diff --git a/src/main/java/com/microsoft/jenkins/containeragents/remote/UsernameAuth.java b/src/main/java/com/microsoft/jenkins/containeragents/remote/UsernameAuth.java
new file mode 100644
index 0000000..7c3d67d
--- /dev/null
+++ b/src/main/java/com/microsoft/jenkins/containeragents/remote/UsernameAuth.java
@@ -0,0 +1,40 @@
+/*
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See LICENSE in the project root for license information.
+ */
+
+package com.microsoft.jenkins.containeragents.remote;
+
+import com.cloudbees.jenkins.plugins.sshcredentials.SSHUserPrivateKey;
+import com.cloudbees.plugins.credentials.common.StandardUsernameCredentials;
+import com.cloudbees.plugins.credentials.common.StandardUsernamePasswordCredentials;
+import hudson.util.Secret;
+
+/**
+ * Abstract SSH authentication credentials with username.
+ */
+abstract class UsernameAuth {
+ private final String username;
+
+ UsernameAuth(String username) {
+ this.username = username;
+ }
+
+ String getUsername() {
+ return username;
+ }
+
+ static UsernameAuth fromCredentials(StandardUsernameCredentials credentials) {
+ if (credentials instanceof StandardUsernamePasswordCredentials) {
+ StandardUsernamePasswordCredentials userPass = (StandardUsernamePasswordCredentials) credentials;
+ return new UsernamePasswordAuth(userPass.getUsername(), userPass.getPassword().getPlainText());
+ } else if (credentials instanceof SSHUserPrivateKey) {
+ SSHUserPrivateKey userKey = (SSHUserPrivateKey) credentials;
+ Secret passphraseSecret = userKey.getPassphrase();
+ String passphrase = passphraseSecret == null ? null : passphraseSecret.getPlainText();
+ return new UsernamePrivateKeyAuth(userKey.getUsername(), passphrase, userKey.getPrivateKeys());
+ } else {
+ throw new IllegalArgumentException("Unsupported credentials type " + credentials.getClass().getName());
+ }
+ }
+}
diff --git a/src/main/java/com/microsoft/jenkins/containeragents/remote/UsernamePasswordAuth.java b/src/main/java/com/microsoft/jenkins/containeragents/remote/UsernamePasswordAuth.java
new file mode 100644
index 0000000..eea858e
--- /dev/null
+++ b/src/main/java/com/microsoft/jenkins/containeragents/remote/UsernamePasswordAuth.java
@@ -0,0 +1,22 @@
+/*
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See LICENSE in the project root for license information.
+ */
+
+package com.microsoft.jenkins.containeragents.remote;
+
+/**
+ * SSH authentication credentials with username and password.
+ */
+class UsernamePasswordAuth extends UsernameAuth {
+ private final String password;
+
+ UsernamePasswordAuth(String username, String password) {
+ super(username);
+ this.password = password;
+ }
+
+ String getPassword() {
+ return password;
+ }
+}
diff --git a/src/main/java/com/microsoft/jenkins/containeragents/remote/UsernamePrivateKeyAuth.java b/src/main/java/com/microsoft/jenkins/containeragents/remote/UsernamePrivateKeyAuth.java
new file mode 100644
index 0000000..2b6904e
--- /dev/null
+++ b/src/main/java/com/microsoft/jenkins/containeragents/remote/UsernamePrivateKeyAuth.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See LICENSE in the project root for license information.
+ */
+
+package com.microsoft.jenkins.containeragents.remote;
+
+import com.google.common.collect.ImmutableList;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+
+/**
+ * SSH authentication credentials with username and private keys.
+ */
+class UsernamePrivateKeyAuth extends UsernameAuth {
+ private final String passPhrase;
+ private final ImmutableList