diff --git a/pom.xml b/pom.xml index 5331aaa1..55f9170c 100644 --- a/pom.xml +++ b/pom.xml @@ -98,6 +98,18 @@ + + io.jenkins.plugins.mina-sshd-api + mina-sshd-api-common + + + io.jenkins.plugins.mina-sshd-api + mina-sshd-api-core + + + io.jenkins.plugins.mina-sshd-api + mina-sshd-api-scp + org.jenkins-ci.plugins credentials @@ -106,6 +118,7 @@ org.jenkins-ci.plugins ssh-credentials + org.jenkins-ci.plugins trilead-api diff --git a/src/main/java/io/jenkins/plugins/sshbuildagents/ssh/Connection.java b/src/main/java/io/jenkins/plugins/sshbuildagents/ssh/Connection.java new file mode 100644 index 00000000..d260d133 --- /dev/null +++ b/src/main/java/io/jenkins/plugins/sshbuildagents/ssh/Connection.java @@ -0,0 +1,134 @@ +/* + * Copyright The Original Author or Authors + * SPDX-License-Identifier: MIT + */ +package io.jenkins.plugins.sshbuildagents.ssh; + +import com.cloudbees.plugins.credentials.common.StandardUsernameCredentials; +import java.io.IOException; +import java.io.OutputStream; +import org.apache.sshd.client.session.ClientSession; + +/** + * Interface to manage an SSH connection. + * + */ +public interface Connection extends AutoCloseable { + /** + * Execute a command and return the exit code returned when it finish. + * + * @param command Command to execute. + * @return The exit code of the command (if the command ran). + * @throws IOException in case of an error launching the command. + */ + int execCommand(String command) throws IOException; + + /** + * Create a {@link ShellChannel} to execute non-interactive commands. + * + * @return Return a {@link ShellChannel} + * @throws IOException + */ + ShellChannel shellChannel() throws IOException; + + /** + * @return Return the host configured to connect by SSH. + */ + String getHostname(); + + /** + * @return Return the port configured to connect by SSH. + */ + int getPort(); + + /** + * Copy a file to the host by SCP. It does not create folders, so the folders of the path must + * exist prior to calling this. + * FIXME The remote file should be relative to the working directory. + * FIXME use SHA instead of MD5 to check the content of the file, it is no longer in the JDK. + * @param remoteFile Full path to the remote file. + * @param data Array of bytes with the data to write. + * @param overwrite @{code true} to overwrite the file if it already exists. If @{false} and the file exists an @{code IOException} will be thrown. + * @param checkSameContent if true will calculate and compare the checksum of the remote file and data and if identical will skip writing the file. + * @throws IOException + */ + void copyFile(String remoteFile, byte[] data, boolean overwrite, boolean checkSameContent) throws IOException; + + /** + * Set the TCP_NODELAY flag on connections. + * + * @param tcpNoDelay True to set TCP_NODELAY. + */ + void setTCPNoDelay(boolean tcpNoDelay); + + /** + * Establishes an SSH connection with the configuration set in the class. + * + * @return Return a {@link ClientSession} to interact with the SSH connection. + * @throws IOException + */ + ClientSession connect() throws IOException; + + /** + * Set Server host verifier. + * + * @param verifier The Server host verifier to use. + */ + void setServerHostKeyVerifier(ServerHostKeyVerifier verifier); + + /** + * Set the connection timeout. + * + * @param timeout Timeout in milliseconds. + */ + void setTimeout(long timeout); + + /** + * Set the credential to use to authenticate in the SSH service. + * + * @param credentials Credentials used to authenticate. + */ + void setCredentials(StandardUsernameCredentials credentials); + + /** + * Set the time to wait between retries. + * + * @param time Time to wait in seconds. + */ + void setRetryWaitTime(int time); + + /** + * Set the number of times we will retry the SSH connection. + * + * @param retries Number of retries. + */ + void setRetries(int retries); + + /** + * Set the absolute path to the working directory. + * + * @param path absolute path to the working directory. + */ + void setWorkingDirectory(String path); + + /** + * Set the standard error output. + * + * @param stderr Value of the new standard error output. + */ + void setStdErr(OutputStream stderr); + + /** + * Set the standard output. + * + * @param stdout Value of the new standard output. + */ + void setStdOut(OutputStream stdout); + + /** + * Check if the connection is open. + * + * @return True if the connection is open, false otherwise. + */ + boolean isOpen(); +} diff --git a/src/main/java/io/jenkins/plugins/sshbuildagents/ssh/KeyAlgorithm.java b/src/main/java/io/jenkins/plugins/sshbuildagents/ssh/KeyAlgorithm.java new file mode 100644 index 00000000..df9461be --- /dev/null +++ b/src/main/java/io/jenkins/plugins/sshbuildagents/ssh/KeyAlgorithm.java @@ -0,0 +1,19 @@ +/* + * Copyright The Original Author or Authors + * SPDX-License-Identifier: MIT + */ +package io.jenkins.plugins.sshbuildagents.ssh; + +import java.io.IOException; + +/** + * Class to manage key algorithms for SSH connections. + * + */ +public class KeyAlgorithm { + public String getKeyFormat() { + return ""; + } + + public void decodePublicKey(byte[] keyValue) throws IOException {} +} diff --git a/src/main/java/io/jenkins/plugins/sshbuildagents/ssh/KeyAlgorithmManager.java b/src/main/java/io/jenkins/plugins/sshbuildagents/ssh/KeyAlgorithmManager.java new file mode 100644 index 00000000..7d5e4a03 --- /dev/null +++ b/src/main/java/io/jenkins/plugins/sshbuildagents/ssh/KeyAlgorithmManager.java @@ -0,0 +1,16 @@ +/* + * Copyright The Original Author or Authors + * SPDX-License-Identifier: MIT + */ +package io.jenkins.plugins.sshbuildagents.ssh; + +import java.util.List; + +/** + * Interface to manage supported key algorithms for SSH connections. + * + */ +public interface KeyAlgorithmManager { + + List getSupportedAlgorithms(); +} diff --git a/src/main/java/io/jenkins/plugins/sshbuildagents/ssh/KnownHosts.java b/src/main/java/io/jenkins/plugins/sshbuildagents/ssh/KnownHosts.java new file mode 100644 index 00000000..fe48813a --- /dev/null +++ b/src/main/java/io/jenkins/plugins/sshbuildagents/ssh/KnownHosts.java @@ -0,0 +1,31 @@ +/* + * Copyright The Original Author or Authors + * SPDX-License-Identifier: MIT + */ +package io.jenkins.plugins.sshbuildagents.ssh; + +import java.io.File; + +/** + * Class to manage known hosts for SSH connections. It provides methods to verify host keys and + * manage known hosts files. TODO Implement a proper host key verification mechanism. + * + */ +public class KnownHosts { + public static final int HOSTKEY_IS_OK = 0; + public static final int HOSTKEY_IS_NEW = 1; + + public KnownHosts(File knownHostsFile) {} + + public static String createHexFingerprint(String algorithm, byte[] key) { + return ""; + } + + public int verifyHostkey(String host, String algorithm, byte[] key) { + return 1; + } + + public String[] getPreferredServerHostkeyAlgorithmOrder(String host) { + return new String[0]; + } +} diff --git a/src/main/java/io/jenkins/plugins/sshbuildagents/ssh/ServerHostKeyVerifier.java b/src/main/java/io/jenkins/plugins/sshbuildagents/ssh/ServerHostKeyVerifier.java new file mode 100644 index 00000000..f22ee4fb --- /dev/null +++ b/src/main/java/io/jenkins/plugins/sshbuildagents/ssh/ServerHostKeyVerifier.java @@ -0,0 +1,12 @@ +/* + * Copyright The Original Author or Authors + * SPDX-License-Identifier: MIT + */ +package io.jenkins.plugins.sshbuildagents.ssh; + +/** + * Interface to verify the server host key during SSH connections. TODO Implement a proper host key + * verification mechanism. + * + */ +public interface ServerHostKeyVerifier {} diff --git a/src/main/java/io/jenkins/plugins/sshbuildagents/ssh/ShellChannel.java b/src/main/java/io/jenkins/plugins/sshbuildagents/ssh/ShellChannel.java new file mode 100644 index 00000000..53fe0fe1 --- /dev/null +++ b/src/main/java/io/jenkins/plugins/sshbuildagents/ssh/ShellChannel.java @@ -0,0 +1,44 @@ +/* + * Copyright The Original Author or Authors + * SPDX-License-Identifier: MIT + */ +package io.jenkins.plugins.sshbuildagents.ssh; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +/** + * Interface to manage non-interactive sessions. + * TODO review names for interactive and non-interactive sessions. Mina uses ShellChannel for interactive and ExecChannel for non-interactive. + * + */ +public interface ShellChannel extends AutoCloseable { + /** + * Execute a command in a non-interactive session and return without waiting for the command to complete. + * + * @param cmd + * @throws IOException + */ + void execCommand(String cmd) throws IOException; + + /** + * @return The standard output of the process launched in a InputStream for reading. + */ + InputStream getInvertedStdout(); + + /** + * @return The standard input of the process launched in a OutputStream for writting. + */ + OutputStream getInvertedStdin(); + + /** + * @return the last error in the channel. + */ + Throwable getLastError(); + + /** + * @return the last command received in the SSH channel. + */ + String getLastAttemptedCommand(); +} diff --git a/src/main/java/io/jenkins/plugins/sshbuildagents/ssh/mina/ConnectionImpl.java b/src/main/java/io/jenkins/plugins/sshbuildagents/ssh/mina/ConnectionImpl.java new file mode 100644 index 00000000..198b9405 --- /dev/null +++ b/src/main/java/io/jenkins/plugins/sshbuildagents/ssh/mina/ConnectionImpl.java @@ -0,0 +1,367 @@ +/* + * Copyright The Original Author or Authors + * SPDX-License-Identifier: MIT + */ + +package io.jenkins.plugins.sshbuildagents.ssh.mina; + +import com.cloudbees.jenkins.plugins.sshcredentials.SSHAuthenticator; +import com.cloudbees.plugins.credentials.common.StandardUsernameCredentials; +import hudson.model.TaskListener; +import io.jenkins.plugins.sshbuildagents.ssh.Connection; +import io.jenkins.plugins.sshbuildagents.ssh.ServerHostKeyVerifier; +import io.jenkins.plugins.sshbuildagents.ssh.ShellChannel; +import java.io.IOException; +import java.io.OutputStream; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.attribute.PosixFilePermission; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.logging.Level; +import org.apache.sshd.client.SshClient; +import org.apache.sshd.client.future.ConnectFuture; +import org.apache.sshd.client.session.ClientSession; +import org.apache.sshd.core.CoreModuleProperties; +import org.apache.sshd.scp.client.DefaultScpClient; + +/** + * Implements {@link Connection} using the Apache Mina SSHD library. + * + */ +public class ConnectionImpl implements Connection { + private static final java.util.logging.Logger LOGGER = + java.util.logging.Logger.getLogger(ConnectionImpl.class.getName()); + /** The number of heartbeat packets lost before closing the connection. */ + public static final int HEARTBEAT_MAX_RETRY = 6; + + /** The size of the SSH window size in bytes. */ + public static final long WINDOW_SIZE = 4L * 1024 * 1024; + + /** The time in seconds to wait before sending a keepalive packet. */ + public static final int HEARTBEAT_INTERVAL_SECONDS = 10; + + /** The time in minutes to wait before closing the session if no command is executed. */ + public static final int IDLE_SESSION_TIMEOUT_MINUTES = 60; + + /** The standard output stream of the channel. */ + private OutputStream stdout = System.out; + + /** The standard error stream of the channel. */ + private OutputStream stderr = System.err; + + /** The SSH client used for the connection. */ + private SshClient client; + + /** The server host key verifier. */ + // TODO implement the host key verifier + @SuppressWarnings("unused") + private ServerHostKeyVerifier hostKeyVerifier; + + /** The timeout in milliseconds for the connection and authentication. */ + private long timeoutMillis = 30000; + + /** The credentials used for authentication. */ + private StandardUsernameCredentials credentials; + + /** The host to connect to. */ + private final String host; + + /** The port to connect to. */ + private final int port; + + /** The maximum number of retries for connection attempts. */ + private int maxNumRetries = 1; + + /** The time in seconds to wait between retries. */ + private int retryWaitTime = 10; + + /** The working directory for the SSH session. */ + // TODO implement the working directory + @SuppressWarnings("unused") + private String workingDirectory; + + /** The TCP_NODELAY flag. */ + private boolean tcpNoDelay = true; + + /** The SSH session. */ + private ClientSession session; + + /** + * Constructor to create a new SSH connection. + * + * @param host The hostname or IP address of the SSH server. + * @param port The port number of the SSH server. + */ + public ConnectionImpl(String host, int port) { + this.host = host; + this.port = port; + } + + /** {@inheritDoc} */ + @Override + // FIXME review, it does not return the exit code as it is said in the javadoc + public int execCommand(String command) throws IOException { + try (ClientSession session = connect()) { + session.executeRemoteCommand(command, stdout, stderr, StandardCharsets.UTF_8); + } + return 0; + } + + /** {@inheritDoc} */ + @Override + public ShellChannel shellChannel() throws IOException { + return new ShellChannelImpl(connect()); + } + + /** {@inheritDoc} */ + @Override + public String getHostname() { + return this.host; + } + + /** {@inheritDoc} */ + @Override + public int getPort() { + return this.port; + } + + /** {@inheritDoc} */ + @Override + public void close() { + if (session != null) { + try { + session.close(); + } catch (IOException e) { + LOGGER.log(Level.FINE, "failed to close session", e); + } + } + if (client != null) { + client.stop(); + } + client = null; + session = null; + } + + /** {@inheritDoc} */ + @Override + public void copyFile(String remotePath, byte[] bytes, boolean overwrite, boolean checkSameContent) + throws IOException { + try (ClientSession session = connect()) { + // TODO set access/modification time to be the same as the source + DefaultScpClient scp = new DefaultScpClient(session); + List permissions = new ArrayList<>(); + // TODO document the permissions the file needs and how to set the umask + // TODO verify if the file exists and if the content is the same + // TODO verify if the file is a directory + permissions.add(PosixFilePermission.OWNER_WRITE); + scp.upload(bytes, remotePath, permissions, null); + } + } + + /** {@inheritDoc} */ + @Override + public void setTCPNoDelay(boolean tcpNoDelay) { + this.tcpNoDelay = tcpNoDelay; + } + + /** {@inheritDoc} */ + @Override + public ClientSession connect() throws IOException { + initClient(); + if (isSession() == false) { + for (int i = 0; i <= maxNumRetries; i++) { + try { + return connectAndAuthenticate(); + } catch (Exception ex) { + String message = getExMessage(ex); + if (maxNumRetries - i > 0) { + // FIXME send these logs to the TaskListener of the computer launcher + println( + stderr, + "SSH Connection failed with IOException: \"" + + message + + "\", retrying in " + + retryWaitTime + + " seconds." + + " There are " + + (maxNumRetries - i) + + " more retries left."); + } + } + waitToRetry(); + } + throw new IOException("Max number or reties reached."); + } + return session; + } + + /** + * @return True is the session is authenticated and open. + */ + private boolean isSession() { + return session != null && session.isAuthenticated() && session.isOpen(); + } + + /** + * Connets to the SSH service configured and authenticate + * + * @return Returns a ClientSession connected and authenticated. + * @throws IOException in case of error. + * @throws InterruptedException + */ + private ClientSession connectAndAuthenticate() throws IOException, InterruptedException { + ConnectFuture connectionFuture = client.connect(this.credentials.getUsername(), this.host, this.port); + connectionFuture.verify(this.timeoutMillis); + session = connectionFuture.getSession(); + var authenticator = SSHAuthenticator.newInstance(session, credentials); + // FIXME send these logs to the TaskListener of the computer launcher + authenticator.authenticate(TaskListener.NULL); + return session; + } + + /** Initialize the SSH client. It reuses the client if it exists. */ + private void initClient() { + if (client == null) { + client = SshClient.setUpDefaultClient(); + CoreModuleProperties.WINDOW_SIZE.set(client, WINDOW_SIZE); + CoreModuleProperties.TCP_NODELAY.set(client, tcpNoDelay); + CoreModuleProperties.HEARTBEAT_REQUEST.set(client, "keepalive@jenkins.io"); + CoreModuleProperties.HEARTBEAT_INTERVAL.set(client, Duration.ofSeconds(HEARTBEAT_INTERVAL_SECONDS)); + CoreModuleProperties.HEARTBEAT_NO_REPLY_MAX.set(client, HEARTBEAT_MAX_RETRY); + CoreModuleProperties.IDLE_TIMEOUT.set(client, Duration.ofMinutes(IDLE_SESSION_TIMEOUT_MINUTES)); + // TODO set the host verifier + // client.setServerKeyVerifier(hostKeyVerifier); + } + if (client.isStarted() == false) { + client.start(); + } + } + + /** + * Sleep retryWaitTime seconds. + * + * @throws IOException in case of error. + */ + private void waitToRetry() throws IOException { + try { + Thread.sleep(TimeUnit.SECONDS.toMillis(retryWaitTime)); + } catch (InterruptedException e) { + throw new IOException(e); + } + } + + /** + * Parse an exception to print the cause or the error message in the error output and return the + * message. + * + * @param ex Exception to parse. + * @return + */ + private String getExMessage(Exception ex) { + String message = "unknown error"; + Throwable cause = ex.getCause(); + if (cause != null) { + message = cause.getMessage(); + println(stderr, message); + } else if (ex.getMessage() != null) { + message = ex.getMessage(); + println(stderr, message); + } + return message; + } + + /** + * Prints a message in the output passed as parameter. + * + * @param out Output to use. + * @param message Message to write. + */ + private void println(OutputStream out, String message) { + if (out instanceof PrintStream) { + ((PrintStream) out).println(message); + } else { + try { + out.write((message + "\n").getBytes(StandardCharsets.UTF_8)); + } catch (IOException e) { + // NOOP + } + } + } + + /** {@inheritDoc} */ + @Override + public void setServerHostKeyVerifier(ServerHostKeyVerifier verifier) { + this.hostKeyVerifier = verifier; + } + + /** {@inheritDoc} */ + @Override + public void setTimeout(long timeout) { + this.timeoutMillis = timeout; + } + + /** {@inheritDoc} */ + @Override + public void setCredentials(StandardUsernameCredentials credentials) { + this.credentials = credentials; + } + + /** {@inheritDoc} */ + @Override + public void setRetryWaitTime(int time) { + this.retryWaitTime = time; + } + + /** {@inheritDoc} */ + @Override + public void setRetries(int retries) { + this.maxNumRetries = retries; + } + + /** {@inheritDoc} */ + @Override + public void setWorkingDirectory(String path) { + this.workingDirectory = path; + } + + /** {@inheritDoc} */ + @Override + // FIXME rename for Stderr + public void setStdErr(OutputStream stderr) { + this.stderr = stderr; + } + + /** {@inheritDoc} */ + @Override + // FIXME rename to Stdout + public void setStdOut(OutputStream stdout) { + this.stdout = stdout; + } + + /** {@inheritDoc} */ + @Override + public boolean isOpen() { + return isSession() && client != null && client.isOpen(); + } + + /** + * Returns the host key verifier used for the connection. + * + * @return The host key verifier. + */ + public ServerHostKeyVerifier getHostKeyVerifier() { + return hostKeyVerifier; + } + + /** + * Returns the working directory used for the connection. + * + * @return The working directory. + */ + public String getWorkingDirectory() { + return workingDirectory; + } +} diff --git a/src/main/java/io/jenkins/plugins/sshbuildagents/ssh/mina/KeyAlgorithmManagerImpl.java b/src/main/java/io/jenkins/plugins/sshbuildagents/ssh/mina/KeyAlgorithmManagerImpl.java new file mode 100644 index 00000000..7e6f4f6c --- /dev/null +++ b/src/main/java/io/jenkins/plugins/sshbuildagents/ssh/mina/KeyAlgorithmManagerImpl.java @@ -0,0 +1,22 @@ +/* + * Copyright The Original Author or Authors + * SPDX-License-Identifier: MIT + */ +package io.jenkins.plugins.sshbuildagents.ssh.mina; + +import io.jenkins.plugins.sshbuildagents.ssh.KeyAlgorithm; +import io.jenkins.plugins.sshbuildagents.ssh.KeyAlgorithmManager; +import java.util.Collections; +import java.util.List; + +/** + * Implementation of KeyAlgorithmManager that provides a list of supported key algorithms. TODO + * Implement a proper key algorithm manager that returns supported algorithms. + * + */ +public class KeyAlgorithmManagerImpl implements KeyAlgorithmManager { + @Override + public List getSupportedAlgorithms() { + return Collections.emptyList(); + } +} diff --git a/src/main/java/io/jenkins/plugins/sshbuildagents/ssh/mina/SSHApacheMinaLauncher.java b/src/main/java/io/jenkins/plugins/sshbuildagents/ssh/mina/SSHApacheMinaLauncher.java new file mode 100644 index 00000000..7a229420 --- /dev/null +++ b/src/main/java/io/jenkins/plugins/sshbuildagents/ssh/mina/SSHApacheMinaLauncher.java @@ -0,0 +1,1073 @@ +/* + * Copyright The Original Author or Authors + * SPDX-License-Identifier: MIT + */ +package io.jenkins.plugins.sshbuildagents.ssh.mina; + +import static hudson.Util.fixEmpty; + +import com.cloudbees.jenkins.plugins.sshcredentials.SSHAuthenticator; +import com.cloudbees.plugins.credentials.CredentialsMatchers; +import com.cloudbees.plugins.credentials.CredentialsProvider; +import com.cloudbees.plugins.credentials.common.StandardUsernameCredentials; +import com.cloudbees.plugins.credentials.common.StandardUsernameListBoxModel; +import com.cloudbees.plugins.credentials.domains.HostnamePortRequirement; +import com.cloudbees.plugins.credentials.domains.SchemeRequirement; +import edu.umd.cs.findbugs.annotations.CheckForNull; +import edu.umd.cs.findbugs.annotations.NonNull; +import hudson.AbortException; +import hudson.EnvVars; +import hudson.Extension; +import hudson.Util; +import hudson.model.Computer; +import hudson.model.Descriptor; +import hudson.model.ItemGroup; +import hudson.model.Node; +import hudson.model.Slave; +import hudson.model.TaskListener; +import hudson.plugins.sshslaves.SSHConnector; +import hudson.plugins.sshslaves.verifiers.HostKey; +import hudson.plugins.sshslaves.verifiers.NonVerifyingKeyVerificationStrategy; +import hudson.plugins.sshslaves.verifiers.SshHostKeyVerificationStrategy; +import hudson.security.ACL; +import hudson.security.AccessControlled; +import hudson.slaves.ComputerLauncher; +import hudson.slaves.EnvironmentVariablesNodeProperty; +import hudson.slaves.NodeProperty; +import hudson.slaves.NodePropertyDescriptor; +import hudson.slaves.SlaveComputer; +import hudson.util.DescribableList; +import hudson.util.FormValidation; +import hudson.util.ListBoxModel; +import io.jenkins.plugins.sshbuildagents.Messages; +import io.jenkins.plugins.sshbuildagents.ssh.Connection; +import io.jenkins.plugins.sshbuildagents.ssh.ServerHostKeyVerifier; +import io.jenkins.plugins.sshbuildagents.ssh.ShellChannel; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.logging.Logger; +import jenkins.model.Jenkins; +import jenkins.util.SystemProperties; +import org.apache.commons.lang.StringUtils; +import org.apache.sshd.client.session.ClientSession; +import org.apache.sshd.common.util.io.output.NoCloseOutputStream; +import org.jenkinsci.Symbol; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; +import org.kohsuke.stapler.AncestorInPath; +import org.kohsuke.stapler.DataBoundConstructor; +import org.kohsuke.stapler.DataBoundSetter; +import org.kohsuke.stapler.QueryParameter; +import org.kohsuke.stapler.interceptor.RequirePOST; + +/** + * A computer launcher that tries to start a linux agent by opening an SSH connection and trying to + * find java. + * + */ +public class SSHApacheMinaLauncher extends ComputerLauncher { + /** The scheme requirement. */ + public static final SchemeRequirement SSH_SCHEME = new SchemeRequirement("ssh"); + + /** Default maximum number of retries for SSH connections. */ + public static final Integer DEFAULT_MAX_NUM_RETRIES = 10; + + /** Default wait time between retries in seconds. */ + public static final Integer DEFAULT_RETRY_WAIT_TIME_SECONDS = 15; + + /** Default launch timeout in seconds. */ + public static final Integer DEFAULT_LAUNCH_TIMEOUT_SECONDS = 60; + + /** Default remoting jar file name. */ + public static final String AGENT_JAR = "remoting.jar"; + + /** Default remoting jar file name with leading slash. */ + public static final String SLASH_AGENT_JAR = "/" + AGENT_JAR; + + /** Working directory parameter for remoting. */ + public static final String WORK_DIR_PARAM = " -workDir "; + + /** JAR cache parameter for remoting. */ + public static final String JAR_CACHE_PARAM = " -jar-cache "; + + /** JAR cache directory for remoting. */ + public static final String JAR_CACHE_DIR = "/remoting/jarCache"; + + /** Default SSH port. */ + public static final int DEFAULT_SSH_PORT = 22; + + private static final Logger LOGGER = Logger.getLogger(SSHApacheMinaLauncher.class.getName()); + + /** Field javaPath. */ + private String javaPath; + + /** Field prefixStartAgentCmd. */ + private String prefixStartAgentCmd; + + /** Field suffixStartAgentCmd. */ + private String suffixStartAgentCmd; + + /** Field launchTimeoutSeconds. */ + private int launchTimeoutSeconds; + + /** Field maxNumRetries. */ + private int maxNumRetries; + + /** Field retryWaitTime (seconds). */ + private int retryWaitTime; + + /** Field host */ + private String host; + + /** Field port */ + private int port; + + /** The id of the credentials to use. */ + private final String credentialsId; + + /** Transient stash of the credentials to use, mostly just for providing floating user object. */ + private transient StandardUsernameCredentials credentials; + + /** Field jvmOptions. */ + private String jvmOptions; + + /** SSH connection to the agent. */ + private transient volatile Connection connection; + + /** + * The verifier to use for checking the SSH key presented by the host responding to the connection + */ + @CheckForNull + private SshHostKeyVerificationStrategy sshHostKeyVerificationStrategy; + + /** Allow to enable/disable the TCP_NODELAY flag on the SSH connection. */ + private Boolean tcpNoDelay; + + /** + * Set the value to add to the remoting parameter -workDir + * + * @see Remoting + * Work directory + */ + private String workDir; + + /** Shell channel to execute the remoting process. */ + @CheckForNull + private transient ShellChannel shellChannel; + + /** + * Constructor SSHLauncher creates a new SSHLauncher instance. + * + * @param host The host to connect to. + * @param port The port to connect on. + * @param credentialsId The credentials id to connect as. + */ + @DataBoundConstructor + public SSHApacheMinaLauncher(@NonNull String host, int port, String credentialsId) { + setHost(host); + setPort(port); + this.credentialsId = credentialsId; + + this.launchTimeoutSeconds = DEFAULT_LAUNCH_TIMEOUT_SECONDS; + this.maxNumRetries = DEFAULT_MAX_NUM_RETRIES; + this.retryWaitTime = DEFAULT_RETRY_WAIT_TIME_SECONDS; + } + + /** + * Looks up the system credentials by id. + * + * @param credentialsId The credentials id to look up. + * @return The credentials or null if not found. + */ + public static StandardUsernameCredentials lookupSystemCredentials(String credentialsId) { + return CredentialsMatchers.firstOrNull( + CredentialsProvider.lookupCredentialsInItemGroup( + StandardUsernameCredentials.class, Jenkins.get(), ACL.SYSTEM2, List.of(SSH_SCHEME)), + CredentialsMatchers.withId(credentialsId)); + } + + /** + * Gets the formatted current time stamp. + * + * @return the formatted current time stamp. + */ + @Restricted(NoExternalUse.class) + public static String getTimestamp() { + return String.format("[%1$tD %1$tT]", new Date()); + } + + /** + * Returns the remote root workspace (without trailing slash). + * + * @param computer The computer to get the root workspace of. + * @return the remote root workspace (without trailing slash). + */ + @CheckForNull + public static String getWorkingDirectory(SlaveComputer computer) { + return getWorkingDirectory(computer.getNode()); + } + + /** + * Returns the remote root workspace (without trailing slash). + * + * @param agent The agent to get the root workspace of. + * @return the remote root workspace (without trailing slash). + */ + @CheckForNull + private static String getWorkingDirectory(@CheckForNull Slave agent) { + if (agent == null) { + return null; + } + String workingDirectory = agent.getRemoteFS(); + while (workingDirectory.endsWith("/")) { + workingDirectory = workingDirectory.substring(0, workingDirectory.length() - 1); + } + return workingDirectory; + } + + /** + * Gets the credentials used to connect to the agent. + * + * @return The credentials used to connect to the agent. + */ + public StandardUsernameCredentials getCredentials() { + String credentialsId = this.credentialsId == null + ? (this.credentials == null ? null : this.credentials.getId()) + : this.credentialsId; + try { + // only ever want from the system + // lookup every time so that we always have the latest + StandardUsernameCredentials credentials = + credentialsId != null ? SSHApacheMinaLauncher.lookupSystemCredentials(credentialsId) : null; + if (credentials != null) { + this.credentials = credentials; + return credentials; + } + } catch (Throwable t) { + // ignore + } + + return this.credentials; + } + + /** {@inheritDoc} */ + @Override + public boolean isLaunchSupported() { + return connection == null; + } + + /** + * Gets the optional JVM Options used to launch the agent JVM. + * + * @return The optional JVM Options used to launch the agent JVM. + */ + public String getJvmOptions() { + return jvmOptions == null ? "" : jvmOptions; + } + + /** Sets the optional JVM Options used to launch the agent JVM. */ + @DataBoundSetter + public void setJvmOptions(String value) { + this.jvmOptions = fixEmpty(value); + } + + /** + * Gets the optional java command to use to launch the agent JVM. + * + * @return The optional java command to use to launch the agent JVM. + */ + public String getJavaPath() { + return javaPath == null ? "" : javaPath; + } + + @DataBoundSetter + public void setJavaPath(String value) { + this.javaPath = fixEmpty(value); + } + + /** {@inheritDoc} */ + @Override + public void launch(@NonNull final SlaveComputer computer, @NonNull final TaskListener listener) + throws InterruptedException { + final Node node = computer.getNode(); + final String host = this.host; + final int port = this.port; + if (computer == null || listener == null) { + throw new IllegalArgumentException(Messages.SSHLauncher_ComputerAndListenerMustNotBeNull()); + } + checkConfig(); + + synchronized (this) { + if (connection != null) { + listener.getLogger().println(Messages.SSHLauncher_alreadyConnected()); + return; + } + connection = new ConnectionImpl(host, port); + + final String workingDirectory = getWorkingDirectory(computer); + if (workingDirectory == null || workingDirectory.isEmpty()) { + listener.getLogger().println(Messages.SSHLauncher_WorkingDirectoryNotSet()); + throw new IllegalArgumentException(Messages.SSHLauncher_WorkingDirectoryNotSet()); + } + listener.getLogger().println(logConfiguration()); + try { + openConnection(listener, computer, workingDirectory); + copyAgentJar(listener, workingDirectory); + verifyNoHeaderJunk(listener); + reportEnvironment(listener); + startAgent(computer, listener, workingDirectory); + } catch (Error | Exception e) { + String msg = Messages.SSHLauncher_UnexpectedError(); + if (StringUtils.isNotBlank(e.getMessage())) { + msg = e.getMessage(); + } + e.printStackTrace(listener.error(msg)); + close(); + } + } + if (node != null && getTrackCredentials()) { + CredentialsProvider.track(node, getCredentials()); + } + } + + /** + * Expands the given expression using the environment variables. + * + * @param computer The computer to get the environment variables from. + * @param expression The expression to expand. + * @return The expanded expression. + */ + private String expandExpression(SlaveComputer computer, String expression) { + return getEnvVars(computer).expand(expression); + } + + /** + * Gets the environment variables for the given computer. + * + * @param computer The computer to get the environment variables from. + * @return The environment variables for the computer. + */ + private EnvVars getEnvVars(SlaveComputer computer) { + final EnvVars envVars = new EnvVars(); + envVars.overrideAll(getEnvVars(Jenkins.get())); + envVars.overrideAll(getEnvVars(computer.getNode())); + return envVars; + } + + /** + * Gets the environment variables for the given Jenkins instance. + * + * @param h The Jenkins instance to get the environment variables from. + */ + private EnvVars getEnvVars(Jenkins h) { + return getEnvVars(h.getGlobalNodeProperties()); + } + + /** + * Gets the environment variables for the given node. + * + * @param n The node to get the environment variables from. + * @return The environment variables for the node. + */ + private EnvVars getEnvVars(Node n) { + return n == null ? new EnvVars() : getEnvVars(n.getNodeProperties()); + } + + /** + * Gets the environment variables for the given list of node properties. + * + * @param dl The list of node properties to get the environment variables from. + * @return The environment variables for the node properties. + */ + private EnvVars getEnvVars(DescribableList, NodePropertyDescriptor> dl) { + final EnvironmentVariablesNodeProperty evnp = dl.get(EnvironmentVariablesNodeProperty.class); + if (evnp == null) { + return new EnvVars(); + } + return evnp.getEnvVars(); + } + + /** + * Makes sure that SSH connection won't produce any unwanted text, which will interfere with scp + * execution. TODO review if it is needed or move to the SSH Provider. + */ + private void verifyNoHeaderJunk(TaskListener listener) throws IOException, InterruptedException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + connection.execCommand("exit 0"); + final String s; + // TODO: Seems we need to retrieve the encoding from the connection destination + s = baos.toString(StandardCharsets.UTF_8.name()); + if (s.length() != 0) { + listener.getLogger().println(Messages.SSHLauncher_SSHHeaderJunkDetected()); + listener.getLogger().println(s); + throw new AbortException(); + } + } + + /** + * Starts the agent process. + * + * @param computer The computer. + * @param listener The listener. + * @param workingDirectory The working directory from which to start the java process. + * @throws IOException If something goes wrong. + */ + private void startAgent(SlaveComputer computer, final TaskListener listener, String workingDirectory) + throws IOException { + String java = "java"; + if (StringUtils.isNotBlank(javaPath)) { + java = expandExpression(computer, javaPath); + } + + String cmd = "cd \"" + + workingDirectory + + "\" && " + + java + + " " + + getJvmOptions() + + " -jar " + + AGENT_JAR + + getWorkDirParam(workingDirectory); + + // This will wrap the cmd with prefix commands and suffix commands if they are + // set. + cmd = getPrefixStartAgentCmd() + cmd + getSuffixStartAgentCmd(); + + listener.getLogger().println(Messages.SSHLauncher_StartingAgentProcess(getTimestamp(), cmd)); + shellChannel = connection.shellChannel(); + shellChannel.execCommand(cmd); + try { + computer.setChannel( + shellChannel.getInvertedStdout(), shellChannel.getInvertedStdin(), listener.getLogger(), null); + } catch (InterruptedException e) { + throw new IOException(Messages.SSHLauncher_AbortedDuringConnectionOpen(), e); + } catch (IOException e) { + throw new AbortException(e.getMessage()); + } + } + + /** + * Method copies the agent jar to the remote system. + * + * @param listener The listener. + * @param workingDirectory The directory into which the agent jar will be copied. + * @throws IOException If something goes wrong. + */ + private void copyAgentJar(TaskListener listener, String workingDirectory) throws IOException { + String fileName = workingDirectory + SLASH_AGENT_JAR; + boolean overwrite = true; + boolean checkSameContent = true; + byte[] bytes = new Slave.JnlpJar(AGENT_JAR).readFully(); + try { + listener.getLogger().println("Uploading " + fileName + " file to the agent."); + connection.copyFile(fileName, bytes, overwrite, checkSameContent); + } catch (Exception e) { + listener.getLogger().println("Error: unable to write the " + fileName + " file to the agent."); + listener.getLogger().println("Check the user, work directory, and permissions you have configured."); + throw new IOException(e); + } + } + + /** + * Reports the environment variables for the remote user on the Agent logs. + * + * @param listener The listener to report the environment variables to. + * @throws IOException If an error occurs while reporting the environment variables. + */ + protected void reportEnvironment(TaskListener listener) throws IOException { + listener.getLogger().println(Messages._SSHLauncher_RemoteUserEnvironment(getTimestamp())); + connection.execCommand("set"); + } + + /** + * Opens a connection to the remote host. + * + * @param listener The listener to report progress to. + * @param computer The computer to connect to. + * @param workingDirectory The working directory on the remote host. + * @throws IOException If an error occurs while opening the connection. + */ + protected void openConnection( + final TaskListener listener, final SlaveComputer computer, final String workingDirectory) + throws IOException { + if (StringUtils.isBlank(workingDirectory)) { + String msg = "the 'working directory' has not been configured for '" + computer + "' and is required"; + listener.error(msg); + throw new AbortException(msg); + } + StandardUsernameCredentials credentials = getCredentials(); + if (credentials == null) { + throw new AbortException("Cannot find SSH User credentials with id: " + credentialsId); + } + // TODO implement verifiers. + PrintStream logger = listener.getLogger(); + logger.println(Messages.SSHLauncher_OpeningSSHConnection(getTimestamp(), host + ":" + port)); + connection.setTCPNoDelay(getTcpNoDelay()); + connection.setServerHostKeyVerifier(new ServerHostKeyVerifierImpl(computer, listener)); + connection.setTimeout((int) getLaunchTimeoutMillis()); + connection.setCredentials(credentials); + connection.setRetries(getMaxNumRetries()); + connection.setRetryWaitTime(getRetryWaitTime()); + connection.setWorkingDirectory(workingDirectory); + connection.setStdErr(new NoCloseOutputStream(listener.getLogger())); + connection.setStdOut(new NoCloseOutputStream(listener.getLogger())); + connection.connect(); + } + + /** + * Validates the Agent configuration. + * + * @throws InterruptedException + */ + private void checkConfig() throws InterruptedException { + // JENKINS-58340 some plugins does not implement Descriptor + DescriptorImpl descriptor = (DescriptorImpl) Jenkins.get().getDescriptor(SSHApacheMinaLauncher.class); + String message = "Validate configuration:\n"; + boolean isValid = true; + if (descriptor == null) { + throw new InterruptedException("Descriptor for SSHApacheMinaLauncher is not available."); + } + + String port = String.valueOf(this.port); + FormValidation validatePort = descriptor.doCheckPort(port); + FormValidation validateHost = descriptor.doCheckHost(this.host); + FormValidation validateCredentials = + descriptor.doCheckCredentialsId(Jenkins.get(), Jenkins.get(), this.host, port, this.credentialsId); + + if (validatePort.kind == FormValidation.Kind.ERROR) { + isValid = false; + message += validatePort.getMessage() + "\n"; + } + if (validateHost.kind == FormValidation.Kind.ERROR) { + isValid = false; + message += validateHost.getMessage() + "\n"; + } + if (validateCredentials.kind == FormValidation.Kind.ERROR) { + isValid = false; + message += validateCredentials.getMessage() + "\n"; + } + + if (!isValid) { + throw new InterruptedException(message); + } + } + + /** {@inheritDoc} */ + @Override + public void afterDisconnect(SlaveComputer slaveComputer, final TaskListener listener) { + if (connection == null) { + // Nothing to do here, the connection is not established + return; + } else { + try { + if (shellChannel != null) { + if (shellChannel.getLastError() != null) { + listener.getLogger() + .println("\tException: " + + shellChannel.getLastError().getMessage()); + } + if (StringUtils.isNotBlank(shellChannel.getLastAttemptedCommand())) { + listener.getLogger().println("\tHint: " + shellChannel.getLastAttemptedCommand()); + } + } + close(); + } catch (Exception e) { + listener.getLogger().println("Error after disconnect agent: " + e.getMessage()); + } + } + } + + /** Closes the SSH connection and any associated channels. */ + private void close() { + try { + if (shellChannel != null) { + shellChannel.close(); + } + if (connection != null) { + connection.close(); + } + } catch (Exception e) { + // NOOP + LOGGER.fine("Error closing connection: " + e.getMessage()); + } + connection = null; + shellChannel = null; + } + + /** + * Gets the credentials id used to connect to the agent. + * + * @return + */ + public String getCredentialsId() { + return credentialsId; + } + + /** Getter for property 'sshHostKeyVerificationStrategy'. */ + @CheckForNull + public SshHostKeyVerificationStrategy getSshHostKeyVerificationStrategy() { + return sshHostKeyVerificationStrategy; + } + + /** + * Setter for property 'sshHostKeyVerificationStrategy'. + * + * @param value The SSH host key verification strategy to set. + */ + @DataBoundSetter + public void setSshHostKeyVerificationStrategy(SshHostKeyVerificationStrategy value) { + this.sshHostKeyVerificationStrategy = value; + } + + /** + * Gets the SSH host key verification strategy, defaulting to a non-verifying strategy if none is + * set. + * + * @return The SSH host key verification strategy. + */ + @NonNull + SshHostKeyVerificationStrategy getSshHostKeyVerificationStrategyDefaulted() { + return sshHostKeyVerificationStrategy != null + ? sshHostKeyVerificationStrategy + : new NonVerifyingKeyVerificationStrategy(); + } + + /** + * Getter for property 'host'. + * + * @return Value for property 'host'. + */ + public String getHost() { + return host; + } + + public void setHost(String value) { + this.host = Util.fixEmptyAndTrim(value); + } + + /** + * Getter for property 'port'. + * + * @return Value for property 'port'. + */ + public int getPort() { + return port; + } + + /** + * Sets the port to connect to. + * + * @param value The port to connect to. + */ + public void setPort(int value) { + this.port = value == 0 ? DEFAULT_SSH_PORT : value; + } + + /** + * Gets the SSH connection. + * + * @return The SSH connection. + */ + public Connection getConnection() { + return connection; + } + + /** + * Gets the prefix command to run before starting the agent. + * + * @return The prefix command to run before starting the agent. + */ + @NonNull + public String getPrefixStartAgentCmd() { + return Util.fixNull(prefixStartAgentCmd); + } + + /** + * Sets the prefix command to run before starting the agent. + * + * @param value The prefix command to run before starting the agent. + */ + @DataBoundSetter + public void setPrefixStartAgentCmd(String value) { + this.prefixStartAgentCmd = fixEmpty(value); + } + + /** + * Gets the suffix command to run after starting the agent. + * + * @return The suffix command to run after starting the agent. + */ + @NonNull + public String getSuffixStartAgentCmd() { + return Util.fixNull(suffixStartAgentCmd); + } + + /** + * Sets the suffix command to run after starting the agent. + * + * @param value The suffix command to run after starting the agent. + */ + @DataBoundSetter + public void setSuffixStartAgentCmd(String value) { + this.suffixStartAgentCmd = fixEmpty(value); + } + + /** + * Getter for property 'launchTimeoutSeconds' + * + * @return launchTimeoutSeconds + */ + public int getLaunchTimeoutSeconds() { + return launchTimeoutSeconds; + } + + /** + * Sets the launch timeout in seconds. + * + * @param value The launch timeout in seconds. + */ + @DataBoundSetter + public void setLaunchTimeoutSeconds(Integer value) { + this.launchTimeoutSeconds = value != null && value > 0 ? value : DEFAULT_LAUNCH_TIMEOUT_SECONDS; + } + + /** + * Gets the launch timeout in milliseconds. + * + * @return The launch timeout in milliseconds. + */ + private long getLaunchTimeoutMillis() { + return launchTimeoutSeconds < 1 + ? DEFAULT_LAUNCH_TIMEOUT_SECONDS + : TimeUnit.SECONDS.toMillis(launchTimeoutSeconds); + } + + /** + * Getter for property 'maxNumRetries' + * + * @return maxNumRetries + */ + public int getMaxNumRetries() { + return maxNumRetries < 1 ? DEFAULT_MAX_NUM_RETRIES : maxNumRetries; + } + + /** + * Sets the maximum number of retries. + * + * @param value The maximum number of retries. + */ + @DataBoundSetter + public void setMaxNumRetries(Integer value) { + this.maxNumRetries = value != null && value >= 0 ? value : DEFAULT_MAX_NUM_RETRIES; + } + + /** + * Getter for property 'retryWaitTime' + * + * @return retryWaitTime + */ + public int getRetryWaitTime() { + return retryWaitTime < 1 ? DEFAULT_RETRY_WAIT_TIME_SECONDS : retryWaitTime; + } + + /** + * Sets the time to wait between retries in seconds. + * + * @param value The time to wait between retries in seconds. + */ + @DataBoundSetter + public void setRetryWaitTime(Integer value) { + this.retryWaitTime = value != null && value >= 0 ? value : DEFAULT_RETRY_WAIT_TIME_SECONDS; + } + + /** + * Gets the TCP_NODELAY flag for the SSH connection. + * + * @return true if TCP_NODELAY is enabled, false otherwise. + */ + public boolean getTcpNoDelay() { + return tcpNoDelay != null ? tcpNoDelay : true; + } + + /** + * Sets the TCP_NODELAY flag for the SSH connection. + * + * @param tcpNoDelay true to enable TCP_NODELAY, false to disable it. + */ + @DataBoundSetter + public void setTcpNoDelay(boolean tcpNoDelay) { + this.tcpNoDelay = tcpNoDelay; + } + + /** + * Enable/Disable the credential tracking, this tracking store information about where it is used + * a credential, in this case in a node. If the tracking is enabled and you launch a big number of + * Agents per day, activate credentials tacking could cause a performance issue see + * + * @see JENKINS-49235 + */ + public boolean getTrackCredentials() { + String trackCredentials = + SystemProperties.getString(SSHApacheMinaLauncher.class.getName() + ".trackCredentials", "true"); + return !"false".equalsIgnoreCase(trackCredentials); + } + + /** + * Sets the working directory for the remoting process. + * + * @return The working directory for the remoting process. + */ + public String getWorkDir() { + return workDir; + } + + /** + * Sets the working directory for the remoting process. + * + * @param workDir The working directory for the remoting process. + */ + @DataBoundSetter + public void setWorkDir(String workDir) { + this.workDir = Util.fixEmptyAndTrim(workDir); + } + + /** + * @param workingDirectory The Working directory set on the configuration of the node. + * @return the remoting parameter to set the workDir, by default it is the same as the working + * directory configured on the node so "-workDir " + workingDirectory, if workDir is set, he + * method will return "-workDir " + getWorkDir() if the parameter is set in + * suffixStartAgentCmd, the method will return an empty String. + */ + @NonNull + @Restricted(NoExternalUse.class) + public String getWorkDirParam(@NonNull String workingDirectory) { + String ret; + if (getSuffixStartAgentCmd().contains(WORK_DIR_PARAM) + || getSuffixStartAgentCmd().contains(JAR_CACHE_PARAM)) { + // the parameter is already set on suffixStartAgentCmd + ret = ""; + } else if (StringUtils.isNotBlank(getWorkDir())) { + ret = WORK_DIR_PARAM + getWorkDir() + JAR_CACHE_PARAM + getWorkDir() + JAR_CACHE_DIR; + } else { + ret = WORK_DIR_PARAM + workingDirectory + JAR_CACHE_PARAM + workingDirectory + JAR_CACHE_DIR; + } + return ret; + } + + /** + * Returns a string representation of the configuration for logging purposes. + * + * @return A string representation of the configuration. + */ + public String logConfiguration() { + final StringBuilder sb = new StringBuilder(this.getClass().getName() + "{"); + sb.append("host='").append(getHost()).append('\''); + sb.append(", port=").append(getPort()); + sb.append(", credentialsId='").append(Util.fixNull(credentialsId)).append('\''); + sb.append(", jvmOptions='").append(getJvmOptions()).append('\''); + sb.append(", javaPath='").append(Util.fixNull(javaPath)).append('\''); + sb.append(", prefixStartAgentCmd='").append(getPrefixStartAgentCmd()).append('\''); + sb.append(", suffixStartAgentCmd='").append(getSuffixStartAgentCmd()).append('\''); + sb.append(", launchTimeoutSeconds=").append(getLaunchTimeoutSeconds()); + sb.append(", maxNumRetries=").append(getMaxNumRetries()); + sb.append(", retryWaitTime=").append(getRetryWaitTime()); + sb.append(", sshHostKeyVerificationStrategy=") + .append( + sshHostKeyVerificationStrategy != null + ? sshHostKeyVerificationStrategy.getClass().getName() + : "None"); + sb.append(", tcpNoDelay=").append(getTcpNoDelay()); + sb.append(", trackCredentials=").append(getTrackCredentials()); + sb.append('}'); + return sb.toString(); + } + + @Extension + @Symbol({"sshMina"}) + public static class DescriptorImpl extends Descriptor { + + /** {@inheritDoc} */ + public String getDisplayName() { + return Messages.SSHApacheMinaLauncher_DescriptorDisplayName(); + } + + public Class getSshConnectorClass() { + return SSHConnector.class; + } + + /** Delegates the help link to the {@link SSHConnector}. */ + @Override + public String getHelpFile(String fieldName) { + String n = super.getHelpFile(fieldName); + if (n == null) + n = Jenkins.get().getDescriptorOrDie(SSHConnector.class).getHelpFile(fieldName); + return n; + } + + /** + * Return the list of credentials ids that can be used to connect to the remote host. + * + * @param context The context in which the credentials are being requested. + * @param host The host to connect to. + * @param port The port to connect on. + * @param credentialsId The current credentials id, if any. + * @return A list of credentials ids that can be used to connect to the remote host. + */ + @RequirePOST + public ListBoxModel doFillCredentialsIdItems( + @AncestorInPath AccessControlled context, + @QueryParameter String host, + @QueryParameter String port, + @QueryParameter String credentialsId) { + Jenkins jenkins = Jenkins.get(); + if ((context == jenkins && !jenkins.hasPermission(Computer.CREATE)) + || (context != jenkins && !context.hasPermission(Computer.CONFIGURE))) { + return new StandardUsernameListBoxModel().includeCurrentValue(credentialsId); + } + try { + int portValue = Integer.parseInt(port); + // TODO review if the HostnamePortRequirement is really needed + return new StandardUsernameListBoxModel() + .includeMatchingAs( + ACL.SYSTEM2, + jenkins, + StandardUsernameCredentials.class, + // Collections.singletonList(SSH_SCHEME), + Collections.singletonList(new HostnamePortRequirement(host, portValue)), + SSHAuthenticator.matcher(ClientSession.class)) + .includeCurrentValue( + credentialsId); // always add the current value last in case already present + } catch (NumberFormatException ex) { + return new StandardUsernameListBoxModel().includeCurrentValue(credentialsId); + } + } + + /** + * Checks if the given credentials id is valid for the given host and port. + * + * @param context The context in which the credentials are being checked. + * @param host The host to connect to. + * @param port The port to connect on. + * @param value The credentials id to check. + * @return A FormValidation indicating whether the credentials id is valid or not. + */ + @RequirePOST + public FormValidation doCheckCredentialsId( + @AncestorInPath ItemGroup context, + @AncestorInPath AccessControlled _context, + @QueryParameter String host, + @QueryParameter String port, + @QueryParameter String value) { + Jenkins jenkins = Jenkins.get(); + if ((_context == jenkins && !jenkins.hasPermission(Computer.CREATE)) + || (_context != jenkins && !_context.hasPermission(Computer.CONFIGURE))) { + return FormValidation.ok(); // no need to alarm a user that cannot configure + } + try { + // TODO review if the HostnamePortRequirement is really needed + int portValue = Integer.parseInt(port); + for (ListBoxModel.Option o : CredentialsProvider.listCredentialsInItemGroup( + StandardUsernameCredentials.class, + context, + ACL.SYSTEM2, + // Collections.singletonList(SSH_SCHEME), + Collections.singletonList(new HostnamePortRequirement(host, portValue)), + SSHAuthenticator.matcher(ClientSession.class))) { + if (StringUtils.equals(value, o.value)) { + return FormValidation.ok(); + } + } + } catch (NumberFormatException e) { + return FormValidation.warning(e, Messages.SSHLauncher_PortNotANumber()); + } + return FormValidation.error(Messages.SSHLauncher_SelectedCredentialsMissing()); + } + + /** + * Checks if the given port is valid. + * + * @param value The port to check. + * @return A FormValidation indicating whether the port is valid or not. + */ + @RequirePOST + public FormValidation doCheckPort(@QueryParameter String value) { + if (StringUtils.isEmpty(value)) { + return FormValidation.error(Messages.SSHLauncher_PortNotSpecified()); + } + try { + int portValue = Integer.parseInt(value); + if (portValue <= 0) { + return FormValidation.error(Messages.SSHLauncher_PortLessThanZero()); + } + if (portValue >= 65536) { + return FormValidation.error(Messages.SSHLauncher_PortMoreThan65535()); + } + return FormValidation.ok(); + } catch (NumberFormatException e) { + return FormValidation.error(e, Messages.SSHLauncher_PortNotANumber()); + } + } + + /** + * Checks if the given host is valid. + * + * @param value The host to check. + * @return A FormValidation indicating whether the host is valid or not. + */ + @RequirePOST + public FormValidation doCheckHost(@QueryParameter String value) { + FormValidation ret = FormValidation.ok(); + if (StringUtils.isEmpty(value)) { + return FormValidation.error(Messages.SSHLauncher_HostNotSpecified()); + } + return ret; + } + + /** + * Checks if the given java path is valid. + * TODO think about to improve the way we process the Java path + * @param value The java path to check. + * @return A FormValidation indicating whether the java path is valid or not. + */ + @RequirePOST + public FormValidation doCheckJavaPath(@QueryParameter String value) { + FormValidation ret = FormValidation.ok(); + if (value != null + && value.contains(" ") + && !(value.startsWith("\"") && value.endsWith("\"")) + && !(value.startsWith("'") && value.endsWith("'"))) { + return FormValidation.warning(Messages.SSHLauncher_JavaPathHasWhiteSpaces()); + } + return ret; + } + + // TODO add a connection verifier + } + + // TODO refactor and extract. + private class ServerHostKeyVerifierImpl implements ServerHostKeyVerifier { + + private final SlaveComputer computer; + private final TaskListener listener; + + public ServerHostKeyVerifierImpl(final SlaveComputer computer, final TaskListener listener) { + this.computer = computer; + this.listener = listener; + } + + public boolean verifyServerHostKey( + String hostname, int port, String serverHostKeyAlgorithm, byte[] serverHostKey) throws Exception { + + final HostKey key = new HostKey(serverHostKeyAlgorithm, serverHostKey); + + return getSshHostKeyVerificationStrategyDefaulted().verify(computer, key, listener); + } + } +} diff --git a/src/main/java/io/jenkins/plugins/sshbuildagents/ssh/mina/ShellChannelImpl.java b/src/main/java/io/jenkins/plugins/sshbuildagents/ssh/mina/ShellChannelImpl.java new file mode 100644 index 00000000..9c12fdc1 --- /dev/null +++ b/src/main/java/io/jenkins/plugins/sshbuildagents/ssh/mina/ShellChannelImpl.java @@ -0,0 +1,125 @@ +/* + * Copyright The Original Author or Authors + * SPDX-License-Identifier: MIT + */ +package io.jenkins.plugins.sshbuildagents.ssh.mina; + +import io.jenkins.plugins.sshbuildagents.ssh.ShellChannel; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.PipedInputStream; +import java.io.PipedOutputStream; +import java.util.Collections; +import java.util.concurrent.TimeUnit; +import org.apache.sshd.client.channel.ChannelSession; +import org.apache.sshd.client.channel.ClientChannelEvent; +import org.apache.sshd.client.session.ClientSession; + +/** + * Implements {@link ShellChannel} using the Apache Mina SSHD library + * https://github.com/apache/mina-sshd + * + */ +// TODO think to rename to ShellRunner or something like that, there are classes in Mina with a similar name +// FIXME this class must be thread-safe, as it can be accessed from multiple threads +public class ShellChannelImpl implements ShellChannel { + + /** Time between session heartbeat probes. */ + public static final int OPERATION_TIMEOUT_MILLISECONDS = 30000; + + /** SSH Client session. */ + private final ClientSession session; + + /** Shell channel to execute the process. */ + private ChannelSession channel; + + // FIXME use the getInverted methods + // https://javadoc.io/static/org.apache.sshd/sshd-core/2.15.0/org/apache/sshd/client/channel/ClientChannel.html#getInvertedErr() + // https://javadoc.io/static/org.apache.sshd/sshd-core/2.15.0/org/apache/sshd/client/channel/ClientChannel.html#getInvertedIn() + // https://javadoc.io/static/org.apache.sshd/sshd-core/2.15.0/org/apache/sshd/client/channel/ClientChannel.html#getInvertedOut() + /** Standard output of the channel. the process output is write in it. */ + private OutputStream out = new PipedOutputStream(); + + /** Output stream to allow writing in the standard input of the process from outside the class. */ + private OutputStream invertedIn = new PipedOutputStream(); + + /** Standard input of the channel. This is sent to the standard input of the process launched. */ + private InputStream in = new PipedInputStream((PipedOutputStream) invertedIn); + + /** Input stream tar allows to read the standard out of the process from outside the class. */ + private InputStream invertedOut = new PipedInputStream((PipedOutputStream) out); + + /** Last exception. */ + private Throwable lastError; + + /** last command received. */ + private String lastHint; + + /** + * Create a Shell channel for a process execution. + * + * @param session SSH session. + * @throws IOException in case of error. + */ + public ShellChannelImpl(ClientSession session) throws IOException { + this.session = session; + } + + /** + * Executes a command in the shell. + * + * @param cmd Command to execute. + * @throws IOException in case of error. This method will block until the command finishes + * executing. + */ + @Override + public void execCommand(String cmd) throws IOException { + this.channel = session.createExecChannel(cmd + "\n"); + this.lastHint = cmd; + this.lastError = null; + channel.setOut(out); + channel.setIn(in); + // FIXME add the stderr of the command, pump it to the TaskListener for the computer + channel.open().verify(OPERATION_TIMEOUT_MILLISECONDS, TimeUnit.MILLISECONDS); + channel.waitFor(Collections.singleton(ClientChannelEvent.CLOSED), OPERATION_TIMEOUT_MILLISECONDS); + if (channel.getExitStatus() != null && channel.getExitStatus() != 0) { + this.lastError = new IOException("Command failed with exit status " + channel.getExitStatus()); + } + } + + /** Returns the standard output stream of the channel. */ + @Override + public InputStream getInvertedStdout() { + return invertedOut; + } + + /** Returns the standard input stream of the channel. */ + @Override + public OutputStream getInvertedStdin() { + return invertedIn; + } + + /** Returns the standard error stream of the channel. */ + @Override + public Throwable getLastError() { + return lastError; + } + + /** Returns the last command executed. */ + @Override + public String getLastAttemptedCommand() { + return lastHint; + } + + /** Closes the channel. */ + @Override + public void close() throws IOException { + channel.close(); + channel = null; + out.close(); + invertedIn.close(); + in.close(); + invertedOut.close(); + } +} diff --git a/src/main/java/io/jenkins/plugins/sshbuildagents/ssh/mina/verifiers/AcceptAllServerKeyVerifier.java b/src/main/java/io/jenkins/plugins/sshbuildagents/ssh/mina/verifiers/AcceptAllServerKeyVerifier.java new file mode 100644 index 00000000..d9c4f453 --- /dev/null +++ b/src/main/java/io/jenkins/plugins/sshbuildagents/ssh/mina/verifiers/AcceptAllServerKeyVerifier.java @@ -0,0 +1,21 @@ +/* + * Copyright The Original Author or Authors + * SPDX-License-Identifier: MIT + */ + +package io.jenkins.plugins.sshbuildagents.ssh.mina.verifiers; + +import org.apache.sshd.client.keyverifier.ServerKeyVerifier; + +/** + * A server key verifier that accepts all server keys without verification. This is not recommended + * for production use as it does not provide any security. Use with caution, primarily for testing + * purposes. + * + */ +public class AcceptAllServerKeyVerifier extends KeyVerifier { + @Override + public ServerKeyVerifier getServerKeyVerifier() { + return org.apache.sshd.client.keyverifier.AcceptAllServerKeyVerifier.INSTANCE; + } +} diff --git a/src/main/java/io/jenkins/plugins/sshbuildagents/ssh/mina/verifiers/KeyVerifier.java b/src/main/java/io/jenkins/plugins/sshbuildagents/ssh/mina/verifiers/KeyVerifier.java new file mode 100644 index 00000000..5c21f931 --- /dev/null +++ b/src/main/java/io/jenkins/plugins/sshbuildagents/ssh/mina/verifiers/KeyVerifier.java @@ -0,0 +1,29 @@ +/* + * Copyright The Original Author or Authors + * SPDX-License-Identifier: MIT + */ + +package io.jenkins.plugins.sshbuildagents.ssh.mina.verifiers; + +import hudson.model.Describable; +import hudson.model.Descriptor; +import jenkins.model.Jenkins; +import org.apache.sshd.client.keyverifier.ServerKeyVerifier; + +/** + * Abstract class for key verifiers used in SSH connections. This class provides a method to + * retrieve the server key verifier. It is intended to be extended by specific key verifier + * implementations. + * + */ +public abstract class KeyVerifier implements Describable { + + @Override + public KeyVerifierDescriptor getDescriptor() { + return (KeyVerifierDescriptor) Jenkins.get().getDescriptorOrDie(getClass()); + } + + public abstract ServerKeyVerifier getServerKeyVerifier(); + + public abstract static class KeyVerifierDescriptor extends Descriptor {} +} diff --git a/src/main/java/io/jenkins/plugins/sshbuildagents/ssh/mina/verifiers/KnowHostServerKeyVerifier.java b/src/main/java/io/jenkins/plugins/sshbuildagents/ssh/mina/verifiers/KnowHostServerKeyVerifier.java new file mode 100644 index 00000000..2dfbb334 --- /dev/null +++ b/src/main/java/io/jenkins/plugins/sshbuildagents/ssh/mina/verifiers/KnowHostServerKeyVerifier.java @@ -0,0 +1,19 @@ +/* + * Copyright The Original Author or Authors + * SPDX-License-Identifier: MIT + */ + +package io.jenkins.plugins.sshbuildagents.ssh.mina.verifiers; + +import org.apache.sshd.client.keyverifier.ServerKeyVerifier; + +/** + * A server key verifier that uses the known hosts file for verification. + * + */ +public class KnowHostServerKeyVerifier extends KeyVerifier { + @Override + public ServerKeyVerifier getServerKeyVerifier() { + return new org.apache.sshd.client.keyverifier.DefaultKnownHostsServerKeyVerifier(null); + } +} diff --git a/src/main/java/io/jenkins/plugins/sshbuildagents/ssh/mina/verifiers/RequiredServerKeyVerifier.java b/src/main/java/io/jenkins/plugins/sshbuildagents/ssh/mina/verifiers/RequiredServerKeyVerifier.java new file mode 100644 index 00000000..71d9c13e --- /dev/null +++ b/src/main/java/io/jenkins/plugins/sshbuildagents/ssh/mina/verifiers/RequiredServerKeyVerifier.java @@ -0,0 +1,36 @@ +/* + * Copyright The Original Author or Authors + * SPDX-License-Identifier: MIT + */ + +package io.jenkins.plugins.sshbuildagents.ssh.mina.verifiers; + +import edu.umd.cs.findbugs.annotations.NonNull; +import java.security.PublicKey; +import org.apache.sshd.client.keyverifier.ServerKeyVerifier; + +/** + * A server key verifier that requires a specific public key for authentication. + * + */ +public class RequiredServerKeyVerifier extends KeyVerifier { + private String publicKey; + + public RequiredServerKeyVerifier(@NonNull String publicKey) { + this.publicKey = publicKey; + } + + @Override + public ServerKeyVerifier getServerKeyVerifier() { + PublicKey requiredKey = null; + return new org.apache.sshd.client.keyverifier.RequiredServerKeyVerifier(requiredKey); + } + + public String getPublicKey() { + return publicKey; + } + + public void setPublicKey(String publicKey) { + this.publicKey = publicKey; + } +} diff --git a/src/main/resources/io/jenkins/plugins/sshbuildagents/Messages.properties b/src/main/resources/io/jenkins/plugins/sshbuildagents/Messages.properties new file mode 100644 index 00000000..7c3bc424 --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/sshbuildagents/Messages.properties @@ -0,0 +1,68 @@ +SSHLauncher.StartingSFTPClient={0} [SSH] Starting sftp client. +SSHLauncher.RemoteFSDoesNotExist={0} [SSH] Remote file system root {1} does not exist. Will try to create it... +SSHLauncher.RemoteFSIsAFile=Remote file system root {0} is a file not a directory or a symlink. +SSHLauncher.CopyingAgentJar={0} [SSH] Copying latest remoting.jar... +SSHLauncher.CopiedXXXBytes={0} [SSH] Copied {1} bytes. +SSHLauncher.ErrorCopyingAgentJarInto=Could not copy remoting.jar into ''{0}'' on agent +SSHLauncher.ErrorCopyingAgentJarTo=Could not copy remoting.jar to ''{0}'' on agent +SSHLauncher.CheckingDefaultJava={0} [SSH] Checking java version of {1} +SSHLauncher.ConnectionClosed={0} [SSH] Connection closed. +SSHLauncher.ErrorWhileClosingConnection=Exception thrown while closing connection. +SSHLauncher.AbortedDuringConnectionOpen=Agent start aborted. +SSHLauncher.FailedToDetectEnvironment=Failed to detect the environment for automatic JDK installation. Please report this to jenkinsci-users@googlegroups.com: {0} +SSHLauncher.NoJavaFound=Java version {0} was found but 1.7 or later is needed. +SSHLauncher.NoJavaFound2=Java version {0} was found but 1.{1} or later is needed. +SSHLauncher.NoPrivateKey={0} [SSH] Private key file "{1}" doesn''t exist. Skipping public key authentication. +SSHLauncher.JavaVersionResult={0} [SSH] {1} -version returned {2}. +SSHLauncher.OpeningSSHConnection={0} [SSH] Opening SSH connection to {1}. +SSHLauncher.AuthenticatingPublicKey={0} [SSH] Authenticating as {1} with {2}. +SSHLauncher.AuthenticatingUserPass={0} [SSH] Authenticating as {1}/{2}. +SSHLauncher.AuthenticationSuccessful={0} [SSH] Authentication successful. +SSHLauncher.AuthenticationFailed={0} [SSH] Authentication failed. +SSHLauncher.AuthenticationFailedException=Authentication failed. +SSHLauncher.ErrorDeletingFile={0} [SSH] Error deleting file. +SSHLauncher.DescriptorDisplayName=Launch agents via SSH (Mina SSHD)(Beta) +SSHLauncher.SSHHeaderJunkDetected=SSH connection reports a garbage before a command execution.\nCheck your .bashrc, .profile, and so on to make sure it is quiet.\nThe received junk text is as follows: +SSHLauncher.UnknownJavaVersion=Couldn''t figure out the Java version of {0} +SSHLauncher.UnexpectedError=Unexpected error in launching a agent. +SSHLauncher.StartingAgentProcess={0} [SSH] Starting agent process: {1} +SSHLauncher.RemoteUserEnvironment={0} [SSH] The remote user''s environment is: +SSHLauncher.StartingSCPClient={0} [SSH] SFTP failed. Copying via SCP. +SSHLauncher.LaunchFailedDuration=SSH Launch of {0} on {1} failed in {2} ms +SSHLauncher.LaunchCompletedDuration=SSH Launch of {0} on {1} completed in {2} ms +SSHLauncher.LaunchFailed=SSH Launch of {0} on {1} failed +SSHConnector.LaunchTimeoutMustBeANumber=The launch timeout must be a number. +SSHConnector.LaunchTimeoutMustBePositive=The launch timeout must be a positive number. +SSHLauncher.SelectedCredentialsMissing=The selected credentials cannot be found +SSHLauncher.PortNotANumber=Cannot parse the port +SSHLauncher.PortNotSpecified=The port must be specified +SSHLauncher.PortLessThanZero=The port value must be greater than 0 +SSHLauncher.PortMoreThan65535=The port value must be less than 65536 +SSHLauncher.HostNotSpecified=The Host must be specified +SSHLauncher.JavaPathHasWhiteSpaces=The Java PATH specified contains whitespaces, probably you need to use quotes around the path. +SSHLauncher.alreadyConnected=The Agent is connected, disconnect it before to try to connect it again. +SSHLauncher.launchCanceled=The agent launch was canceled due an error +ManualTrustingHostKeyVerifier.KeyNotTrusted={0} [SSH] WARNING: The SSH key for this host is not currently trusted. Connections will be denied until this new key is authorised. +ManualTrustingHostKeyVerifier.KeyAutoTrusted={0} [SSH] The SSH key with fingerprint {1} has been automatically trusted for connections to this machine. +ManualTrustingHostKeyVerifier.KeyTrusted={0} [SSH] SSH host key matches key seen previously for this host. Connection will be allowed. +ManualTrustingHostKeyVerifier.DescriptorDisplayName=Manually trusted key Verification Strategy +NonVerifyingHostKeyVerifier.NoVerificationWarning={0} [SSH] WARNING: SSH Host Keys are not being verified. Man-in-the-middle attacks may be possible against this connection. +NonVerifyingHostKeyVerifier.DescriptorDisplayName=Non verifying Verification Strategy +TrustHostKeyAction.DisplayName=Trust SSH Host Key +ManualKeyProvidedHostKeyVerifier.KeyNotTrusted={0} [SSH] WARNING: The SSH key for this host does not match the key required in the connection configuration. Connections will be denied until the host key matches the configuration key. +ManualKeyProvidedHostKeyVerifier.KeyTrusted={0} [SSH] SSH host key matched the key required for this connection. Connection will be allowed. +ManualKeyProvidedHostKeyVerifier.TwoPartKey=Key should be 2 parts: algorithm and Base 64 encoded key value. +ManualKeyProvidedHostKeyVerifier.Base64EncodedKeyValueRequired=The value part of the key should be a Base64 encoded value. +ManualKeyProvidedHostKeyVerifier.KeyValueDoesNotParse=Key value does not parse into a valid {0} key +ManualKeyProvidedHostKeyVerifier.UnknownKeyAlgorithm=Key algorithm should be one of ssh-rsa or ssh-dss. +ManualKeyProvidedHostKeyVerifier.DisplayName=Manually provided key Verification Strategy +KnownHostsFileHostKeyVerifier.DisplayName=Known hosts file Verification Strategy +KnownHostsFileHostKeyVerifier.NewKeyNotTrusted={0} [SSH] WARNING: No entry currently exists in the Known Hosts file for this host. Connections will be denied until this new host and its associated key is added to the Known Hosts file. +KnownHostsFileHostKeyVerifier.ChangedKeyNotTrusted={0} [SSH] The SSH key presented by the remote host does not match the key saved in the Known Hosts file against this host. Connections to this host will be denied until the two keys match. +KnownHostsFileHostKeyVerifier.KeyTrusted={0} [SSH] SSH host key matches key in Known Hosts file. Connection will be allowed. +KnownHostsFileHostKeyVerifier.NoKnownHostsFile={0} [SSH] No Known Hosts file was found at {0}. Please ensure one is created at this path and that Jenkins can read it. +KnownHostsFileHostKeyVerifier.SearchingFor=Searching for {0} in {1} +MissingVerificationStrategyAdministrativeMonitor.DisplayName=Missing Verification Strategy Monitor +SSHApacheMinaLauncher.DescriptorDisplayName=Launch agents via SSH (Apache Mina SSHD) (Alpha) +SSHLauncher.WorkingDirectoryNotSet=The working directory is not set. +SSHLauncher.ComputerAndListenerMustNotBeNull=Computer and listener must not be null. diff --git a/src/main/resources/io/jenkins/plugins/sshbuildagents/Messages_de.properties b/src/main/resources/io/jenkins/plugins/sshbuildagents/Messages_de.properties new file mode 100644 index 00000000..fca14ba0 --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/sshbuildagents/Messages_de.properties @@ -0,0 +1,2 @@ +SSHLauncher.DescriptorDisplayName=Starte Agent \u00FCber SSH +SSHApacheMinaLauncher.DescriptorDisplayName=Starte Agent \u00FCber SSH (Apache Mina SSHD) diff --git a/src/main/resources/io/jenkins/plugins/sshbuildagents/Messages_es.properties b/src/main/resources/io/jenkins/plugins/sshbuildagents/Messages_es.properties new file mode 100644 index 00000000..69b2357b --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/sshbuildagents/Messages_es.properties @@ -0,0 +1,80 @@ +# The MIT License +# +# Copyright (c) 2004-2010, Sun Microsystems, Inc. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +# {0} [SSH] Remote file system root {1} does not exist. Will try to create it... +SSHLauncher.RemoteFSDoesNotExist={0} [SSH] Directorio raiz en el sistema remoto ({1}) no existe. Intentando crearlo ... +# {0} [SSH] Authentication successful. +SSHLauncher.AuthenticationSuccessful={0} [SSH] Autenticaci\u00F3n v\u00E1lida +# Could not copy remoting.jar into ''{0}'' on agent +SSHLauncher.ErrorCopyingAgentJarInto=No se pudo copiar "remoting.jar" en ''{0}'' en el nodo remoto +# Could not copy remoting.jar to ''{0}'' on agent +SSHLauncher.ErrorCopyingAgentJarTo=No se pudo copiar "remoting.jar" a ''{0}'' en el nodo remoto +# {0} [SSH] Copied {1} bytes. +SSHLauncher.CopiedXXXBytes={0} [SSH] Se copiaron {1} bytes. +# Exception thrown while closing connection. +SSHLauncher.ErrorWhileClosingConnection=Se produjo una excepci\u00F3n al cerrar la conexi\u00F3n +# {0} [SSH] Copying latest remoting.jar... +SSHLauncher.CopyingAgentJar={0} [SSH] Copiando el \u00FAltimo remoting.jar... +# SSH connection reports a garbage before a command execution.\nCheck your .bashrc, .profile, and so on to make sure it is quiet.\nThe received junk text is as follows: +SSHLauncher.SSHHeaderJunkDetected=La conexi\u00F3n ha recibido texto antes de ejecutar el comando.\n\ + Comprueba tus ficheros ".bashrc" o ".profile" para comprobar que no escriben nada al hacer 'login'.\n\ + El texto recibido es: +# {0} [SSH] Authentication failed. +SSHLauncher.AuthenticationFailed={0} [SSH] Fallo en la autenticaci\u00F3n. +# {0} [SSH] Connection closed. +SSHLauncher.ConnectionClosed={0} [SSH] Cerrada la conexi\u00F3n. +# Failed to detect the environment for automatic JDK installation. Please report this to jenkinsci-users@googlegroups.com: {0} +SSHLauncher.FailedToDetectEnvironment=Se ha detectado un fallo al intentar detectar el entorno para instalar java (JDK). \ + Por favor env\u00EDa un correo con el error a jenkinsci-users@googlegroups.com: {0} +# {0} [SSH] Error deleting file. +SSHLauncher.ErrorDeletingFile={0} [SSH] Error al borrar el fichero. +# {0} [SSH] The remote user''s environment is: +SSHLauncher.RemoteUserEnvironment={0} [SSH] The entorno remoto del usuario es: +# Couldn''t figure out the Java version of {0} +SSHLauncher.UnknownJavaVersion=Imposible de averiguar la versi\u00F3n de Java en {0} +# agent start aborted. +SSHLauncher.AbortedDuringConnectionOpen=El inicio del nodo remoto ha sido abortado +# {0} [SSH] Starting agent process: {1} +SSHLauncher.StartingAgentProcess={0} [SSH] Arrancando proceso en el nodo remoto: {1} +# {0} [SSH] {1} -version returned {2}. +SSHLauncher.JavaVersionResult={0} [SSH] {1} -version ha retornado {2}. +# Unexpected error in launching a agent. This is probably a bug in Jenkins. +SSHLauncher.UnexpectedError=Error inesperado al lanzar un nodo. +# Authentication failed. +SSHLauncher.AuthenticationFailedException=Autenticaci\u00F3n fallida +# Java version {0} was found but 1.5 or later is needed. +SSHLauncher.NoJavaFound=Se encontr\u00F3 la versi\u00F3n de Java {0}, sin embargo se necesita la versi\u00F3n 1.5 o posterior +# {0} [SSH] Starting sftp client. +SSHLauncher.StartingSFTPClient={0} [SSH] Arrancando cliente de "sftp" +# Remote file system root {0} is a file not a directory or a symlink. +SSHLauncher.RemoteFSIsAFile=El systema de archivos remoto {0} es un fichero, no un directorio ni un link simb\u00F3lico +# Launch agents on Unix machines via SSH +SSHLauncher.DescriptorDisplayName=Arrancar agentes remotos v\u00EDa SSH +SSHApacheMinaLauncher.DescriptorDisplayName=Arrancar agentes remotos v\u00EDa SSH (Apache Mina SSHD) +# {0} [SSH] Authenticating as {1}/{2}. +SSHLauncher.AuthenticatingUserPass={0} [SSH] Autentic\u00E1ndose como {1}/{2}. +# {0} [SSH] Checking java version of {1} +SSHLauncher.CheckingDefaultJava={0} [SSH] Comprobando la versi\u00F3n de java en {1} +# {0} [SSH] Authenticating as {1} with {2}. +SSHLauncher.AuthenticatingPublicKey={0} [SSH] Authentic\u00E1ndose como {1} con {2}. +# {0} [SSH] Opening SSH connection to {1}. +SSHLauncher.OpeningSSHConnection={0} [SSH] abriendo una conexi\u00F3n SSH sobre {1}. diff --git a/src/main/resources/io/jenkins/plugins/sshbuildagents/Messages_ja.properties b/src/main/resources/io/jenkins/plugins/sshbuildagents/Messages_ja.properties new file mode 100644 index 00000000..2ec6e319 --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/sshbuildagents/Messages_ja.properties @@ -0,0 +1,28 @@ +SSHLauncher.StartingSFTPClient={0} [SSH] sftp\u30af\u30e9\u30a4\u30a2\u30f3\u30c8\u306e\u958b\u59cb +SSHLauncher.RemoteFSDoesNotExist={0} [SSH] \u30ea\u30e2\u30fc\u30c8FS\u30eb\u30fc\u30c8 {1} \u304c\u5b58\u5728\u3057\u306a\u3044\u306e\u3067\u3001\u4f5c\u6210\u3057\u307e\u3059... +SSHLauncher.RemoteFSIsAFile=\u30ea\u30e2\u30fc\u30c8FS\u30eb\u30fc\u30c8 {1} \u304c\u3001\u30c7\u30a3\u30ec\u30af\u30c8\u30ea\u3084\u30ea\u30f3\u30af\u3067\u306f\u306a\u304f\u30d5\u30a1\u30a4\u30eb\u3067\u3059\u3002 +SSHLauncher.CopyingAgentJar={0} [SSH] \u6700\u65b0\u306eremoting.jar\u3092\u30b3\u30d4\u30fc\u4e2d... +SSHLauncher.CopiedXXXBytes={0} [SSH] {1} \u30d0\u30a4\u30c8\u30b3\u30d4\u30fc. +SSHLauncher.ErrorCopyingAgentJarInto=remoting.jar\u3092\u30b9\u30ec\u30fc\u30d6\u306e ''{0}'' \u306b\u30b3\u30d4\u30fc\u3067\u304d\u307e\u305b\u3093\u3067\u3057\u305f\u3002 +SSHLauncher.ErrorCopyingAgentJarTo=remoting.jar\u3092\u30b9\u30ec\u30fc\u30d6\u306e ''{0}'' \u306b\u30b3\u30d4\u30fc\u3067\u304d\u307e\u305b\u3093\u3067\u3057\u305f\u3002 +SSHLauncher.CheckingDefaultJava={0} [SSH] {1}\u306eJava\u30d0\u30fc\u30b8\u30e7\u30f3\u3092\u30c1\u30a7\u30c3\u30af +SSHLauncher.ConnectionClosed={0} [SSH] \u30b3\u30cd\u30af\u30b7\u30e7\u30f3\u7d42\u4e86 +SSHLauncher.ErrorWhileClosingConnection=\u30b3\u30cd\u30af\u30b7\u30e7\u30f3\u7d42\u4e86\u4e2d\u306b\u4f8b\u5916\u304c\u767a\u751f +SSHLauncher.AbortedDuringConnectionOpen=\u30b9\u30ec\u30fc\u30d6\u306e\u958b\u59cb\u3092\u4e2d\u6b62 +SSHLauncher.FailedToDetectEnvironment=JDK\u81ea\u52d5\u30a4\u30f3\u30b9\u30c8\u30fc\u30eb\u306e\u74b0\u5883\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093\u3002jenkinsci-users@googlegroups.com\u306b\u30ec\u30dd\u30fc\u30c8\u3057\u3066\u304f\u3060\u3055\u3044\u3002: {0} +SSHLauncher.NoJavaFound=Java\u306e\u30d0\u30fc\u30b8\u30e7\u30f3 {0} \u304c\u898b\u3064\u304b\u308a\u307e\u3057\u305f\u304c\u30011.5\u4ee5\u964d\u304c\u5fc5\u8981\u3067\u3059\u3002 +SSHLauncher.JavaVersionResult={0} [SSH] {1} -version returned {2}. +SSHLauncher.OpeningSSHConnection={0} [SSH] {1}\u3068\u306eSSH\u30b3\u30cd\u30af\u30b7\u30e7\u30f3\u3092\u30aa\u30fc\u30d7\u30f3 +SSHLauncher.AuthenticatingPublicKey={0} [SSH] {2}\u3092\u4f7f\u7528\u3057\u3066\u3001{1}\u3092\u8a8d\u8a3c +SSHLauncher.AuthenticatingUserPass={0} [SSH] {1}/{2}\u3092\u8a8d\u8a3c +SSHLauncher.AuthenticationSuccessful={0} [SSH] \u8a8d\u8a3c\u6210\u529f +SSHLauncher.AuthenticationFailed={0} [SSH] \u8a8d\u8a3c\u5931\u6557 +SSHLauncher.AuthenticationFailedException=\u8a8d\u8a3c\u5931\u6557 +SSHLauncher.ErrorDeletingFile={0} [SSH] \u30d5\u30a1\u30a4\u30eb\u524a\u9664\u4e2d\u306b\u30a8\u30e9\u30fc\u304c\u767a\u751f\u3057\u307e\u3057\u305f\u3002 +SSHLauncher.DescriptorDisplayName=SSH\u7d4c\u7531\u3067Unix\u30de\u30b7\u30f3\u306e\u30b9\u30ec\u30fc\u30d6\u30a8\u30fc\u30b8\u30a7\u30f3\u30c8\u3092\u8d77\u52d5 +SSHLauncher.SSHHeaderJunkDetected=\u30b3\u30de\u30f3\u30c9\u3092\u5b9f\u884c\u3059\u308b\u524d\u306b\u3001SSH\u306e\u30b3\u30cd\u30af\u30b7\u30e7\u30f3\u306b\u4e0d\u8981\u306a\u30c7\u30fc\u30bf\u304c\u9001\u4fe1\u3055\u308c\u307e\u3057\u305f\u3002\n.bashrc, .profile\u306a\u3069\u3092\u78ba\u8a8d\u3057\u3066\u3001\u9001\u3089\u306a\u3044\u3088\u3046\u306b\u3057\u3066\u304f\u3060\u3055\u3044\u3002\n\u53d7\u4fe1\u3057\u305f\u4e0d\u8981\u306a\u30c7\u30fc\u30bf\u306f\u6b21\u306e\u901a\u308a\u3067\u3059\u3002: +SSHLauncher.UnknownJavaVersion={0} \u306eJava\u30d0\u30fc\u30b8\u30e7\u30f3\u304c\u4e0d\u660e\u3067\u3059\u3002 +SSHLauncher.UnexpectedError=\u30b9\u30ec\u30fc\u30d6\u306e\u8d77\u52d5\u6642\u306b\u4e0d\u660e\u306a\u30a8\u30e9\u30fc\u304c\u767a\u751f\u3057\u307e\u3057\u305f\u3002\u305f\u3076\u3093\u3001Jenkins\u306e\u30d0\u30b0\u3067\u3059\u3002 +SSHLauncher.StartingAgentProcess={0} [SSH] \u30b9\u30ec\u30fc\u30d6\u306e\u30d7\u30ed\u30bb\u30b9\u3092\u958b\u59cb: {1} +SSHLauncher.RemoteUserEnvironment={0} [SSH] \u30ea\u30e2\u30fc\u30c8\u30e6\u30fc\u30b6\u30fc\u306e\u74b0\u5883: +SSHLauncher.StartingSCPClient={0} [SSH] SFTP\u304c\u5931\u6557\u3057\u307e\u3057\u305f\u3002SCP\u3067\u30b3\u30d4\u30fc\u3057\u307e\u3059\u3002 diff --git a/src/main/resources/io/jenkins/plugins/sshbuildagents/Messages_zh_CN.properties b/src/main/resources/io/jenkins/plugins/sshbuildagents/Messages_zh_CN.properties new file mode 100644 index 00000000..e27aa5ce --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/sshbuildagents/Messages_zh_CN.properties @@ -0,0 +1,36 @@ +# The MIT License +# +# Copyright (c) 2017, suren +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +SSHLauncher.CopyingAgentJar={0} [SSH] \u6B63\u5728\u62F7\u8D1D\u6700\u65B0\u7248\u672C\u7684 remoting.jar... +SSHLauncher.ConnectionClosed={0} [SSH] \u8FDE\u63A5\u5173\u95ED\u3002 +SSHLauncher.ErrorWhileClosingConnection=\u5173\u95ED\u8FDE\u63A5\u65F6\u53D1\u751F\u5F02\u5E38\u3002 +SSHLauncher.AbortedDuringConnectionOpen=\u4ECE\u8282\u70B9\u542F\u52A8\u7EC8\u6B62\u3002 +SSHLauncher.AuthenticationSuccessful={0} [SSH] \u8BA4\u8BC1\u6210\u529F\u3002 +SSHLauncher.AuthenticationFailed={0} [SSH] \u8BA4\u8BC1\u5931\u8D25\u3002 +SSHLauncher.AuthenticationFailedException=\u8BA4\u8BC1\u5931\u8D25\u3002 +SSHConnector.LaunchTimeoutMustBeANumber=\u542F\u52A8\u8D85\u65F6\u65F6\u95F4\u5FC5\u987B\u4E3A\u6570\u5B57 +SSHConnector.LaunchTimeoutMustBePositive=\u542F\u52A8\u8D85\u65F6\u65F6\u95F4\u5FC5\u987B\u4E3A\u6B63\u6570 +SSHLauncher.SelectedCredentialsMissing=The selected credentials cannot be found +SSHLauncher.PortNotANumber=\u7AEF\u53E3\u53F7\u5FC5\u987B\u662F\u6570\u5B57 +SSHLauncher.PortNotSpecified=\u5FC5\u987B\u6307\u5B9A\u7AEF\u53E3 +SSHLauncher.PortLessThanZero=\u7AEF\u53E3\u53F7\u5FC5\u987B\u5927\u4E8E 0 +SSHLauncher.PortMoreThan65535=\u7AEF\u53E3\u53F7\u5FC5\u987B\u5C0F\u4E8E 65536 diff --git a/src/main/resources/io/jenkins/plugins/sshbuildagents/Messages_zh_TW.properties b/src/main/resources/io/jenkins/plugins/sshbuildagents/Messages_zh_TW.properties new file mode 100644 index 00000000..95752a8f --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/sshbuildagents/Messages_zh_TW.properties @@ -0,0 +1,51 @@ +# The MIT License +# +# Copyright (c) 2013, Chunghwa Telecom Co., Ltd., Pei-Tang Huang +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +SSHLauncher.StartingSFTPClient={0} [SSH] \u555f\u52d5 sftp \u7528\u6236\u7aef\u3002 +SSHLauncher.RemoteFSDoesNotExist={0} [SSH] \u9060\u7aef\u6a94\u6848\u7cfb\u7d71\u6839\u76ee\u9304 {1} \u4e0d\u5b58\u5728\u3002\u8a66\u8457\u5efa\u7acb\u76ee\u9304... +SSHLauncher.RemoteFSIsAFile=\u9060\u7aef\u6a94\u6848\u7cfb\u7d71\u6839\u8def\u5f91 {0} \u662f\u6a94\u6848\u800c\u4e0d\u662f\u76ee\u9304\uff0c\u6216\u662f symlink\u3002 +SSHLauncher.CopyingAgentJar={0} [SSH] \u8907\u88fd\u6700\u65b0\u7684 remoting.jar... +SSHLauncher.CopiedXXXBytes={0} [SSH] \u5df2\u8907\u88fd {1} \u500b\u4f4d\u5143\u7d44\u3002 +SSHLauncher.ErrorCopyingAgentJarInto=\u7121\u6cd5\u5c07 remoting.jar \u8907\u88fd\u5230 agent \u4e0a\u7684 ''{0}'' +SSHLauncher.ErrorCopyingAgentJarTo=\u7121\u6cd5\u5c07 remoting.jar \u8907\u88fd\u5230 agent \u4e0a\u7684 ''{0}'' +SSHLauncher.CheckingDefaultJava={0} [SSH] \u6aa2\u67e5 {1} \u7684 Java \u7248\u672c +SSHLauncher.ConnectionClosed={0} [SSH] \u9023\u7dda\u95dc\u9589\u3002 +SSHLauncher.ErrorWhileClosingConnection=\u95dc\u9589\u9023\u7dda\u6642\u767c\u751f\u4f8b\u5916\u4e8b\u4ef6\u3002 +SSHLauncher.AbortedDuringConnectionOpen=agent \u555f\u52d5\u5df2\u4e2d\u6b62\u3002 +SSHLauncher.FailedToDetectEnvironment=\u7121\u6cd5\u5075\u6e2c\u81ea\u52d5\u5b89\u88dd JDK \u7684\u74b0\u5883\u3002\u8acb\u5c07\u554f\u984c\u56de\u5831\u5230 jenkinsci-users@googlegroups.com: {0} +SSHLauncher.NoJavaFound=\u627e\u5230 Java {0} \u7248\uff0c\u4f46\u662f\u81f3\u5c11\u8981 1.5 \u7248\u624d\u884c\u3002 +SSHLauncher.NoPrivateKey={0} [SSH] \u79c1\u6709\u91d1\u9470\u6a94 "{1}" \u4e0d\u5b58\u5728\u3002\u7565\u904e\u516c\u958b\u516c\u9470\u9a57\u8b49\u3002 +SSHLauncher.JavaVersionResult={0} [SSH] {1} -version \u56de\u50b3 {2}\u3002 +SSHLauncher.OpeningSSHConnection={0} [SSH] \u958b\u555f SSH \u9023\u7dda\u5230 {1}\u3002 +SSHLauncher.AuthenticatingPublicKey={0} [SSH] \u7528 {2} \u9a57\u8b49 {1}\u3002 +SSHLauncher.AuthenticatingUserPass={0} [SSH] \u4ee5 {1}/{2} \u9a57\u8b49\u3002 +SSHLauncher.AuthenticationSuccessful={0} [SSH] \u9a57\u8b49\u6210\u529f\u3002 +SSHLauncher.AuthenticationFailed={0} [SSH] \u9a57\u8b49\u5931\u6557\u3002 +SSHLauncher.AuthenticationFailedException=\u9a57\u8b49\u5931\u6557\u3002 +SSHLauncher.ErrorDeletingFile={0} [SSH] \u522a\u9664\u6a94\u6848\u6642\u767c\u751f\u932f\u8aa4\u3002 +SSHLauncher.DescriptorDisplayName=\u900f\u904e SSH \u555f\u52d5 Unix \u4e3b\u6a5f\u4e0a\u7684 agent \u4ee3\u7406\u7a0b\u5f0f +SSHLauncher.SSHHeaderJunkDetected=\u57f7\u884c\u6307\u4ee4\u524d SSH \u9023\u7dda\u56de\u8986\u5783\u573e\u8a0a\u606f\u3002\n\u8acb\u6aa2\u67e5\u60a8\u7684 .bashrc, .profile \u7b49\u6a94\u6848\uff0c\u78ba\u5b9a\u5b83\u5011\u4e0d\u6703\u51fa\u4ec0\u9ebc\u8a0a\u606f\u3002\n\u63a5\u6536\u5230\u7684\u5783\u573e\u8a0a\u606f\u70ba: +SSHLauncher.UnknownJavaVersion=\u4e0d\u77e5\u9053 {0} \u7684 Java \u7248\u672c +SSHLauncher.UnexpectedError=\u555f\u52d5 agent \u6642\u767c\u751f\u9810\u671f\u5916\u7684\u932f\u8aa4\u3002\u9019\u53ef\u80fd\u662f Jenkins \u7684 Bug\u3002 +SSHLauncher.StartingAgentProcess={0} [SSH] \u555f\u52d5 agent \u8655\u7406\u5e8f: {1} +SSHLauncher.RemoteUserEnvironment={0} [SSH] \u9060\u7aef\u4f7f\u7528\u8005\u7684\u74b0\u5883\u662f: +SSHLauncher.StartingSCPClient={0} [SSH] SFTP \u5931\u6557\uff0c\u900f\u904e SCP \u8907\u88fd\u3002 diff --git a/src/main/resources/io/jenkins/plugins/sshbuildagents/ssh/mina/SSHApacheMinaLauncher/config.jelly b/src/main/resources/io/jenkins/plugins/sshbuildagents/ssh/mina/SSHApacheMinaLauncher/config.jelly new file mode 100644 index 00000000..b44ca3a3 --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/sshbuildagents/ssh/mina/SSHApacheMinaLauncher/config.jelly @@ -0,0 +1,62 @@ + + +
+ The SSH Apache Mina Launcher is in early state of development and is not recommended for production use. +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/src/main/resources/io/jenkins/plugins/sshbuildagents/ssh/mina/SSHApacheMinaLauncher/help-credentialsId.html b/src/main/resources/io/jenkins/plugins/sshbuildagents/ssh/mina/SSHApacheMinaLauncher/help-credentialsId.html new file mode 100644 index 00000000..2b7bc57e --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/sshbuildagents/ssh/mina/SSHApacheMinaLauncher/help-credentialsId.html @@ -0,0 +1,3 @@ +
+ Select the credentials to be used for logging in to the remote host. +
\ No newline at end of file diff --git a/src/main/resources/io/jenkins/plugins/sshbuildagents/ssh/mina/SSHApacheMinaLauncher/help-credentialsId_ja.html b/src/main/resources/io/jenkins/plugins/sshbuildagents/ssh/mina/SSHApacheMinaLauncher/help-credentialsId_ja.html new file mode 100644 index 00000000..2848aa98 --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/sshbuildagents/ssh/mina/SSHApacheMinaLauncher/help-credentialsId_ja.html @@ -0,0 +1,3 @@ +
+ リモートホストにログインする際に使用する認証情報を選択します。 +
\ No newline at end of file diff --git a/src/main/resources/io/jenkins/plugins/sshbuildagents/ssh/mina/SSHApacheMinaLauncher/help-credentialsId_zh_TW.html b/src/main/resources/io/jenkins/plugins/sshbuildagents/ssh/mina/SSHApacheMinaLauncher/help-credentialsId_zh_TW.html new file mode 100644 index 00000000..efb91d6d --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/sshbuildagents/ssh/mina/SSHApacheMinaLauncher/help-credentialsId_zh_TW.html @@ -0,0 +1,3 @@ +
+ 選擇要登入遠端主機的憑證。 +
\ No newline at end of file diff --git a/src/main/resources/io/jenkins/plugins/sshbuildagents/ssh/mina/SSHApacheMinaLauncher/help-host.html b/src/main/resources/io/jenkins/plugins/sshbuildagents/ssh/mina/SSHApacheMinaLauncher/help-host.html new file mode 100644 index 00000000..97c0281f --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/sshbuildagents/ssh/mina/SSHApacheMinaLauncher/help-host.html @@ -0,0 +1,3 @@ +
+ Agent's Hostname or IP to connect. +
diff --git a/src/main/resources/io/jenkins/plugins/sshbuildagents/ssh/mina/SSHApacheMinaLauncher/help-javaPath.html b/src/main/resources/io/jenkins/plugins/sshbuildagents/ssh/mina/SSHApacheMinaLauncher/help-javaPath.html new file mode 100644 index 00000000..b55c16b9 --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/sshbuildagents/ssh/mina/SSHApacheMinaLauncher/help-javaPath.html @@ -0,0 +1,8 @@ +
+

This java Path will be used to start the jvm. (/mycustomjdkpath/bin/java ) + If empty Jenkins will search java command in the agent +

+

Expressions such as $key or ${key} may be declared in the java Path and will be expanded to values of matching + keys declared in the list of environment variables of this node, or if not present, in the list of global + environment variables.

+
diff --git a/src/main/resources/io/jenkins/plugins/sshbuildagents/ssh/mina/SSHApacheMinaLauncher/help-javaPath_ja.html b/src/main/resources/io/jenkins/plugins/sshbuildagents/ssh/mina/SSHApacheMinaLauncher/help-javaPath_ja.html new file mode 100644 index 00000000..2d4f15c5 --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/sshbuildagents/ssh/mina/SSHApacheMinaLauncher/help-javaPath_ja.html @@ -0,0 +1,8 @@ +
+

このJavaパスは、JVMを起動する際に使用するJavaコマンドのパスです。 + 未入力であれば、スレーブ内のJavaコマンドを探します。 +

+

$keyや${key}といった表記方法を使用すると、このノードの環境変数の値に展開されます。 + もし、存在しなければグローバル環境変数の値に展開します。 +

+
diff --git a/src/main/resources/io/jenkins/plugins/sshbuildagents/ssh/mina/SSHApacheMinaLauncher/help-javaPath_zh_TW.html b/src/main/resources/io/jenkins/plugins/sshbuildagents/ssh/mina/SSHApacheMinaLauncher/help-javaPath_zh_TW.html new file mode 100644 index 00000000..d776a6e7 --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/sshbuildagents/ssh/mina/SSHApacheMinaLauncher/help-javaPath_zh_TW.html @@ -0,0 +1,7 @@ +
+ 用來啟動 JVM 的 Java 路徑(/mycustomjdkpath/bin/java )。 + 不填的話 Jenkins 會在 agent 搜尋 java 指令。 + +

+ Java 路徑裡可以用 $key 或 ${key} 這類表示式,對應到的名稱會被節點的環境變數值取代,如果節點上面沒有設定該變數,就會用全域的環境變數值取代。 +

diff --git a/src/main/resources/io/jenkins/plugins/sshbuildagents/ssh/mina/SSHApacheMinaLauncher/help-jvmOptions.html b/src/main/resources/io/jenkins/plugins/sshbuildagents/ssh/mina/SSHApacheMinaLauncher/help-jvmOptions.html new file mode 100644 index 00000000..d4be48b4 --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/sshbuildagents/ssh/mina/SSHApacheMinaLauncher/help-jvmOptions.html @@ -0,0 +1,3 @@ +
+ Additional arguments for the JVM, such as -Xmx or GC options. +
diff --git a/src/main/resources/io/jenkins/plugins/sshbuildagents/ssh/mina/SSHApacheMinaLauncher/help-launchTimeoutSeconds.html b/src/main/resources/io/jenkins/plugins/sshbuildagents/ssh/mina/SSHApacheMinaLauncher/help-launchTimeoutSeconds.html new file mode 100644 index 00000000..e4a1087f --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/sshbuildagents/ssh/mina/SSHApacheMinaLauncher/help-launchTimeoutSeconds.html @@ -0,0 +1,8 @@ +
+

+ Set the timeout value for ssh agent launch in seconds. If empty, it will be reset to default value. +

+

+ This will only set the timeout for agent launching; once launched, the timeout will not apply. +

+
diff --git a/src/main/resources/io/jenkins/plugins/sshbuildagents/ssh/mina/SSHApacheMinaLauncher/help-maxNumRetries.html b/src/main/resources/io/jenkins/plugins/sshbuildagents/ssh/mina/SSHApacheMinaLauncher/help-maxNumRetries.html new file mode 100644 index 00000000..5b8645e8 --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/sshbuildagents/ssh/mina/SSHApacheMinaLauncher/help-maxNumRetries.html @@ -0,0 +1,6 @@ +
+

+ Set the number of times the SSH connection will be retried if the initial connection results in an error. + If empty, it will be reset to default value. +

+
diff --git a/src/main/resources/io/jenkins/plugins/sshbuildagents/ssh/mina/SSHApacheMinaLauncher/help-port.html b/src/main/resources/io/jenkins/plugins/sshbuildagents/ssh/mina/SSHApacheMinaLauncher/help-port.html new file mode 100644 index 00000000..e65f38bc --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/sshbuildagents/ssh/mina/SSHApacheMinaLauncher/help-port.html @@ -0,0 +1,3 @@ +
+ The TCP port on which the agent's SSH daemon is listening, usually 22. +
diff --git a/src/main/resources/io/jenkins/plugins/sshbuildagents/ssh/mina/SSHApacheMinaLauncher/help-prefixStartSlaveCmd.html b/src/main/resources/io/jenkins/plugins/sshbuildagents/ssh/mina/SSHApacheMinaLauncher/help-prefixStartSlaveCmd.html new file mode 100644 index 00000000..56cfa234 --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/sshbuildagents/ssh/mina/SSHApacheMinaLauncher/help-prefixStartSlaveCmd.html @@ -0,0 +1,5 @@ +
+

What you enter here will be prepended to the launch command.

+

The actual command being issued will be the concatenation of Prefix Start Agent Command, + the command to launch remoting.jar and Suffix Start Agent Command, without any separators. +

\ No newline at end of file diff --git a/src/main/resources/io/jenkins/plugins/sshbuildagents/ssh/mina/SSHApacheMinaLauncher/help-retryWaitTime.html b/src/main/resources/io/jenkins/plugins/sshbuildagents/ssh/mina/SSHApacheMinaLauncher/help-retryWaitTime.html new file mode 100644 index 00000000..ce41b8c5 --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/sshbuildagents/ssh/mina/SSHApacheMinaLauncher/help-retryWaitTime.html @@ -0,0 +1,5 @@ +
+

+ Set the number of seconds to wait between retry attempts of the initial SSH connection. +

+
diff --git a/src/main/resources/io/jenkins/plugins/sshbuildagents/ssh/mina/SSHApacheMinaLauncher/help-sshHostKeyVerificationStrategy.html b/src/main/resources/io/jenkins/plugins/sshbuildagents/ssh/mina/SSHApacheMinaLauncher/help-sshHostKeyVerificationStrategy.html new file mode 100644 index 00000000..3acbb26a --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/sshbuildagents/ssh/mina/SSHApacheMinaLauncher/help-sshHostKeyVerificationStrategy.html @@ -0,0 +1 @@ +

Controls how Jenkins verifies the SSH key presented by the remote host whilst connecting.

diff --git a/src/main/resources/io/jenkins/plugins/sshbuildagents/ssh/mina/SSHApacheMinaLauncher/help-suffixStartSlaveCmd.html b/src/main/resources/io/jenkins/plugins/sshbuildagents/ssh/mina/SSHApacheMinaLauncher/help-suffixStartSlaveCmd.html new file mode 100644 index 00000000..805c2ddc --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/sshbuildagents/ssh/mina/SSHApacheMinaLauncher/help-suffixStartSlaveCmd.html @@ -0,0 +1,6 @@ +
+

What you enter here will be appended to the launch command.

+

The actual command being issued will be the concatenation of Prefix Start Agent Command, + the command to launch remoting.jar and Suffix Start Agent Command, without any separators. + The Suffix Start Agent Command can be used to pass arguments to remoting.jar.

+
\ No newline at end of file diff --git a/src/main/resources/io/jenkins/plugins/sshbuildagents/ssh/mina/SSHApacheMinaLauncher/help-tcpNoDelay.html b/src/main/resources/io/jenkins/plugins/sshbuildagents/ssh/mina/SSHApacheMinaLauncher/help-tcpNoDelay.html new file mode 100644 index 00000000..d6194e6d --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/sshbuildagents/ssh/mina/SSHApacheMinaLauncher/help-tcpNoDelay.html @@ -0,0 +1,7 @@ +
+ Enable/Disables the TCP_NODELAY flag on the SSH connection. + If set, disable the Nagle algorithm. This means that segments are always sent as soon as possible, + even if there is only a small amount of data. When not set, + data is buffered until there is a sufficient amount to send out, + thereby avoiding the frequent sending of small packets, which results in poor utilization of the network. +
\ No newline at end of file diff --git a/src/main/resources/io/jenkins/plugins/sshbuildagents/ssh/mina/SSHApacheMinaLauncher/help-workDir.html b/src/main/resources/io/jenkins/plugins/sshbuildagents/ssh/mina/SSHApacheMinaLauncher/help-workDir.html new file mode 100644 index 00000000..901af96b --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/sshbuildagents/ssh/mina/SSHApacheMinaLauncher/help-workDir.html @@ -0,0 +1,6 @@ +
+ The Remoting work directory is an internal data storage, which may be used by Remoting to store caches, logs and other metadata. + For more details see Remoting Work directory + If remoting parameter "-workDir PATH" or "-jar-cache PATH" is set in Suffix Start Agent Command this field will be ignored. + If empty, the Remote root directory is used as Remoting Work directory +
\ No newline at end of file diff --git a/src/test/java/io/jenkins/plugins/sshbuildagents/FakeURI.java b/src/test/java/io/jenkins/plugins/sshbuildagents/FakeURI.java new file mode 100644 index 00000000..25446d1c --- /dev/null +++ b/src/test/java/io/jenkins/plugins/sshbuildagents/FakeURI.java @@ -0,0 +1,32 @@ +/* + * Copyright The Original Author or Authors + * SPDX-License-Identifier: MIT + */ + +package io.jenkins.plugins.sshbuildagents; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.URISyntaxException; +import org.apache.sshd.common.util.io.resource.URIResource; + +/** + * A fake URI resource that simulates a URI with a key as its content. This is used for testing + * purposes to provide a simple way to create a resource that contains a key string. + * + */ +public class FakeURI extends URIResource { + private final String key; + + public FakeURI(String key) throws URISyntaxException { + super(new URI("fake://key")); + this.key = key; + } + + @Override + public InputStream openInputStream() throws IOException { + return new ByteArrayInputStream(this.key.getBytes("UTF-8")); + } +} diff --git a/src/test/java/io/jenkins/plugins/sshbuildagents/ssh/ConnectionImplTest.java b/src/test/java/io/jenkins/plugins/sshbuildagents/ssh/ConnectionImplTest.java new file mode 100644 index 00000000..6a679a2e --- /dev/null +++ b/src/test/java/io/jenkins/plugins/sshbuildagents/ssh/ConnectionImplTest.java @@ -0,0 +1,231 @@ +/* + * Copyright The Original Author or Authors + * SPDX-License-Identifier: MIT + */ +package io.jenkins.plugins.sshbuildagents.ssh; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.cloudbees.plugins.credentials.CredentialsScope; +import com.cloudbees.plugins.credentials.common.StandardUsernameCredentials; +import com.cloudbees.plugins.credentials.impl.UsernamePasswordCredentialsImpl; +import hudson.model.Descriptor.FormException; +import io.jenkins.plugins.sshbuildagents.ssh.agents.AgentConnectionBaseTest; +import io.jenkins.plugins.sshbuildagents.ssh.mina.ConnectionImpl; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.PipedInputStream; +import java.io.PipedOutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import java.util.EnumSet; +import java.util.concurrent.TimeUnit; +import java.util.logging.Level; +import java.util.logging.Logger; +import org.apache.commons.io.IOUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.sshd.client.channel.ChannelShell; +import org.apache.sshd.client.channel.ClientChannel; +import org.apache.sshd.client.channel.ClientChannelEvent; +import org.apache.sshd.client.session.ClientSession; +import org.apache.sshd.common.util.io.output.NoCloseOutputStream; +import org.apache.sshd.scp.server.ScpCommandFactory; +import org.apache.sshd.server.SshServer; +import org.apache.sshd.server.auth.pubkey.AcceptAllPublickeyAuthenticator; +import org.apache.sshd.server.keyprovider.SimpleGeneratorHostKeyProvider; +import org.apache.sshd.server.shell.InteractiveProcessShellFactory; +import org.apache.sshd.server.shell.ProcessShellCommandFactory; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.jvnet.hudson.test.JenkinsRule; +import org.jvnet.hudson.test.junit.jupiter.WithJenkins; + +// Because we use the SSHAuthenticator class, we need to have a Jenkins instance running if not the extension list is +// empty and there is no factories to process the authentication. +// TODO think about remove this class and make only tests of the whole launcher. +@WithJenkins +public class ConnectionImplTest { + private SshServer sshd; + + @TempDir + public Path tempFolder; + + protected JenkinsRule j; + + @BeforeEach + void beforeEach(JenkinsRule j) { + this.j = j; + this.j.timeout = 0; + } + + @BeforeEach + public void setup() throws IOException { + Logger.getLogger("org.apache.sshd").setLevel(Level.FINE); + Logger.getLogger("io.jenkins.plugins.sshbuildagents").setLevel(Level.FINE); + sshd = SshServer.setUpDefaultServer(); + sshd.setHost("127.0.0.1"); + ScpCommandFactory.Builder cmdFactoryBuilder = new ScpCommandFactory.Builder(); + sshd.setCommandFactory(cmdFactoryBuilder + .withDelegate(ProcessShellCommandFactory.INSTANCE) + .build()); + sshd.setShellFactory(InteractiveProcessShellFactory.INSTANCE); + sshd.setPasswordAuthenticator((username, password, session) -> + AgentConnectionBaseTest.USER.equals(username) && AgentConnectionBaseTest.PASSWORD.equals(password)); + sshd.setPublickeyAuthenticator(AcceptAllPublickeyAuthenticator.INSTANCE); + sshd.setPublickeyAuthenticator(AcceptAllPublickeyAuthenticator.INSTANCE); + sshd.setKeyPairProvider(new SimpleGeneratorHostKeyProvider()); + + sshd.start(); + } + + @AfterEach + public void tearDown() throws IOException { + sshd.stop(); + } + + @Test + public void testRunCommandUserPassword() throws Exception, FormException { + Connection connection = new ConnectionImpl(sshd.getHost(), sshd.getPort()); + StandardUsernameCredentials credentials = new UsernamePasswordCredentialsImpl( + CredentialsScope.SYSTEM, "id", "", AgentConnectionBaseTest.USER, AgentConnectionBaseTest.PASSWORD); + connection.setCredentials(credentials); + int ret = connection.execCommand("echo FOO"); + connection.close(); + assertEquals(ret, 0); + } + + @Test + public void testRunCommandSSHKey() throws Exception { + Connection connection = new ConnectionImpl(sshd.getHost(), sshd.getPort()); + StandardUsernameCredentials credentials = new FakeSSHKeyCredential(); + connection.setCredentials(credentials); + int ret = connection.execCommand("echo FOO"); + connection.close(); + assertEquals(ret, 0); + } + + @Test + public void testCopyFile() throws Exception, FormException { + final File tempFile = + Files.createFile(tempFolder.resolve("tempFile.txt")).toFile(); + try (Connection connection = new ConnectionImpl(sshd.getHost(), sshd.getPort())) { + StandardUsernameCredentials credentials = new UsernamePasswordCredentialsImpl( + CredentialsScope.SYSTEM, "id", "", AgentConnectionBaseTest.USER, AgentConnectionBaseTest.PASSWORD); + connection.setCredentials(credentials); + String data = "Test data"; + connection.copyFile(tempFile.getAbsolutePath(), data.getBytes(StandardCharsets.UTF_8), true, true); + String dataUpload = Files.readString(tempFile.toPath()); + assertEquals(data, dataUpload); + } + } + + @Test + public void testShellChannel() throws Exception, FormException { + Logger logger = Logger.getLogger("io.jenkins.plugins.sshbuildagents.ssh.agents"); + try (Connection connection = new ConnectionImpl(sshd.getHost(), sshd.getPort())) { + StandardUsernameCredentials credentials = new UsernamePasswordCredentialsImpl( + CredentialsScope.SYSTEM, "id", "", AgentConnectionBaseTest.USER, AgentConnectionBaseTest.PASSWORD); + connection.setCredentials(credentials); + ShellChannel shellChannel = connection.shellChannel(); + shellChannel.execCommand("echo FOO"); + byte[] data = IOUtils.readFully( + shellChannel.getInvertedStdout(), + shellChannel.getInvertedStdout().available()); + String dataStr = IOUtils.toString(data, "UTF-8"); + logger.info(dataStr); + assertEquals("FOO", StringUtils.chomp(dataStr)); + } + } + + @Test + @Disabled("Test is too long and should be run manually") + public void testRunLongConnection() throws Exception, InterruptedException { + try (Connection connection = new ConnectionImpl(sshd.getHost(), sshd.getPort())) { + StandardUsernameCredentials credentials = new FakeSSHKeyCredential(); + connection.setCredentials(credentials); + ShellChannel shellChannel = connection.shellChannel(); + shellChannel.execCommand("sleep 500s"); + for (int i = 0; i < 300; i++) { + Thread.sleep(1000); + assertTrue(connection.isOpen()); + } + } + } + + @Test + public void testShellChannel2() throws Exception, FormException { + Logger logger = Logger.getLogger("io.jenkins.plugins.sshbuildagents.ssh.agents"); + try (Connection connection = new ConnectionImpl(sshd.getHost(), sshd.getPort())) { + StandardUsernameCredentials credentials = new UsernamePasswordCredentialsImpl( + CredentialsScope.SYSTEM, "id", "", AgentConnectionBaseTest.USER, AgentConnectionBaseTest.PASSWORD); + connection.setCredentials(credentials); + try (ClientSession session = connection.connect(); + PipedOutputStream pipedIn = new PipedOutputStream(); + InputStream inPipe = new PipedInputStream(pipedIn); + ByteArrayOutputStream out = new ByteArrayOutputStream()) { + try (ChannelShell channel = session.createShellChannel()) { + channel.setOut(new NoCloseOutputStream(out)); + channel.setErr(new NoCloseOutputStream(out)); + channel.setIn(inPipe); + channel.open().verify(5L, TimeUnit.SECONDS); + pipedIn.write(("echo BAR\n").getBytes(StandardCharsets.UTF_8)); + pipedIn.flush(); + channel.waitFor(Collections.singleton(ClientChannelEvent.CLOSED), 10000); + logger.info(out.toString("UTF-8")); + } + } + } + } + + // FIXME review this test https://github.com/jenkinsci/ssh-agents-plugin/pull/570#discussion_r2194833821 + @Test + public void testClient() throws Exception { + Logger logger = Logger.getLogger("io.jenkins.plugins.sshbuildagents.ssh.agents"); + try (Connection connection = new ConnectionImpl(sshd.getHost(), sshd.getPort())) { + StandardUsernameCredentials credentials = new UsernamePasswordCredentialsImpl( + CredentialsScope.SYSTEM, "id", "", AgentConnectionBaseTest.USER, AgentConnectionBaseTest.PASSWORD); + connection.setCredentials(credentials); + try (ClientSession session = connection.connect(); + ClientChannel channel = session.createShellChannel(); + ByteArrayOutputStream sent = new ByteArrayOutputStream(); + PipedOutputStream pipedIn = new PipedOutputStream(); + PipedInputStream pipedOut = new PipedInputStream(pipedIn); + ByteArrayOutputStream out = new ByteArrayOutputStream()) { + + channel.setIn(pipedOut); + channel.setOut(out); + channel.setErr(out); + channel.open(); + + pipedIn.write("touch /tmp/FOO\n".getBytes(StandardCharsets.UTF_8)); + pipedIn.flush(); + + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 1000; i++) { + sb.append("echo FOO\n"); + } + sb.append('\n'); + pipedIn.write(sb.toString().getBytes(StandardCharsets.UTF_8)); + + pipedIn.write("exit\n".getBytes(StandardCharsets.UTF_8)); + pipedIn.flush(); + logger.info(out.toString()); + channel.waitFor(EnumSet.of(ClientChannelEvent.CLOSED), 10000); + + channel.close(false); + connection.close(); + + } finally { + connection.close(); + } + } + } +} diff --git a/src/test/java/io/jenkins/plugins/sshbuildagents/ssh/FakeSSHKeyCredential.java b/src/test/java/io/jenkins/plugins/sshbuildagents/ssh/FakeSSHKeyCredential.java new file mode 100644 index 00000000..f7607482 --- /dev/null +++ b/src/test/java/io/jenkins/plugins/sshbuildagents/ssh/FakeSSHKeyCredential.java @@ -0,0 +1,53 @@ +/* + * Copyright The Original Author or Authors + * SPDX-License-Identifier: MIT + */ +package io.jenkins.plugins.sshbuildagents.ssh; + +import static io.jenkins.plugins.sshbuildagents.ssh.agents.AgentConnectionBaseTest.AGENTS_RESOURCES_PATH; + +import com.cloudbees.jenkins.plugins.sshcredentials.SSHUserPrivateKey; +import com.cloudbees.jenkins.plugins.sshcredentials.impl.BaseSSHUser; +import com.cloudbees.plugins.credentials.CredentialsScope; +import edu.umd.cs.findbugs.annotations.NonNull; +import hudson.util.Secret; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import org.apache.commons.io.IOUtils; + +public class FakeSSHKeyCredential extends BaseSSHUser implements SSHUserPrivateKey { + public static final String SSH_AGENT_NAME = "ssh-agent-rsa512"; + public static final String SSH_KEY_PATH = "ssh/rsa-512-key"; + public static final String SSH_KEY_PUB_PATH = "ssh/rsa-512-key.pub"; + + public static final String ID = "id"; + public static final String USERNAME = "jenkins"; + private final List keys = new ArrayList<>(); + + public FakeSSHKeyCredential() throws IOException { + super(CredentialsScope.SYSTEM, ID, USERNAME, "Fake credentials."); + String privateKey = IOUtils.toString( + getClass().getResourceAsStream(AGENTS_RESOURCES_PATH + "/" + SSH_AGENT_NAME + "/" + SSH_KEY_PATH), + StandardCharsets.UTF_8); + keys.add(privateKey); + } + + @NonNull + @Override + public String getPrivateKey() { + return keys.get(0); + } + + @Override + public Secret getPassphrase() { + return Secret.fromString(""); + } + + @NonNull + @Override + public List getPrivateKeys() { + return keys; + } +} diff --git a/src/test/java/io/jenkins/plugins/sshbuildagents/ssh/agents/AgentConnectionBaseTest.java b/src/test/java/io/jenkins/plugins/sshbuildagents/ssh/agents/AgentConnectionBaseTest.java new file mode 100644 index 00000000..02b13ee1 --- /dev/null +++ b/src/test/java/io/jenkins/plugins/sshbuildagents/ssh/agents/AgentConnectionBaseTest.java @@ -0,0 +1,166 @@ +/* + * Copyright The Original Author or Authors + * SPDX-License-Identifier: MIT + */ +package io.jenkins.plugins.sshbuildagents.ssh.agents; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.cloudbees.jenkins.plugins.sshcredentials.impl.BasicSSHUserPrivateKey; +import com.cloudbees.plugins.credentials.CredentialsScope; +import com.cloudbees.plugins.credentials.SystemCredentialsProvider; +import com.cloudbees.plugins.credentials.common.StandardUsernameCredentials; +import com.cloudbees.plugins.credentials.domains.Domain; +import com.cloudbees.plugins.credentials.impl.UsernamePasswordCredentialsImpl; +import hudson.model.Descriptor; +import hudson.model.Descriptor.FormException; +import hudson.model.Node; +import hudson.plugins.sshslaves.verifiers.NonVerifyingKeyVerificationStrategy; +import hudson.slaves.DumbSlave; +import io.jenkins.plugins.sshbuildagents.ssh.mina.SSHApacheMinaLauncher; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.concurrent.TimeUnit; +import org.apache.commons.io.IOUtils; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.api.condition.DisabledOnOs; +import org.junit.jupiter.api.condition.OS; +import org.jvnet.hudson.test.JenkinsRule; +import org.jvnet.hudson.test.junit.jupiter.WithJenkins; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.images.builder.ImageFromDockerfile; +import org.testcontainers.junit.jupiter.Testcontainers; + +/** + * Base class to test connections to a remote SSH Agent + * + */ +@Timeout(value = 10, unit = TimeUnit.MINUTES) +@WithJenkins +@Testcontainers(disabledWithoutDocker = true) +@DisabledOnOs(OS.WINDOWS) +public abstract class AgentConnectionBaseTest { + + public static final String USER = "jenkins"; + public static final String PASSWORD = "password"; + public static final String AGENT_WORK_DIR = "/home/jenkins"; + public static final int SSH_PORT = 22; + public static final String SSH_SSHD_CONFIG = "ssh/sshd_config"; + public static final String DOCKERFILE = "Dockerfile"; + public static final String SSH_AUTHORIZED_KEYS = "ssh/authorized_keys"; + public static final String AGENTS_RESOURCES_PATH = "/io/jenkins/plugins/sshbuildagents/ssh/agents/"; + public static final String LOGGING_PROPERTIES = "remoting_logger.properties"; + + protected JenkinsRule j; + + @BeforeEach + void beforeEach(JenkinsRule j) { + this.j = j; + this.j.timeout = 0; + } + + @Test + void connectionTests() throws IOException, InterruptedException, Descriptor.FormException { + Node node = createPermanentAgent( + getAgentName(), + getAgentContainer().getHost(), + getAgentContainer().getMappedPort(SSH_PORT), + getAgentSshKeyPath(), + getAgentSshKeyPassphrase()); + waitForAgentConnected(node); + assertTrue(isSuccessfullyConnected(node)); + } + + protected abstract String getAgentName(); + + protected abstract GenericContainer getAgentContainer(); + + protected String getAgentSshKeyPath() { + return null; + } + + protected String getAgentSshKeyPassphrase() { + return ""; + } + + protected static boolean isSuccessfullyConnected(Node node) throws IOException, InterruptedException { + boolean ret = false; + int count = 0; + while (count < 30 && !ret) { + Thread.sleep(1000); + String log = node.toComputer().getLog(); + ret = log.contains("Agent successfully connected and online"); + count++; + } + return ret; + } + + protected void waitForAgentConnected(Node node) throws InterruptedException { + int count = 0; + while (!node.toComputer().isOnline() && count < 150) { + Thread.sleep(1000); + count++; + } + assertTrue(node.toComputer().isOnline()); + } + + protected Node createPermanentAgent( + String name, String host, int sshPort, String keyResourcePath, String passphrase) + throws Descriptor.FormException, IOException { + String credId = "sshCredentialsId"; + + if (keyResourcePath != null) { + createSshKeyCredentials(credId, keyResourcePath, passphrase); + } else { + createSshCredentials(credId); + } + + final SSHApacheMinaLauncher launcher = new SSHApacheMinaLauncher(host, sshPort, credId); + initLauncher(launcher); + DumbSlave agent = new DumbSlave(name, AGENT_WORK_DIR, launcher); + j.jenkins.addNode(agent); + return j.jenkins.getNode(agent.getNodeName()); + } + + private void initLauncher(SSHApacheMinaLauncher launcher) { + launcher.setSshHostKeyVerificationStrategy(new NonVerifyingKeyVerificationStrategy()); + launcher.setJvmOptions(" -Dhudson.remoting.Launcher.pingIntervalSec=-1 " + + "-Dhudson.slaves.ChannelPinger.pingIntervalSeconds=-1 " + + "-Djava.awt.headless=true "); + launcher.setSuffixStartAgentCmd(" -loggingConfig /home/jenkins/.ssh/remoting_logger.properties "); + } + + private void createSshKeyCredentials(String id, String keyResourcePath, String passphrase) throws IOException { + String privateKey = IOUtils.toString(getClass().getResourceAsStream(keyResourcePath), StandardCharsets.UTF_8); + BasicSSHUserPrivateKey.DirectEntryPrivateKeySource privateKeySource = + new BasicSSHUserPrivateKey.DirectEntryPrivateKeySource(privateKey); + BasicSSHUserPrivateKey credentials = new BasicSSHUserPrivateKey( + CredentialsScope.SYSTEM, id, USER, privateKeySource, passphrase, "Private Key ssh credentials"); + SystemCredentialsProvider.getInstance() + .getDomainCredentialsMap() + .put(Domain.global(), Collections.singletonList(credentials)); + } + + private static void createSshCredentials(String id) throws FormException { + StandardUsernameCredentials credentials = + new UsernamePasswordCredentialsImpl(CredentialsScope.SYSTEM, id, "", USER, PASSWORD); + SystemCredentialsProvider.getInstance() + .getDomainCredentialsMap() + .put(Domain.global(), Collections.singletonList(credentials)); + } + + public static ImageFromDockerfile newImageFromDockerfile( + String agentName, String sshKeyPath, String sshKeyPubPath) { + return new ImageFromDockerfile(agentName, false) + .withFileFromClasspath( + SSH_AUTHORIZED_KEYS, AGENTS_RESOURCES_PATH + "/" + agentName + "/" + SSH_AUTHORIZED_KEYS) + .withFileFromClasspath(sshKeyPath, AGENTS_RESOURCES_PATH + "/" + agentName + "/" + sshKeyPath) + .withFileFromClasspath(sshKeyPubPath, AGENTS_RESOURCES_PATH + "/" + agentName + "/" + sshKeyPubPath) + .withFileFromClasspath(SSH_SSHD_CONFIG, AGENTS_RESOURCES_PATH + "/" + agentName + "/" + SSH_SSHD_CONFIG) + .withFileFromClasspath(DOCKERFILE, AGENTS_RESOURCES_PATH + "/" + agentName + "/" + DOCKERFILE) + .withFileFromClasspath("ssh/" + LOGGING_PROPERTIES, "/" + LOGGING_PROPERTIES); + } +} diff --git a/src/test/java/io/jenkins/plugins/sshbuildagents/ssh/agents/AgentRSA512ConnectionTest.java b/src/test/java/io/jenkins/plugins/sshbuildagents/ssh/agents/AgentRSA512ConnectionTest.java new file mode 100644 index 00000000..1f578a5e --- /dev/null +++ b/src/test/java/io/jenkins/plugins/sshbuildagents/ssh/agents/AgentRSA512ConnectionTest.java @@ -0,0 +1,46 @@ +/* + * Copyright The Original Author or Authors + * SPDX-License-Identifier: MIT + */ +package io.jenkins.plugins.sshbuildagents.ssh.agents; + +import static hudson.plugins.sshslaves.tags.TestTags.AGENT_SSH_TEST; +import static hudson.plugins.sshslaves.tags.TestTags.SSH_KEX_TEST; + +import org.junit.jupiter.api.Tag; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Container; + +/** + * Connect to a remote SSH Agent + * + */ +@Tag(AGENT_SSH_TEST) +@Tag(SSH_KEX_TEST) +// FIXME verify log output some messages from the verifier are printed in the Jenkins Controller log +public class AgentRSA512ConnectionTest extends AgentConnectionBaseTest { + public static final String SSH_AGENT_NAME = "ssh-agent-rsa512"; + public static final String SSH_KEY_PATH = "ssh/rsa-512-key"; + public static final String SSH_KEY_PUB_PATH = "ssh/rsa-512-key.pub"; + + @SuppressWarnings("resource") + @Container + private static final GenericContainer agentContainer = new GenericContainer<>( + newImageFromDockerfile(SSH_AGENT_NAME, SSH_KEY_PATH, SSH_KEY_PUB_PATH)) + .withExposedPorts(SSH_PORT); + + @Override + protected String getAgentName() { + return SSH_AGENT_NAME; + } + + @Override + protected GenericContainer getAgentContainer() { + return agentContainer; + } + + @Override + protected String getAgentSshKeyPath() { + return SSH_AGENT_NAME + "/" + SSH_KEY_PATH; + } +} diff --git a/src/test/java/io/jenkins/plugins/sshbuildagents/ssh/agents/AgentUserAndPasswordConnectionTest.java b/src/test/java/io/jenkins/plugins/sshbuildagents/ssh/agents/AgentUserAndPasswordConnectionTest.java new file mode 100644 index 00000000..d1d1085d --- /dev/null +++ b/src/test/java/io/jenkins/plugins/sshbuildagents/ssh/agents/AgentUserAndPasswordConnectionTest.java @@ -0,0 +1,38 @@ +/* + * Copyright The Original Author or Authors + * SPDX-License-Identifier: MIT + */ +package io.jenkins.plugins.sshbuildagents.ssh.agents; + +import static hudson.plugins.sshslaves.tags.TestTags.AGENT_SSH_TEST; + +import org.junit.jupiter.api.Tag; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Container; + +/** + * Connect to a remote SSH Agent + * + */ +@Tag(AGENT_SSH_TEST) +public class AgentUserAndPasswordConnectionTest extends AgentConnectionBaseTest { + public static final String SSH_AGENT_NAME = "ssh-agent-rsa512"; + public static final String SSH_KEY_PATH = "ssh/rsa-512-key"; + public static final String SSH_KEY_PUB_PATH = "ssh/rsa-512-key.pub"; + + @SuppressWarnings("resource") + @Container + private static final GenericContainer agentContainer = new GenericContainer<>( + newImageFromDockerfile(SSH_AGENT_NAME, SSH_KEY_PATH, SSH_KEY_PUB_PATH)) + .withExposedPorts(SSH_PORT); + + @Override + protected String getAgentName() { + return SSH_AGENT_NAME; + } + + @Override + protected GenericContainer getAgentContainer() { + return agentContainer; + } +} diff --git a/src/test/java/io/jenkins/plugins/sshbuildagents/ssh/agents/ClientRSA512ConnectionTest.java b/src/test/java/io/jenkins/plugins/sshbuildagents/ssh/agents/ClientRSA512ConnectionTest.java new file mode 100644 index 00000000..8c7a9406 --- /dev/null +++ b/src/test/java/io/jenkins/plugins/sshbuildagents/ssh/agents/ClientRSA512ConnectionTest.java @@ -0,0 +1,176 @@ +/* + * Copyright The Original Author or Authors + * SPDX-License-Identifier: MIT + */ +package io.jenkins.plugins.sshbuildagents.ssh.agents; + +import static hudson.plugins.sshslaves.tags.TestTags.AGENT_SSH_TEST; +import static hudson.plugins.sshslaves.tags.TestTags.SSH_KEX_TEST; +import static io.jenkins.plugins.sshbuildagents.ssh.agents.AgentConnectionBaseTest.SSH_PORT; +import static io.jenkins.plugins.sshbuildagents.ssh.mina.ConnectionImpl.HEARTBEAT_INTERVAL_SECONDS; +import static io.jenkins.plugins.sshbuildagents.ssh.mina.ConnectionImpl.HEARTBEAT_MAX_RETRY; +import static io.jenkins.plugins.sshbuildagents.ssh.mina.ConnectionImpl.IDLE_SESSION_TIMEOUT_MINUTES; +import static io.jenkins.plugins.sshbuildagents.ssh.mina.ConnectionImpl.WINDOW_SIZE; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.cloudbees.plugins.credentials.common.StandardUsernameCredentials; +import io.jenkins.plugins.sshbuildagents.ssh.Connection; +import io.jenkins.plugins.sshbuildagents.ssh.FakeSSHKeyCredential; +import io.jenkins.plugins.sshbuildagents.ssh.ShellChannel; +import io.jenkins.plugins.sshbuildagents.ssh.mina.ConnectionImpl; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Collections; +import java.util.concurrent.TimeUnit; +import java.util.logging.Level; +import java.util.logging.Logger; +import org.apache.sshd.client.SshClient; +import org.apache.sshd.client.channel.ChannelExec; +import org.apache.sshd.client.channel.ClientChannelEvent; +import org.apache.sshd.client.future.AuthFuture; +import org.apache.sshd.client.future.ConnectFuture; +import org.apache.sshd.client.session.ClientSession; +import org.apache.sshd.common.util.io.input.NullInputStream; +import org.apache.sshd.core.CoreModuleProperties; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.api.condition.DisabledOnOs; +import org.junit.jupiter.api.condition.OS; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +/** + * Connect to a remote SSH Server with a plain Apache Mina SSHD client. + * + */ +@Tag(AGENT_SSH_TEST) +@Tag(SSH_KEX_TEST) +@Testcontainers(disabledWithoutDocker = true) +@DisabledOnOs(OS.WINDOWS) +public class ClientRSA512ConnectionTest { + public static final String SSH_AGENT_NAME = "ssh-agent-rsa512"; + public static final String SSH_KEY_PATH = "ssh/rsa-512-key"; + public static final String SSH_KEY_PUB_PATH = "ssh/rsa-512-key.pub"; + public static final String USER = "jenkins"; + public static final String PASSWORD = "password"; + public static final long timeout = 30000L; + + @SuppressWarnings("resource") + @Container + private static final GenericContainer agentContainer = new GenericContainer<>( + AgentConnectionBaseTest.newImageFromDockerfile(SSH_AGENT_NAME, SSH_KEY_PATH, SSH_KEY_PUB_PATH)) + .withExposedPorts(SSH_PORT); + + @BeforeAll + public static void setup() throws IOException { + Logger.getLogger("org.apache.sshd").setLevel(Level.FINE); + Logger.getLogger("io.jenkins.plugins.sshbuildagents").setLevel(Level.FINE); + Logger.getLogger("org.apache.sshd.common.io.nio2").setLevel(Level.FINE); + } + + @Test + @Timeout(value = 5, unit = TimeUnit.MINUTES) + public void connectionExecCommandTests() throws IOException, InterruptedException { + Logger logger = Logger.getLogger("io.jenkins.plugins.sshbuildagents.ssh.agents"); + agentContainer.start(); + assertTrue(agentContainer.isRunning()); + int port = agentContainer.getMappedPort(SSH_PORT); + String host = agentContainer.getHost(); + try (SshClient client = getSshClient(); + ByteArrayOutputStream baOut = new ByteArrayOutputStream()) { + client.start(); + try (ClientSession session = getClientSession(client, USER, host, port, timeout, PASSWORD)) { + session.executeRemoteCommand("sleep 30s", baOut, baOut, StandardCharsets.UTF_8); + for (int i = 0; i < 30; i++) { + Thread.sleep(1000); + logger.info(baOut.toString()); + assertTrue(session.isOpen()); + } + } + } + assertTrue(true); + } + + @Test + @Timeout(value = 5, unit = TimeUnit.MINUTES) + public void connectionChannelTests() throws IOException, InterruptedException { + Logger logger = Logger.getLogger("io.jenkins.plugins.sshbuildagents.ssh.agents"); + agentContainer.start(); + assertTrue(agentContainer.isRunning()); + int port = agentContainer.getMappedPort(SSH_PORT); + String host = agentContainer.getHost(); + + try (SshClient client = getSshClient()) { + client.start(); + try (ClientSession session = getClientSession(client, USER, host, port, timeout, PASSWORD)) { + try (ChannelExec channel = session.createExecChannel("sleep 30\n"); + ByteArrayOutputStream baOut = new ByteArrayOutputStream(); + NullInputStream nullIn = new NullInputStream()) { + channel.setOut(baOut); + channel.setIn(nullIn); + channel.open().verify(timeout, TimeUnit.MILLISECONDS); + channel.waitFor(Collections.singleton(ClientChannelEvent.CLOSED), timeout); + for (int i = 0; i < 30; i++) { + Thread.sleep(1000); + logger.info(baOut.toString()); + assertTrue(session.isOpen()); + } + } + } + } + assertTrue(true); + } + + @Test + @Timeout(value = 15, unit = TimeUnit.MINUTES) + @Disabled("Test is too long and should be run manually") + public void testRunLongConnection() throws Exception, InterruptedException { + agentContainer.start(); + assertTrue(agentContainer.isRunning()); + int port = agentContainer.getMappedPort(SSH_PORT); + String host = agentContainer.getHost(); + try (Connection connection = new ConnectionImpl(host, port)) { + StandardUsernameCredentials credentials = new FakeSSHKeyCredential(); + connection.setCredentials(credentials); + try (ShellChannel shellChannel = connection.shellChannel()) { + shellChannel.execCommand("sleep 300s"); + for (int i = 0; i < 300; i++) { + Thread.sleep(1000); + assertTrue(connection.isOpen()); + } + } + } + assertTrue(true); + } + + private ClientSession getClientSession( + SshClient client, String user, String host, int port, long timeout, String password) throws IOException { + ConnectFuture connectionFuture = client.connect(user, host, port); + connectionFuture.verify(timeout); + ClientSession session = connectionFuture.getSession(); + session.addPasswordIdentity(password); + AuthFuture auth = session.auth(); + auth.verify(timeout); + return session; + } + + // https://github.com/apache/mina-sshd/issues/460 + private SshClient getSshClient() { + SshClient client = SshClient.setUpDefaultClient(); + client = SshClient.setUpDefaultClient(); + + CoreModuleProperties.WINDOW_SIZE.set(client, WINDOW_SIZE); + CoreModuleProperties.TCP_NODELAY.set(client, true); + CoreModuleProperties.HEARTBEAT_REQUEST.set(client, "keepalive@jenkins.io"); + CoreModuleProperties.HEARTBEAT_INTERVAL.set(client, Duration.ofSeconds(HEARTBEAT_INTERVAL_SECONDS)); + CoreModuleProperties.HEARTBEAT_NO_REPLY_MAX.set(client, HEARTBEAT_MAX_RETRY); + CoreModuleProperties.IDLE_TIMEOUT.set(client, Duration.ofMinutes(IDLE_SESSION_TIMEOUT_MINUTES)); + return client; + } +} diff --git a/src/test/resources/remoting_logger.properties b/src/test/resources/remoting_logger.properties new file mode 100644 index 00000000..11b44f0e --- /dev/null +++ b/src/test/resources/remoting_logger.properties @@ -0,0 +1,15 @@ +handlers= java.util.logging.ConsoleHandler, java.util.logging.FileHandler +.level= INFO +java.util.logging.ConsoleHandler.level = INFO +java.util.logging.ConsoleHandler.formatter = java.util.logging.SimpleFormatter + +java.util.logging.FileHandler.append = false +java.util.logging.FileHandler.formatter = java.util.logging.SimpleFormatter +#the logs files are in the system temporal folder +java.util.logging.FileHandler.pattern = %t/ssh-build-agents-test-log.%u.%g.log + +hudson.remoting.level = FINE +hudson.slaves.level = FINE +io.jenkins.plugins.sshbuildagents.level = FINE +org.apache.sshd.common.io.nio2.level = FINE +org.apache.sshd.level = FINE