diff --git a/pom.xml b/pom.xml index 83af1c6b..5accc69f 100644 --- a/pom.xml +++ b/pom.xml @@ -105,6 +105,26 @@ io.jenkins.plugins commons-lang3-api + + 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 + + + io.jenkins.plugins.mina-sshd-api + mina-sshd-api-sftp + + + org.jenkins-ci.plugins + bouncycastle-api + org.jenkins-ci.plugins credentials @@ -127,6 +147,12 @@ test-harness test + + org.awaitility + awaitility + 4.2.1 + test + org.jenkins-ci.modules sshd diff --git a/src/main/java/hudson/plugins/sshslaves/PluginImpl.java b/src/main/java/hudson/plugins/sshslaves/PluginImpl.java index 6581c875..5473bc96 100644 --- a/src/main/java/hudson/plugins/sshslaves/PluginImpl.java +++ b/src/main/java/hudson/plugins/sshslaves/PluginImpl.java @@ -34,7 +34,9 @@ * Entry point of the plugin. * * @author Stephen Connolly + * @deprecated Trilead SSH connection management. Use {@link hudson.plugins.sshslaves.mina.MinaSSHLauncher} instead. */ +@Deprecated public class PluginImpl extends Plugin { /** diff --git a/src/main/java/hudson/plugins/sshslaves/SSHConnector.java b/src/main/java/hudson/plugins/sshslaves/SSHConnector.java index 02832790..3c22eea3 100644 --- a/src/main/java/hudson/plugins/sshslaves/SSHConnector.java +++ b/src/main/java/hudson/plugins/sshslaves/SSHConnector.java @@ -63,7 +63,9 @@ * connector and launcher share them. * * @author Kohsuke Kawaguchi + * @deprecated Use {@link hudson.plugins.sshslaves.mina.MinaSSHLauncher} instead. */ +@Deprecated public class SSHConnector extends ComputerConnector { /** * Field port diff --git a/src/main/java/hudson/plugins/sshslaves/SSHLauncher.java b/src/main/java/hudson/plugins/sshslaves/SSHLauncher.java index b532e059..e54acd3d 100644 --- a/src/main/java/hudson/plugins/sshslaves/SSHLauncher.java +++ b/src/main/java/hudson/plugins/sshslaves/SSHLauncher.java @@ -105,7 +105,10 @@ /** * A computer launcher that tries to start a linux agent by opening an SSH connection and trying to find java. + * + * @deprecated Use {@link hudson.plugins.sshslaves.mina.MinaSSHLauncher} instead. */ +@Deprecated public class SSHLauncher extends ComputerLauncher { /** diff --git a/src/main/java/hudson/plugins/sshslaves/mina/BlindTrustVerificationStrategy.java b/src/main/java/hudson/plugins/sshslaves/mina/BlindTrustVerificationStrategy.java new file mode 100644 index 00000000..7c1a2226 --- /dev/null +++ b/src/main/java/hudson/plugins/sshslaves/mina/BlindTrustVerificationStrategy.java @@ -0,0 +1,60 @@ +/* + * The MIT License + * + * Copyright (c) 2004-, all the contributors + * + * 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. + */ +package hudson.plugins.sshslaves.mina; + +import edu.umd.cs.findbugs.annotations.NonNull; +import hudson.Extension; +import hudson.slaves.SlaveComputer; +import org.apache.sshd.client.keyverifier.AcceptAllServerKeyVerifier; +import org.apache.sshd.client.keyverifier.ServerKeyVerifier; +import org.jenkinsci.Symbol; +import org.kohsuke.stapler.DataBoundConstructor; + +/** + * Verification strategy that accepts all server host keys without verification. + * + *

This is the least secure option and should only be used in trusted environments. + */ +public class BlindTrustVerificationStrategy extends MinaServerKeyVerificationStrategy { + + @DataBoundConstructor + public BlindTrustVerificationStrategy() {} + + @Override + @NonNull + public ServerKeyVerifier createVerifier(SlaveComputer computer, String host) { + return AcceptAllServerKeyVerifier.INSTANCE; + } + + @Extension + @Symbol("minaBlindlyTrust") + public static class DescriptorImpl extends MinaServerKeyVerificationStrategyDescriptor { + + @Override + @NonNull + public String getDisplayName() { + return Messages.BlindTrustVerificationStrategy_DisplayName(); + } + } +} diff --git a/src/main/java/hudson/plugins/sshslaves/mina/KnownHostsVerificationStrategy.java b/src/main/java/hudson/plugins/sshslaves/mina/KnownHostsVerificationStrategy.java new file mode 100644 index 00000000..a43948cf --- /dev/null +++ b/src/main/java/hudson/plugins/sshslaves/mina/KnownHostsVerificationStrategy.java @@ -0,0 +1,67 @@ +/* + * The MIT License + * + * Copyright (c) 2004-, all the contributors + * + * 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. + */ +package hudson.plugins.sshslaves.mina; + +import edu.umd.cs.findbugs.annotations.NonNull; +import hudson.Extension; +import hudson.slaves.SlaveComputer; +import java.util.logging.Level; +import java.util.logging.Logger; +import org.apache.sshd.client.keyverifier.DefaultKnownHostsServerKeyVerifier; +import org.apache.sshd.client.keyverifier.RejectAllServerKeyVerifier; +import org.apache.sshd.client.keyverifier.ServerKeyVerifier; +import org.jenkinsci.Symbol; +import org.kohsuke.stapler.DataBoundConstructor; + +/** + * Verification strategy that validates server host keys against the user's ~/.ssh/known_hosts file. + * + *

Only hosts that are listed in the known_hosts file will be accepted. All others are rejected. + */ +public class KnownHostsVerificationStrategy extends MinaServerKeyVerificationStrategy { + + private static final Logger LOGGER = Logger.getLogger(KnownHostsVerificationStrategy.class.getName()); + + @DataBoundConstructor + public KnownHostsVerificationStrategy() {} + + @Override + @NonNull + public ServerKeyVerifier createVerifier(SlaveComputer computer, String host) { + ServerKeyVerifier verifier = new DefaultKnownHostsServerKeyVerifier(RejectAllServerKeyVerifier.INSTANCE); + LOGGER.log(Level.FINE, () -> "Created known_hosts verifier: " + verifier); + return verifier; + } + + @Extension + @Symbol("minaKnownHosts") + public static class DescriptorImpl extends MinaServerKeyVerificationStrategyDescriptor { + + @Override + @NonNull + public String getDisplayName() { + return Messages.KnownHostsVerificationStrategy_DisplayName(); + } + } +} diff --git a/src/main/java/hudson/plugins/sshslaves/mina/LoggingServerKeyVerifier.java b/src/main/java/hudson/plugins/sshslaves/mina/LoggingServerKeyVerifier.java new file mode 100644 index 00000000..b0e65aa0 --- /dev/null +++ b/src/main/java/hudson/plugins/sshslaves/mina/LoggingServerKeyVerifier.java @@ -0,0 +1,99 @@ +/* + * The MIT License + * + * Copyright (c) 2004-, all the contributors + * + * 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. + */ +package hudson.plugins.sshslaves.mina; + +import hudson.model.TaskListener; +import java.net.SocketAddress; +import java.security.PublicKey; +import java.security.interfaces.DSAPublicKey; +import java.security.interfaces.ECPublicKey; +import java.security.interfaces.RSAPublicKey; +import java.util.logging.Level; +import java.util.logging.Logger; +import jenkins.security.FIPS140; +import org.apache.sshd.client.keyverifier.ServerKeyVerifier; +import org.apache.sshd.client.session.ClientSession; +import org.apache.sshd.common.config.keys.KeyUtils; + +/** + * Decorator that wraps a {@link ServerKeyVerifier} to log key fingerprints and verification + * results to both the Jenkins task listener and Java logging. + */ +class LoggingServerKeyVerifier implements ServerKeyVerifier { + + private static final Logger LOGGER = Logger.getLogger(LoggingServerKeyVerifier.class.getName()); + + private final ServerKeyVerifier delegate; + private final TaskListener listener; + + LoggingServerKeyVerifier(ServerKeyVerifier delegate, TaskListener listener) { + this.delegate = delegate; + this.listener = listener; + } + + @Override + public boolean verifyServerKey(ClientSession clientSession, SocketAddress remoteAddress, PublicKey serverKey) { + String kind; + + if (serverKey instanceof ECPublicKey) { + kind = "ECDSA"; + } else if (serverKey instanceof RSAPublicKey) { + kind = "RSA"; + } else if (serverKey instanceof DSAPublicKey) { + kind = "DSA"; + } else { + if (FIPS140.useCompliantAlgorithms()) { + listener.getLogger().format("[SSH Mina] Error unknown server host key: %s%n", serverKey); + return false; + } + if (!"net.i2p.crypto.eddsa.EdDSAPublicKey" + .equals(serverKey.getClass().getName())) { + listener.getLogger().format("[SSH Mina] Warning unknown server host key type: %s%n", serverKey); + } + kind = serverKey.getAlgorithm(); + } + + LOGGER.log( + Level.FINE, + () -> "use kind " + kind + " for host " + clientSession.getRemoteAddress() + " publicKey: " + + serverKey); + + listener.getLogger().format("[SSH Mina] Verifying server host key...%n"); + listener.getLogger().format("[SSH Mina] %s key fingerprint is %s%n", kind, KeyUtils.getFingerPrint(serverKey)); + + boolean result = delegate.verifyServerKey(clientSession, remoteAddress, serverKey); + if (result) { + listener.getLogger().format("[SSH Mina] Server host key verified%n"); + } else { + listener.getLogger().format("[SSH Mina] Server host key rejected%n"); + } + + LOGGER.log( + Level.FINE, + () -> "verifier " + delegate.getClass().getName() + " return " + result + " for host " + + clientSession.getRemoteAddress() + " publicKey: " + serverKey); + + return result; + } +} diff --git a/src/main/java/hudson/plugins/sshslaves/mina/ManualKeyVerificationStrategy.java b/src/main/java/hudson/plugins/sshslaves/mina/ManualKeyVerificationStrategy.java new file mode 100644 index 00000000..53c3bb10 --- /dev/null +++ b/src/main/java/hudson/plugins/sshslaves/mina/ManualKeyVerificationStrategy.java @@ -0,0 +1,133 @@ +/* + * The MIT License + * + * Copyright (c) 2004-, all the contributors + * + * 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. + */ +package hudson.plugins.sshslaves.mina; + +import edu.umd.cs.findbugs.annotations.NonNull; +import hudson.Extension; +import hudson.slaves.SlaveComputer; +import hudson.util.FormValidation; +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.security.PublicKey; +import java.util.Collections; +import java.util.List; +import java.util.logging.Level; +import java.util.logging.Logger; +import org.apache.sshd.client.keyverifier.RejectAllServerKeyVerifier; +import org.apache.sshd.client.keyverifier.RequiredServerKeyVerifier; +import org.apache.sshd.client.keyverifier.ServerKeyVerifier; +import org.apache.sshd.common.config.keys.AuthorizedKeyEntry; +import org.apache.sshd.common.config.keys.PublicKeyEntryResolver; +import org.jenkinsci.Symbol; +import org.kohsuke.stapler.DataBoundConstructor; +import org.kohsuke.stapler.QueryParameter; + +/** + * Verification strategy that validates the server host key against a manually provided public key. + * + *

The key should be in authorized_keys format (e.g., "ssh-rsa AAAA... comment"). + * If the key type is known, it will be used to set preferred algorithms during negotiation. + */ +public class ManualKeyVerificationStrategy extends MinaServerKeyVerificationStrategy { + + private static final Logger LOGGER = Logger.getLogger(ManualKeyVerificationStrategy.class.getName()); + + private final String key; + private transient AuthorizedKeyEntry entry; + + @DataBoundConstructor + public ManualKeyVerificationStrategy(String key) { + this.key = key; + } + + public String getKey() { + return key; + } + + private AuthorizedKeyEntry getEntry() { + if (entry == null) { + entry = AuthorizedKeyEntry.parseAuthorizedKeyEntry(key); + } + return entry; + } + + @Override + @NonNull + public ServerKeyVerifier createVerifier(SlaveComputer computer, String host) { + AuthorizedKeyEntry keyEntry = getEntry(); + if (keyEntry != null) { + try { + PublicKey publicKey = keyEntry.resolvePublicKey(null, PublicKeyEntryResolver.IGNORING); + if (publicKey != null) { + return new RequiredServerKeyVerifier(publicKey); + } + LOGGER.log( + Level.FINE, + () -> "Could not resolve public key from the configured server key, all keys are rejected."); + } catch (IOException | GeneralSecurityException e) { + LOGGER.log(Level.FINE, "Error resolving the configured server key, all keys are rejected.", e); + } + } else { + LOGGER.log(Level.FINE, () -> "No server key configured, all keys are rejected."); + } + return RejectAllServerKeyVerifier.INSTANCE; + } + + @Override + @NonNull + public List getPreferredKeyAlgorithms() { + AuthorizedKeyEntry keyEntry = getEntry(); + if (keyEntry != null) { + String keyType = keyEntry.getKeyType(); + if (keyType != null && !keyType.isEmpty()) { + LOGGER.log(Level.FINE, () -> "Configured host key type: " + keyType); + return Collections.singletonList(keyType); + } + } + return Collections.emptyList(); + } + + @Extension + @Symbol("minaManualKey") + public static class DescriptorImpl extends MinaServerKeyVerificationStrategyDescriptor { + + @Override + @NonNull + public String getDisplayName() { + return Messages.ManualKeyVerificationStrategy_DisplayName(); + } + + @SuppressWarnings("unused") + public FormValidation doCheckKey(@QueryParameter String value) { + try { + if (AuthorizedKeyEntry.parseAuthorizedKeyEntry(value) == null) { + return FormValidation.error("No valid key recovered"); + } + } catch (IllegalArgumentException e) { + return FormValidation.error(e.getMessage()); + } + return FormValidation.ok(); + } + } +} diff --git a/src/main/java/hudson/plugins/sshslaves/mina/MinaSSHLauncher.java b/src/main/java/hudson/plugins/sshslaves/mina/MinaSSHLauncher.java new file mode 100644 index 00000000..b2994097 --- /dev/null +++ b/src/main/java/hudson/plugins/sshslaves/mina/MinaSSHLauncher.java @@ -0,0 +1,808 @@ +/* + * The MIT License + * + * Copyright (c) 2004-, all the contributors + * + * 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. + */ +package hudson.plugins.sshslaves.mina; + +import com.cloudbees.jenkins.plugins.sshcredentials.SSHAuthenticator; +import com.cloudbees.plugins.credentials.CredentialsMatchers; +import com.cloudbees.plugins.credentials.CredentialsNameProvider; +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.DomainRequirement; +import com.cloudbees.plugins.credentials.domains.HostnamePortRequirement; +import com.cloudbees.plugins.credentials.domains.SchemeRequirement; +import edu.umd.cs.findbugs.annotations.NonNull; +import hudson.AbortException; +import hudson.Extension; +import hudson.Functions; +import hudson.Util; +import hudson.init.Terminator; +import hudson.model.Computer; +import hudson.model.Descriptor; +import hudson.model.Item; +import hudson.model.ModelObject; +import hudson.model.Slave; +import hudson.model.TaskListener; +import hudson.remoting.Channel; +import hudson.security.ACL; +import hudson.security.AccessControlled; +import hudson.slaves.ComputerLauncher; +import hudson.slaves.SlaveComputer; +import hudson.util.FormValidation; +import hudson.util.ListBoxModel; +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.lang.ref.WeakReference; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermission; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.EnumSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.logging.Level; +import java.util.logging.Logger; +import jenkins.model.Jenkins; +import net.jcip.annotations.GuardedBy; +import org.apache.commons.io.output.CloseShieldOutputStream; +import org.apache.commons.lang3.StringUtils; +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.ConnectFuture; +import org.apache.sshd.client.keyverifier.ServerKeyVerifier; +import org.apache.sshd.client.session.ClientSession; +import org.apache.sshd.common.AttributeRepository; +import org.apache.sshd.common.AttributeStore; +import org.apache.sshd.common.SshException; +import org.apache.sshd.common.session.Session; +import org.apache.sshd.scp.client.DefaultScpClient; +import org.apache.sshd.scp.client.ScpClient; +import org.apache.sshd.scp.common.ScpTransferEventListener; +import org.apache.sshd.sftp.client.SftpClient; +import org.apache.sshd.sftp.client.SftpClientFactory; +import org.apache.sshd.sftp.common.SftpException; +import org.jenkinsci.Symbol; +import org.kohsuke.stapler.AncestorInPath; +import org.kohsuke.stapler.DataBoundConstructor; +import org.kohsuke.stapler.DataBoundSetter; +import org.kohsuke.stapler.QueryParameter; +import org.kohsuke.stapler.verb.POST; + +/** + * SSH launcher that uses Apache Mina SSHD for connections. + * + *

This is an alternative to the Trilead-based {@link hudson.plugins.sshslaves.SSHLauncher}, + * providing NIO-based SSH connections with modern cryptography support via Apache Mina SSHD. + * + *

A shared {@link SshClient} instance (managed by {@link MinaSshClient}) is reused across + * all connections for resource efficiency. + */ +public class MinaSSHLauncher extends ComputerLauncher { + + private static final Logger LOGGER = Logger.getLogger(MinaSSHLauncher.class.getName()); + + public static final int DEFAULT_SSH_PORT = 22; + public static final Integer DEFAULT_MAX_NUM_RETRIES = 10; + public static final Integer DEFAULT_RETRY_WAIT_TIME = 15; + public static final Integer DEFAULT_LAUNCH_TIMEOUT_SECONDS = 60; + public static final String AGENT_JAR = "remoting.jar"; + public static final SchemeRequirement SSH_SCHEME = new SchemeRequirement("ssh"); + + /** + * Default timeout for SSH operations in milliseconds. + */ + public static final int TIMEOUT = Integer.getInteger(MinaSSHLauncher.class.getName() + ".TIMEOUT", 60000); + + @GuardedBy("class") + private static WeakReference agentJarBytes; + + private final String host; + private final int port; + private final String credentialsId; + + private String jvmOptions; + private String javaPath; + private String prefixStartSlaveCmd; + private String suffixStartSlaveCmd; + private Integer launchTimeoutSeconds; + private Integer maxNumRetries; + private Integer retryWaitTime; + private Boolean tcpNoDelay; + private String workDir; + private MinaServerKeyVerificationStrategy serverKeyVerificationStrategy; + + private transient volatile ClientSession session; + + @DataBoundConstructor + public MinaSSHLauncher(@NonNull String host, int port, String credentialsId) { + this.host = host; + this.port = port == 0 ? DEFAULT_SSH_PORT : port; + this.credentialsId = credentialsId; + } + + @NonNull + public String getHost() { + return host; + } + + public int getPort() { + return port == 0 ? DEFAULT_SSH_PORT : port; + } + + public String getCredentialsId() { + return credentialsId; + } + + public String getJvmOptions() { + return jvmOptions; + } + + @DataBoundSetter + public void setJvmOptions(String jvmOptions) { + this.jvmOptions = Util.fixEmpty(jvmOptions); + } + + public String getJavaPath() { + return javaPath; + } + + @DataBoundSetter + public void setJavaPath(String javaPath) { + this.javaPath = Util.fixEmpty(javaPath); + } + + public String getPrefixStartSlaveCmd() { + return prefixStartSlaveCmd; + } + + @DataBoundSetter + public void setPrefixStartSlaveCmd(String prefixStartSlaveCmd) { + this.prefixStartSlaveCmd = Util.fixEmpty(prefixStartSlaveCmd); + } + + public String getSuffixStartSlaveCmd() { + return suffixStartSlaveCmd; + } + + @DataBoundSetter + public void setSuffixStartSlaveCmd(String suffixStartSlaveCmd) { + this.suffixStartSlaveCmd = Util.fixEmpty(suffixStartSlaveCmd); + } + + public Integer getLaunchTimeoutSeconds() { + return launchTimeoutSeconds == null ? DEFAULT_LAUNCH_TIMEOUT_SECONDS : launchTimeoutSeconds; + } + + @DataBoundSetter + public void setLaunchTimeoutSeconds(Integer launchTimeoutSeconds) { + this.launchTimeoutSeconds = launchTimeoutSeconds; + } + + public Integer getMaxNumRetries() { + return maxNumRetries == null ? DEFAULT_MAX_NUM_RETRIES : maxNumRetries; + } + + @DataBoundSetter + public void setMaxNumRetries(Integer maxNumRetries) { + this.maxNumRetries = maxNumRetries; + } + + public Integer getRetryWaitTime() { + return retryWaitTime == null ? DEFAULT_RETRY_WAIT_TIME : retryWaitTime; + } + + @DataBoundSetter + public void setRetryWaitTime(Integer retryWaitTime) { + this.retryWaitTime = retryWaitTime; + } + + public Boolean getTcpNoDelay() { + return tcpNoDelay; + } + + @DataBoundSetter + public void setTcpNoDelay(Boolean tcpNoDelay) { + this.tcpNoDelay = tcpNoDelay; + } + + public String getWorkDir() { + return workDir; + } + + @DataBoundSetter + public void setWorkDir(String workDir) { + this.workDir = Util.fixEmpty(workDir); + } + + public MinaServerKeyVerificationStrategy getServerKeyVerificationStrategy() { + return serverKeyVerificationStrategy; + } + + @DataBoundSetter + public void setServerKeyVerificationStrategy(MinaServerKeyVerificationStrategy serverKeyVerificationStrategy) { + this.serverKeyVerificationStrategy = serverKeyVerificationStrategy; + } + + @Override + public boolean isLaunchSupported() { + return true; + } + + @Override + public void launch(SlaveComputer computer, TaskListener listener) throws IOException, InterruptedException { + long startTime = System.currentTimeMillis(); + + StandardUsernameCredentials credentials = lookupCredentials(); + if (credentials == null) { + throw new AbortException("[SSH Mina] Cannot find specified credentials: " + credentialsId); + } + if (!SSHAuthenticator.isSupported(ClientSession.class, credentials.getClass())) { + throw new AbortException( + "[SSH Mina] Incompatible credentials: " + CredentialsNameProvider.name(credentials)); + } + + MinaServerKeyVerificationStrategy strategy = getServerKeyVerificationStrategyDefaulted(); + List preferredAlgorithms = strategy.getPreferredKeyAlgorithms(); + + ClientSession connectedSession = openConnection(listener, computer, credentials, preferredAlgorithms); + this.session = connectedSession; + + // Set up key verification + connectedSession + .getMetadataMap() + .put( + ServerKeyVerifier.class, + new LoggingServerKeyVerifier(strategy.createVerifier(computer, host), listener)); + + // Authenticate + println(listener, "Authenticating as " + CredentialsNameProvider.name(credentials)); + if (!SSHAuthenticator.newInstance(connectedSession, credentials).authenticate(listener)) { + println(listener, "Authentication failed"); + connectedSession.close(true); + return; + } + println(listener, "Authentication successful"); + + try { + verifyNoHeaderJunk(connectedSession, listener); + String workingDirectory = getWorkingDirectory(computer); + println(listener, "Remote SSH server: " + connectedSession.getServerVersion()); + + String java = resolveJava(connectedSession, listener); + copyAgentJar(connectedSession, listener, workingDirectory); + startAgent(computer, listener, connectedSession, workingDirectory, java); + + double elapsed = (System.currentTimeMillis() - startTime) * 1e-3; + println(listener, "Connection established after " + String.format("%.1f", elapsed) + " seconds"); + } catch (RuntimeException e) { + connectedSession.close(true); + throw e; + } catch (Throwable e) { + connectedSession.close(true); + throw new IOException(e.getMessage(), e); + } + } + + @Override + public void afterDisconnect(SlaveComputer slaveComputer, TaskListener listener) { + ClientSession currentSession = this.session; + if (currentSession != null) { + try { + currentSession.close(true); + } catch (Exception e) { + LOGGER.log(Level.FINE, "Error closing Mina SSH session", e); + } + this.session = null; + } + super.afterDisconnect(slaveComputer, listener); + } + + private ClientSession openConnection( + TaskListener listener, + SlaveComputer computer, + StandardUsernameCredentials credentials, + List preferredAlgorithms) + throws IOException, InterruptedException { + SshClient client = MinaSshClient.getClient(); + int effectivePort = getPort(); + int maxRetries = getMaxNumRetries(); + int retryWait = getRetryWaitTime(); + + for (int attempt = 0; attempt <= maxRetries; attempt++) { + try { + println( + listener, + "Opening SSH connection to " + + host + ":" + effectivePort + + " as " + CredentialsNameProvider.name(credentials)); + + // Create connection context with preferred algorithms if specified + AttributeStore context = null; + if (!preferredAlgorithms.isEmpty()) { + context = createAttributeStore(); + context.setAttribute(MinaSshClient.PREFERRED_ALGORITHMS_KEY, preferredAlgorithms); + } + + ConnectFuture future = client.connect(credentials.getUsername(), host, effectivePort, context, null); + future.await(getLaunchTimeoutSeconds(), TimeUnit.SECONDS); + + if (!future.isConnected()) { + throw new IOException("SSH connection timed out"); + } + + return future.getSession(); + } catch (IOException e) { + if (attempt < maxRetries) { + println( + listener, + "SSH connection failed: " + e.getMessage() + ". Retrying in " + retryWait + " seconds (" + + (attempt + 1) + "/" + maxRetries + ")"); + Thread.sleep(retryWait * 1000L); + } else { + throw e; + } + } + } + throw new IOException("SSH connection failed after " + maxRetries + " retries"); + } + + private void startAgent( + SlaveComputer computer, + TaskListener listener, + ClientSession connectedSession, + String workingDirectory, + String java) + throws IOException, InterruptedException { + String prefix = StringUtils.isNotBlank(prefixStartSlaveCmd) ? prefixStartSlaveCmd + " " : ""; + String suffix = StringUtils.isNotBlank(suffixStartSlaveCmd) ? " " + suffixStartSlaveCmd : ""; + String jvmOpts = StringUtils.defaultString(jvmOptions); + + String cmd = prefix + "cd \"" + workingDirectory + "\" && \"" + java + "\" " + jvmOpts + " -jar " + AGENT_JAR + + suffix; + + ChannelExec process = connectedSession.createExecChannel(cmd); + println(listener, "$ " + cmd); + process.open().await(); + + // Pipe stderr to listener for diagnostics + process.setErr(CloseShieldOutputStream.wrap(listener.getLogger())); + + computer.setChannel( + process.getInvertedOut(), process.getInvertedIn(), listener.getLogger(), new Channel.Listener() { + @Override + public void onClosed(Channel channel, IOException cause) { + if (cause != null) { + Functions.printStackTrace(cause, listener.error("[SSH Mina] Agent terminated")); + } + try { + process.close(true); + } catch (Throwable t) { + Functions.printStackTrace(t, listener.error("[SSH Mina] Error while closing SSH channel")); + } + try { + connectedSession.close(true); + } catch (Throwable t) { + Functions.printStackTrace(t, listener.error("[SSH Mina] Error while closing SSH session")); + } + } + }); + } + + private void copyAgentJar(ClientSession connectedSession, TaskListener listener, String workingDirectory) + throws IOException { + String fileName = + workingDirectory.endsWith("/") ? workingDirectory + AGENT_JAR : workingDirectory + "/" + AGENT_JAR; + byte[] slaveJar = getAgentJarBytes(); + + // Check if jar is already current (MD5 check) + String digest = Util.getDigestOf(new ByteArrayInputStream(slaveJar)); + println(listener, "Verifying agent jar..."); + ChannelExec execChannel = null; + try { + String cmd = "mkdir -p \"" + workingDirectory + "\" 2>/dev/null ; md5sum \"" + fileName + "\" || md5 \"" + + fileName + "\""; + execChannel = connectedSession.createExecChannel(cmd); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + execChannel.setOut(baos); + execChannel.setErr(baos); + execChannel.open().await(); + execChannel.waitFor(Arrays.asList(ClientChannelEvent.EOF, ClientChannelEvent.EXIT_STATUS), TIMEOUT); + execChannel.close(false); + execChannel = null; + String output = baos.toString(StandardCharsets.UTF_8); + if (output.toLowerCase().contains(digest) && digest.length() > 30) { + println(listener, "Agent jar is current (" + digest + ")"); + return; + } + } catch (IOException e) { + // fall through to copy + } finally { + if (execChannel != null) { + execChannel.close(true); + } + } + + // Try SFTP first + SftpClientFactory factory = SftpClientFactory.instance(); + try (SftpClient sftp = factory.createSftpClient(connectedSession)) { + println(listener, "Copying agent jar via SFTP..."); + try { + SftpClient.Attributes stat = sftp.stat(workingDirectory); + if (stat.isRegularFile()) { + throw new IOException("Remote FS " + workingDirectory + " is a file, not a directory"); + } + } catch (SshException | SftpException e) { + println(listener, "Remote directory " + workingDirectory + " does not exist, creating..."); + mkdirs(sftp, workingDirectory, 0700); + } + try { + sftp.remove(fileName); + } catch (IOException e) { + // file did not exist + } + try (SftpClient.CloseableHandle handle = + sftp.open(fileName, EnumSet.of(SftpClient.OpenMode.Create, SftpClient.OpenMode.Write))) { + int offset = 0; + while (offset < slaveJar.length) { + int size = Math.min(32768, slaveJar.length - offset); + sftp.write(handle, offset, slaveJar, offset, size); + offset += size; + } + println(listener, "Copied " + offset + " bytes via SFTP"); + } catch (Exception e) { + connectedSession.close(true); + throw new IOException("Error copying agent jar into " + workingDirectory, e); + } + } catch (IOException e) { + // SFTP failed, try SCP + println(listener, "SFTP failed, trying SCP..."); + ScpClient client = new DefaultScpClient(connectedSession, null, new ScpTransferEventListener() { + @Override + public void startFileEvent( + Session session, FileOperation op, Path path, long length, Set perms) { + println(listener, "Sending file " + path); + } + + @Override + public void endFileEvent( + Session session, + FileOperation op, + Path path, + long length, + Set perms, + Throwable thrown) { + println(listener, "Sent file " + path); + } + + @Override + public void startFolderEvent( + Session session, FileOperation op, Path path, Set perms) {} + + @Override + public void endFolderEvent( + Session session, + FileOperation op, + Path path, + Set perms, + Throwable thrown) {} + }); + client.upload(slaveJar, fileName, Collections.singleton(PosixFilePermission.OWNER_READ), null); + println(listener, "Copied " + slaveJar.length + " bytes via SCP"); + } + } + + private void verifyNoHeaderJunk(ClientSession connectedSession, TaskListener listener) + throws IOException, InterruptedException { + println(listener, "Checking for header junk..."); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ChannelExec execChannel = connectedSession.createExecChannel("true"); + try { + execChannel.setOut(baos); + execChannel.open().await(); + execChannel.waitFor(Collections.singleton(ClientChannelEvent.EOF), TIMEOUT); + } finally { + execChannel.close(false); + } + if (baos.toByteArray().length != 0) { + String junk = baos.toString(StandardCharsets.UTF_8); + println(listener, "Header junk detected: " + junk); + throw new AbortException( + "SSH header junk detected. Please disable banner printing and .profile/.bashrc output."); + } + println(listener, "No header junk"); + } + + private String resolveJava(ClientSession connectedSession, TaskListener listener) + throws IOException, InterruptedException { + println(listener, "Verifying Java..."); + if (StringUtils.isNotBlank(javaPath)) { + return javaPath; + } + + List candidates = Arrays.asList( + "java", + "/usr/bin/java", + "/usr/java/default/bin/java", + "/usr/java/latest/bin/java", + "/usr/local/bin/java", + "/usr/local/java/bin/java"); + List tried = new ArrayList<>(); + + for (String javaCommand : candidates) { + println(listener, "Trying " + javaCommand + "..."); + tried.add(javaCommand); + ChannelExec execChannel = null; + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try { + String cmd = "\"" + javaCommand + "\" " + StringUtils.defaultString(jvmOptions) + " -version 2>&1"; + execChannel = connectedSession.createExecChannel(cmd); + execChannel.setOut(baos); + execChannel.setErr(baos); + execChannel.open().await(); + execChannel.waitFor(Collections.singletonList(ClientChannelEvent.EOF), TIMEOUT); + execChannel.close(false); + execChannel = null; + + String output = baos.toString(StandardCharsets.UTF_8); + // Check for a valid Java version output + checkJavaVersion(listener, javaCommand, output); + return javaCommand; + } catch (IOException e) { + // try next + } finally { + if (execChannel != null) { + execChannel.close(true); + } + } + } + throw new IOException("Could not find any known supported Java version in " + tried); + } + + private void checkJavaVersion(TaskListener listener, String javaCommand, String output) throws IOException { + BufferedReader reader = new BufferedReader(new InputStreamReader( + new ByteArrayInputStream(output.getBytes(StandardCharsets.UTF_8)), StandardCharsets.UTF_8)); + String line; + while ((line = reader.readLine()) != null) { + if (line.contains("version")) { + println(listener, "Found " + javaCommand + ": " + line.trim()); + return; + } + } + throw new IOException(javaCommand + " -version did not produce recognizable output: " + output); + } + + private MinaServerKeyVerificationStrategy getServerKeyVerificationStrategyDefaulted() { + if (serverKeyVerificationStrategy == null) { + return new BlindTrustVerificationStrategy(); + } + return serverKeyVerificationStrategy; + } + + private StandardUsernameCredentials lookupCredentials() { + return CredentialsMatchers.firstOrNull( + CredentialsProvider.lookupCredentialsInItemGroup( + StandardUsernameCredentials.class, Jenkins.get(), ACL.SYSTEM2, List.of(SSH_SCHEME)), + CredentialsMatchers.withId(credentialsId)); + } + + private static String getWorkingDirectory(SlaveComputer computer) { + Slave node = computer.getNode(); + if (node == null) { + throw new IllegalStateException("Node is null"); + } + String dir = node.getRemoteFS(); + while (dir.endsWith("/")) { + dir = dir.substring(0, dir.length() - 1); + } + return dir; + } + + private static synchronized byte[] getAgentJarBytes() throws IOException { + byte[] ref = agentJarBytes == null ? null : agentJarBytes.get(); + if (ref != null) { + return ref; + } + ref = new Slave.JnlpJar(AGENT_JAR).readFully(); + agentJarBytes = new WeakReference<>(ref); + return ref; + } + + private static void mkdirs(SftpClient sftp, String path, int mode) throws IOException { + if (!path.endsWith("/")) { + path = path + "/"; + } + for (int i = path.indexOf('/'); i != -1; i = path.indexOf('/', i + 1)) { + if (i == 0) { + continue; + } + try { + sftp.stat(path.substring(0, i)); + } catch (SftpException e) { + sftp.mkdir(path.substring(0, i)); + sftp.setStat(path.substring(0, i), new SftpClient.Attributes().perms(mode)); + } + } + } + + private static AttributeStore createAttributeStore() { + return new AttributeStore() { + private final ConcurrentHashMap, Object> attributes = + new ConcurrentHashMap<>(); + + @Override + @SuppressWarnings("unchecked") + public T getAttribute(AttributeRepository.AttributeKey key) { + return (T) attributes.get(key); + } + + @Override + @SuppressWarnings("unchecked") + public T setAttribute(AttributeRepository.AttributeKey key, T value) { + return value != null ? (T) attributes.put(key, value) : (T) attributes.remove(key); + } + + @Override + @SuppressWarnings("unchecked") + public T removeAttribute(AttributeRepository.AttributeKey key) { + return (T) attributes.remove(key); + } + + @Override + public int getAttributesCount() { + return attributes.size(); + } + + @Override + public void clearAttributes() { + attributes.clear(); + } + + @Override + public Collection> attributeKeys() { + return attributes.keySet(); + } + }; + } + + /** + * Shuts down the shared Mina SSH client when Jenkins is stopping. + */ + @Terminator + public static void stopMinaSshClient() { + MinaSshClient.stop(); + } + + private static void println(TaskListener listener, String message) { + listener.getLogger().println("[SSH Mina] " + message); + } + + @Extension + @Symbol("sshMina") + public static class DescriptorImpl extends Descriptor { + + @Override + @NonNull + public String getDisplayName() { + return Messages.MinaSSHLauncher_DisplayName(); + } + + @SuppressWarnings("unused") + public ListBoxModel doFillCredentialsIdItems( + @AncestorInPath ModelObject context, + @QueryParameter String host, + @QueryParameter String port, + @QueryParameter String credentialsId) { + Jenkins jenkins = Jenkins.getInstanceOrNull(); + AccessControlled aclHolder = context instanceof AccessControlled ? (AccessControlled) context : jenkins; + if (aclHolder == null) { + return new StandardUsernameListBoxModel(); + } + if (aclHolder instanceof Item) { + if (!aclHolder.hasPermission(Item.CONFIGURE)) { + aclHolder.checkPermission(Item.EXTENDED_READ); + return new StandardUsernameListBoxModel(); + } + } else if (aclHolder instanceof Computer || aclHolder == jenkins) { + if (!aclHolder.hasPermission(Computer.CONFIGURE)) { + aclHolder.checkPermission(Computer.EXTENDED_READ); + return new StandardUsernameListBoxModel(); + } + } else { + return new StandardUsernameListBoxModel(); + } + + List domainRequirements = new ArrayList<>(); + domainRequirements.add(SSH_SCHEME); + if (StringUtils.isNotBlank(host)) { + try { + int portValue = StringUtils.isBlank(port) ? DEFAULT_SSH_PORT : Integer.parseInt(port); + domainRequirements.add(new HostnamePortRequirement(host, portValue)); + } catch (NumberFormatException e) { + // ignore invalid port + } + } + + if (context instanceof Item) { + return new StandardUsernameListBoxModel() + .includeMatchingAs( + ACL.SYSTEM, + (Item) context, + StandardUsernameCredentials.class, + domainRequirements, + SSHAuthenticator.matcher(ClientSession.class)); + } else { + return new StandardUsernameListBoxModel() + .includeMatchingAs( + ACL.SYSTEM, + jenkins, + StandardUsernameCredentials.class, + domainRequirements, + SSHAuthenticator.matcher(ClientSession.class)); + } + } + + @POST + @SuppressWarnings("unused") + public FormValidation doCheckHost(@QueryParameter String value) { + if (StringUtils.isBlank(value)) { + return FormValidation.error("Host is required"); + } + return FormValidation.ok(); + } + + @POST + @SuppressWarnings("unused") + public FormValidation doCheckPort(@QueryParameter String value) { + if (StringUtils.isNotBlank(value)) { + try { + int port = Integer.parseInt(value); + if (port < 1 || port > 65535) { + return FormValidation.error("Port must be between 1 and 65535"); + } + } catch (NumberFormatException e) { + return FormValidation.error("Invalid port number"); + } + } + return FormValidation.ok(); + } + + @POST + @SuppressWarnings("unused") + public FormValidation doCheckCredentialsId(@QueryParameter String value) { + if (StringUtils.isBlank(value)) { + return FormValidation.error("Credentials are required"); + } + return FormValidation.ok(); + } + } +} diff --git a/src/main/java/hudson/plugins/sshslaves/mina/MinaServerKeyVerificationStrategy.java b/src/main/java/hudson/plugins/sshslaves/mina/MinaServerKeyVerificationStrategy.java new file mode 100644 index 00000000..03ba0ff7 --- /dev/null +++ b/src/main/java/hudson/plugins/sshslaves/mina/MinaServerKeyVerificationStrategy.java @@ -0,0 +1,73 @@ +/* + * The MIT License + * + * Copyright (c) 2004-, all the contributors + * + * 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. + */ +package hudson.plugins.sshslaves.mina; + +import edu.umd.cs.findbugs.annotations.NonNull; +import hudson.model.AbstractDescribableImpl; +import hudson.model.Descriptor; +import hudson.slaves.SlaveComputer; +import java.util.Collections; +import java.util.List; +import org.apache.sshd.client.keyverifier.ServerKeyVerifier; + +/** + * Abstract base for Mina SSHD host key verification strategies. + * + *

Each implementation provides a {@link ServerKeyVerifier} for use during SSH connection + * establishment. Implementations may optionally specify preferred key algorithms to influence + * the SSH handshake negotiation. + */ +public abstract class MinaServerKeyVerificationStrategy + extends AbstractDescribableImpl { + + /** + * Creates a {@link ServerKeyVerifier} for the given computer. + * + * @param computer the slave computer being connected to + * @param host the SSH host being connected to + * @return a verifier to use during SSH handshake + */ + @NonNull + public abstract ServerKeyVerifier createVerifier(SlaveComputer computer, String host); + + /** + * Returns the preferred host key algorithms for this strategy. + * + *

When non-empty, the SSH client will reorder the algorithm negotiation to + * prefer the specified algorithms. This is useful when a specific host key type + * is required (e.g., when manually providing an ED25519 key). + * + * @return list of algorithm names (e.g., "ssh-ed25519", "ssh-rsa"), or empty list + */ + @NonNull + public List getPreferredKeyAlgorithms() { + return Collections.emptyList(); + } + + /** + * Descriptor base for Mina server key verification strategies. + */ + public abstract static class MinaServerKeyVerificationStrategyDescriptor + extends Descriptor {} +} diff --git a/src/main/java/hudson/plugins/sshslaves/mina/MinaSshClient.java b/src/main/java/hudson/plugins/sshslaves/mina/MinaSshClient.java new file mode 100644 index 00000000..50cbfb30 --- /dev/null +++ b/src/main/java/hudson/plugins/sshslaves/mina/MinaSshClient.java @@ -0,0 +1,183 @@ +/* + * The MIT License + * + * Copyright (c) 2004-, all the contributors + * + * 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. + */ +package hudson.plugins.sshslaves.mina; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.logging.Level; +import java.util.logging.Logger; +import org.apache.sshd.client.ClientBuilder; +import org.apache.sshd.client.SshClient; +import org.apache.sshd.client.keyverifier.DelegatingServerKeyVerifier; +import org.apache.sshd.client.session.ClientSession; +import org.apache.sshd.common.AttributeRepository; +import org.apache.sshd.common.channel.RequestHandler; +import org.apache.sshd.common.global.KeepAliveHandler; +import org.apache.sshd.common.kex.KexProposalOption; +import org.apache.sshd.common.session.ConnectionService; +import org.apache.sshd.common.session.Session; +import org.apache.sshd.common.session.SessionListener; + +/** + * Singleton manager for the shared Apache Mina SSHD {@link SshClient}. + * + *

Per Apache Mina best practices, a single {@link SshClient} instance is reused across all + * connections to avoid excessive resource allocation (thread pools, NIO selectors, etc.). + * + *

A {@link SessionListener} is used to configure per-session host key algorithm preferences + * based on attributes passed through the connection context via {@link #PREFERRED_ALGORITHMS_KEY}. + */ +final class MinaSshClient { + + private static final Logger LOGGER = Logger.getLogger(MinaSshClient.class.getName()); + + /** + * Attribute key for passing preferred host key algorithms to a session. + * The value should be a {@code List} of algorithm names (e.g., "ssh-rsa", "ssh-ed25519"). + */ + static final AttributeRepository.AttributeKey> PREFERRED_ALGORITHMS_KEY = + new AttributeRepository.AttributeKey<>(); + + private static SshClient client; + + /** + * Returns the shared SSH client, creating it on first use. + * + *

The client uses a {@link SessionListener} to apply per-session algorithm preferences + * based on attributes passed through the connection context. + */ + static synchronized SshClient getClient() { + if (client != null) { + return client; + } + client = ClientBuilder.builder() + .serverKeyVerifier(new DelegatingServerKeyVerifier()) + .build(true); + + // Add session listener for per-session algorithm configuration + client.addSessionListener(new SessionListener() { + @Override + public void sessionNegotiationOptionsCreated(Session session, Map proposal) { + List preferredAlgorithms = getPreferredAlgorithms(session); + + if (!preferredAlgorithms.isEmpty()) { + String currentAlgorithms = proposal.get(KexProposalOption.SERVERKEYS); + if (currentAlgorithms != null && !currentAlgorithms.isEmpty()) { + String reordered = reorderAlgorithms(currentAlgorithms, preferredAlgorithms); + proposal.put(KexProposalOption.SERVERKEYS, reordered); + LOGGER.log(Level.FINE, () -> "Reordered host key algorithms for session: " + reordered); + } + } + } + + private List getPreferredAlgorithms(Session session) { + // Try direct session attribute first + List result = session.getAttribute(PREFERRED_ALGORITHMS_KEY); + if (result != null) { + return result; + } + + // Try via connection context (for attributes passed through connect()) + if (session instanceof ClientSession clientSession) { + AttributeRepository context = clientSession.getConnectionContext(); + if (context != null) { + result = context.getAttribute(PREFERRED_ALGORITHMS_KEY); + if (result != null) { + return result; + } + } + } + return Collections.emptyList(); + } + }); + + // Add keep-alive handler + List> requestHandlers = client.getGlobalRequestHandlers(); + requestHandlers = (requestHandlers == null) ? new ArrayList<>() : new ArrayList<>(requestHandlers); + boolean found = false; + for (RequestHandler handler : requestHandlers) { + if (handler instanceof KeepAliveHandler) { + found = true; + break; + } + } + if (!found) { + requestHandlers.add(new KeepAliveHandler()); + client.setGlobalRequestHandlers(requestHandlers); + } + + client.start(); + LOGGER.fine("Mina SshClient started"); + return client; + } + + /** + * Reorders the algorithm list to prioritize the preferred algorithms. + * + * @param currentAlgorithms comma-separated list of current algorithms + * @param preferredAlgorithms list of algorithms to prioritize + * @return reordered comma-separated algorithm list + */ + static String reorderAlgorithms(String currentAlgorithms, List preferredAlgorithms) { + List algorithms = new ArrayList<>(Arrays.asList(currentAlgorithms.split(","))); + List preferred = new ArrayList<>(); + List others = new ArrayList<>(); + + for (String algo : algorithms) { + String trimmed = algo.trim(); + boolean isPreferred = preferredAlgorithms.stream().anyMatch(pref -> trimmed.toLowerCase() + .contains(pref.toLowerCase().replace("ssh-", ""))); + if (isPreferred) { + preferred.add(trimmed); + } else { + others.add(trimmed); + } + } + preferred.addAll(others); + LOGGER.log( + Level.FINE, + () -> "Preferred host key algorithms: " + preferred + ", others: " + others + ", current: " + + currentAlgorithms); + return String.join(",", preferred); + } + + /** + * Stops the shared SSH client. Called during plugin shutdown. + */ + static synchronized void stop() { + if (client != null) { + try { + client.stop(); + } catch (Exception e) { + LOGGER.log(Level.WARNING, "Error stopping Mina SshClient", e); + } + client = null; + } + } + + private MinaSshClient() {} +} diff --git a/src/main/java/hudson/plugins/sshslaves/mina/TrustOnFirstUseVerificationStrategy.java b/src/main/java/hudson/plugins/sshslaves/mina/TrustOnFirstUseVerificationStrategy.java new file mode 100644 index 00000000..94942c67 --- /dev/null +++ b/src/main/java/hudson/plugins/sshslaves/mina/TrustOnFirstUseVerificationStrategy.java @@ -0,0 +1,192 @@ +/* + * The MIT License + * + * Copyright (c) 2004-, all the contributors + * + * 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. + */ +package hudson.plugins.sshslaves.mina; + +import edu.umd.cs.findbugs.annotations.NonNull; +import hudson.Extension; +import hudson.model.Slave; +import hudson.slaves.EphemeralNode; +import hudson.slaves.SlaveComputer; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.net.SocketAddress; +import java.security.GeneralSecurityException; +import java.security.PublicKey; +import java.util.logging.Level; +import java.util.logging.Logger; +import jenkins.model.Jenkins; +import jenkins.model.Nodes; +import org.apache.commons.io.FileUtils; +import org.apache.sshd.client.keyverifier.ServerKeyVerifier; +import org.apache.sshd.client.session.ClientSession; +import org.apache.sshd.common.config.keys.AuthorizedKeyEntry; +import org.apache.sshd.common.config.keys.KeyUtils; +import org.apache.sshd.common.config.keys.PublicKeyEntryDecoder; +import org.apache.sshd.common.config.keys.PublicKeyEntryResolver; +import org.jenkinsci.Symbol; +import org.kohsuke.stapler.DataBoundConstructor; + +/** + * Trust-on-first-use (TOFU) verification strategy. + * + *

On the first connection to a new host, the server's host key is stored. On subsequent + * connections, the stored key is compared with the presented key. + * + *

When {@link #manualVerification} is {@code true}, the first connection will be rejected + * and the key must be manually approved via the Jenkins UI before the agent can connect. + * + *

Trusted keys are stored per-node in {@code $JENKINS_HOME/nodes//authorized_key}. + */ +public class TrustOnFirstUseVerificationStrategy extends MinaServerKeyVerificationStrategy { + + private static final Logger LOGGER = Logger.getLogger(TrustOnFirstUseVerificationStrategy.class.getName()); + + private final boolean manualVerification; + + @DataBoundConstructor + public TrustOnFirstUseVerificationStrategy(boolean manualVerification) { + this.manualVerification = manualVerification; + } + + public boolean isManualVerification() { + return manualVerification; + } + + @Override + @NonNull + public ServerKeyVerifier createVerifier(SlaveComputer computer, String host) { + AuthorizedKeyEntry storedEntry; + try { + storedEntry = loadStoredKey(computer); + } catch (IOException e) { + storedEntry = null; + } + + if (storedEntry != null) { + // We have a stored key - verify against it + try { + final PublicKey expected = storedEntry.resolvePublicKey(null, PublicKeyEntryResolver.IGNORING); + return (ClientSession clientSession, SocketAddress remoteAddress, PublicKey serverKey) -> { + boolean result = KeyUtils.compareKeys(expected, serverKey); + LOGGER.log( + Level.FINE, + () -> "Comparing expected: " + expected + ", serverKey: " + serverKey + ", result: " + + result); + return result; + }; + } catch (IOException | GeneralSecurityException e) { + LOGGER.log(Level.FINE, "Error resolving stored key for verification", e); + } + } + + // No stored key - trust on first use (or require manual verification) + return (ClientSession clientSession, SocketAddress remoteAddress, PublicKey serverKey) -> { + @SuppressWarnings("unchecked") + PublicKeyEntryDecoder decoder = + (PublicKeyEntryDecoder) KeyUtils.getPublicKeyEntryDecoder(serverKey); + try (ByteArrayOutputStream s = new ByteArrayOutputStream(Byte.MAX_VALUE)) { + AuthorizedKeyEntry newEntry = new AuthorizedKeyEntry(); + newEntry.setKeyType(decoder.encodePublicKey(s, serverKey)); + newEntry.setKeyData(s.toByteArray()); + + if (manualVerification) { + LOGGER.log(Level.FINE, () -> "Manual verification required, rejecting first-time key"); + return false; + } + + // Auto-trust: store the key + storeKey(computer, newEntry); + return true; + } catch (IOException e) { + LOGGER.log(Level.WARNING, "Error saving server key", e); + return false; + } + }; + } + + private AuthorizedKeyEntry loadStoredKey(SlaveComputer computer) throws IOException { + File nodesDir = getNodesDir(); + Slave node = computer.getNode(); + if (node == null || node instanceof EphemeralNode || nodesDir == null) { + return null; + } + File authorizedKeyFile = new File(new File(nodesDir, node.getNodeName()), "authorized_key"); + if (authorizedKeyFile.isFile()) { + return AuthorizedKeyEntry.parseAuthorizedKeyEntry(FileUtils.readFileToString(authorizedKeyFile, "UTF-8")); + } + return null; + } + + private void storeKey(SlaveComputer computer, AuthorizedKeyEntry entry) { + File nodesDir = getNodesDir(); + Slave node = computer.getNode(); + if (node == null || node instanceof EphemeralNode || nodesDir == null) { + return; + } + File authorizedKeyFile = new File(new File(nodesDir, node.getNodeName()), "authorized_key"); + if (entry == null) { + FileUtils.deleteQuietly(authorizedKeyFile); + } else { + StringBuilder buf = new StringBuilder(); + try { + entry.appendPublicKey(null, buf, PublicKeyEntryResolver.IGNORING); + FileUtils.write(authorizedKeyFile, buf, "UTF-8"); + } catch (IOException | GeneralSecurityException e) { + FileUtils.deleteQuietly(authorizedKeyFile); + } + } + } + + private static File getNodesDir() { + Jenkins jenkins = Jenkins.get(); + try { + Method getNodesDir = Nodes.class.getDeclaredMethod("getNodesDir"); + getNodesDir.setAccessible(true); + Method getNodesObject = Jenkins.class.getDeclaredMethod("getNodesObject"); + getNodesObject.setAccessible(true); + Object nodes = getNodesObject.invoke(jenkins); + return (File) getNodesDir.invoke(nodes); + } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) { + File nodesDir = new File(jenkins.getRootDir(), "nodes"); + if (!nodesDir.isDirectory() && !nodesDir.mkdirs()) { + return null; + } + return nodesDir; + } + } + + @Extension + @Symbol("minaTrustFirstUse") + public static class DescriptorImpl extends MinaServerKeyVerificationStrategyDescriptor { + + @Override + @NonNull + public String getDisplayName() { + return Messages.TrustOnFirstUseVerificationStrategy_DisplayName(); + } + } +} diff --git a/src/main/java/hudson/plugins/sshslaves/verifiers/HostKey.java b/src/main/java/hudson/plugins/sshslaves/verifiers/HostKey.java index 85066837..5ebcc8b6 100644 --- a/src/main/java/hudson/plugins/sshslaves/verifiers/HostKey.java +++ b/src/main/java/hudson/plugins/sshslaves/verifiers/HostKey.java @@ -35,7 +35,9 @@ * and secure the initial setup of the SSH connection. * @author Michael Clarke * @since 1.13 + * @deprecated Trilead-specific key representation. Use Apache Mina SSHD {@code java.security.PublicKey} instead. */ +@Deprecated public final class HostKey implements Serializable { private static final long serialVersionUID = -5131839381842616910L; diff --git a/src/main/java/hudson/plugins/sshslaves/verifiers/HostKeyHelper.java b/src/main/java/hudson/plugins/sshslaves/verifiers/HostKeyHelper.java index 77c7ec4b..9395d9df 100644 --- a/src/main/java/hudson/plugins/sshslaves/verifiers/HostKeyHelper.java +++ b/src/main/java/hudson/plugins/sshslaves/verifiers/HostKeyHelper.java @@ -39,7 +39,9 @@ * point the verifier is invoked during the connection attempt. * @author Michael Clarke * @since 1.13 + * @deprecated Trilead-specific host key management. Use {@link hudson.plugins.sshslaves.mina.MinaSSHLauncher} instead. */ +@Deprecated public final class HostKeyHelper { private static final HostKeyHelper INSTANCE = new HostKeyHelper(); diff --git a/src/main/java/hudson/plugins/sshslaves/verifiers/JenkinsTrilead9VersionSupport.java b/src/main/java/hudson/plugins/sshslaves/verifiers/JenkinsTrilead9VersionSupport.java index c078032b..3bffbc1e 100644 --- a/src/main/java/hudson/plugins/sshslaves/verifiers/JenkinsTrilead9VersionSupport.java +++ b/src/main/java/hudson/plugins/sshslaves/verifiers/JenkinsTrilead9VersionSupport.java @@ -11,7 +11,9 @@ /** * @author Michael Clarke + * @deprecated Trilead-specific version support. Use {@link hudson.plugins.sshslaves.mina.MinaSSHLauncher} instead. */ +@Deprecated @Restricted(NoExternalUse.class) class JenkinsTrilead9VersionSupport extends TrileadVersionSupportManager.TrileadVersionSupport { diff --git a/src/main/java/hudson/plugins/sshslaves/verifiers/KeyParseException.java b/src/main/java/hudson/plugins/sshslaves/verifiers/KeyParseException.java index 9c821f1a..cb5f0cf5 100644 --- a/src/main/java/hudson/plugins/sshslaves/verifiers/KeyParseException.java +++ b/src/main/java/hudson/plugins/sshslaves/verifiers/KeyParseException.java @@ -3,7 +3,9 @@ /** * @author Michael Clarke * @since 1.18 + * @deprecated Trilead-specific exception. Use {@link hudson.plugins.sshslaves.mina.MinaSSHLauncher} instead. */ +@Deprecated public class KeyParseException extends Exception { public KeyParseException(String message) { diff --git a/src/main/java/hudson/plugins/sshslaves/verifiers/KnownHostsFileKeyVerificationStrategy.java b/src/main/java/hudson/plugins/sshslaves/verifiers/KnownHostsFileKeyVerificationStrategy.java index a5064ef3..0da7db99 100644 --- a/src/main/java/hudson/plugins/sshslaves/verifiers/KnownHostsFileKeyVerificationStrategy.java +++ b/src/main/java/hudson/plugins/sshslaves/verifiers/KnownHostsFileKeyVerificationStrategy.java @@ -44,7 +44,9 @@ * * @author Michael Clarke * @since 1.13 + * @deprecated Use {@link hudson.plugins.sshslaves.mina.KnownHostsVerificationStrategy} instead. */ +@Deprecated public class KnownHostsFileKeyVerificationStrategy extends SshHostKeyVerificationStrategy { public static final String KNOWN_HOSTS_DEFAULT = diff --git a/src/main/java/hudson/plugins/sshslaves/verifiers/ManuallyProvidedKeyVerificationStrategy.java b/src/main/java/hudson/plugins/sshslaves/verifiers/ManuallyProvidedKeyVerificationStrategy.java index bb9a4cff..be928d5f 100644 --- a/src/main/java/hudson/plugins/sshslaves/verifiers/ManuallyProvidedKeyVerificationStrategy.java +++ b/src/main/java/hudson/plugins/sshslaves/verifiers/ManuallyProvidedKeyVerificationStrategy.java @@ -49,7 +49,9 @@ * value in their known hosts file before attempting an SSH connection on a Unix/Linux machine. * @author Michael Clarke * @since 1.13 + * @deprecated Use {@link hudson.plugins.sshslaves.mina.ManualKeyVerificationStrategy} instead. */ +@Deprecated public class ManuallyProvidedKeyVerificationStrategy extends SshHostKeyVerificationStrategy { private final HostKey key; diff --git a/src/main/java/hudson/plugins/sshslaves/verifiers/ManuallyTrustedKeyVerificationStrategy.java b/src/main/java/hudson/plugins/sshslaves/verifiers/ManuallyTrustedKeyVerificationStrategy.java index bebc9381..b2da1315 100644 --- a/src/main/java/hudson/plugins/sshslaves/verifiers/ManuallyTrustedKeyVerificationStrategy.java +++ b/src/main/java/hudson/plugins/sshslaves/verifiers/ManuallyTrustedKeyVerificationStrategy.java @@ -46,7 +46,9 @@ * key in the known hosts database. * @author Michael Clarke * @since 1.13 + * @deprecated Use {@link hudson.plugins.sshslaves.mina.TrustOnFirstUseVerificationStrategy} instead. */ +@Deprecated public class ManuallyTrustedKeyVerificationStrategy extends SshHostKeyVerificationStrategy { private static final Logger LOGGER = Logger.getLogger(ManuallyTrustedKeyVerificationStrategy.class.getName()); diff --git a/src/main/java/hudson/plugins/sshslaves/verifiers/MissingVerificationStrategyAdministrativeMonitor.java b/src/main/java/hudson/plugins/sshslaves/verifiers/MissingVerificationStrategyAdministrativeMonitor.java index 4769094f..e25cf9ce 100644 --- a/src/main/java/hudson/plugins/sshslaves/verifiers/MissingVerificationStrategyAdministrativeMonitor.java +++ b/src/main/java/hudson/plugins/sshslaves/verifiers/MissingVerificationStrategyAdministrativeMonitor.java @@ -39,7 +39,9 @@ * set against them and prompts the admin to update the settings as needed. * @author Michael Clarke * @since 1.13 + * @deprecated Trilead-specific monitor. Use {@link hudson.plugins.sshslaves.mina.MinaSSHLauncher} instead. */ +@Deprecated @Extension public class MissingVerificationStrategyAdministrativeMonitor extends AdministrativeMonitor { private List agentNames; diff --git a/src/main/java/hudson/plugins/sshslaves/verifiers/NonVerifyingKeyVerificationStrategy.java b/src/main/java/hudson/plugins/sshslaves/verifiers/NonVerifyingKeyVerificationStrategy.java index ddd5b0a3..3d1cdf7c 100644 --- a/src/main/java/hudson/plugins/sshslaves/verifiers/NonVerifyingKeyVerificationStrategy.java +++ b/src/main/java/hudson/plugins/sshslaves/verifiers/NonVerifyingKeyVerificationStrategy.java @@ -38,7 +38,9 @@ * be possible against this connection. * @author Michael Clarke * @since 1.13 + * @deprecated Use {@link hudson.plugins.sshslaves.mina.BlindTrustVerificationStrategy} instead. */ +@Deprecated public class NonVerifyingKeyVerificationStrategy extends SshHostKeyVerificationStrategy { @DataBoundConstructor diff --git a/src/main/java/hudson/plugins/sshslaves/verifiers/SshHostKeyVerificationStrategy.java b/src/main/java/hudson/plugins/sshslaves/verifiers/SshHostKeyVerificationStrategy.java index 062e01b3..752320ca 100644 --- a/src/main/java/hudson/plugins/sshslaves/verifiers/SshHostKeyVerificationStrategy.java +++ b/src/main/java/hudson/plugins/sshslaves/verifiers/SshHostKeyVerificationStrategy.java @@ -38,7 +38,9 @@ * * @author Michael Clarke * @since 1.13 + * @deprecated Use {@link hudson.plugins.sshslaves.mina.MinaServerKeyVerificationStrategy} instead. */ +@Deprecated public abstract class SshHostKeyVerificationStrategy implements Describable { @Override diff --git a/src/main/java/hudson/plugins/sshslaves/verifiers/TrileadVersionSupportManager.java b/src/main/java/hudson/plugins/sshslaves/verifiers/TrileadVersionSupportManager.java index 293b2ee0..10a9a1ee 100644 --- a/src/main/java/hudson/plugins/sshslaves/verifiers/TrileadVersionSupportManager.java +++ b/src/main/java/hudson/plugins/sshslaves/verifiers/TrileadVersionSupportManager.java @@ -13,7 +13,9 @@ * An abstraction layer to allow handling of feature changes (e.g. new key types) between different Trilead versions. * @author Michael Clarke * @since 1.18 + * @deprecated Trilead-specific version support. Use {@link hudson.plugins.sshslaves.mina.MinaSSHLauncher} instead. */ +@Deprecated @Restricted(NoExternalUse.class) final class TrileadVersionSupportManager { diff --git a/src/main/java/hudson/plugins/sshslaves/verifiers/TrustHostKeyAction.java b/src/main/java/hudson/plugins/sshslaves/verifiers/TrustHostKeyAction.java index 6ef33802..d9311f8c 100644 --- a/src/main/java/hudson/plugins/sshslaves/verifiers/TrustHostKeyAction.java +++ b/src/main/java/hudson/plugins/sshslaves/verifiers/TrustHostKeyAction.java @@ -42,7 +42,9 @@ * key. * @author Michael Clarke * @since 1.13 + * @deprecated Trilead-specific host key trust action. Use {@link hudson.plugins.sshslaves.mina.MinaSSHLauncher} instead. */ +@Deprecated public class TrustHostKeyAction extends TaskAction { private static int keyNumber = 0; diff --git a/src/main/resources/hudson/plugins/sshslaves/Messages.properties b/src/main/resources/hudson/plugins/sshslaves/Messages.properties index f5b45bbf..c712cc0b 100644 --- a/src/main/resources/hudson/plugins/sshslaves/Messages.properties +++ b/src/main/resources/hudson/plugins/sshslaves/Messages.properties @@ -21,7 +21,7 @@ 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 +SSHLauncher.DescriptorDisplayName=Launch agents via SSH (Deprecated) 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. @@ -45,21 +45,21 @@ 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 +ManualTrustingHostKeyVerifier.DescriptorDisplayName=Manually trusted key Verification Strategy (Deprecated) 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 +NonVerifyingHostKeyVerifier.DescriptorDisplayName=Non verifying Verification Strategy (Deprecated) +TrustHostKeyAction.DisplayName=Trust SSH Host Key (Deprecated) 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 +ManualKeyProvidedHostKeyVerifier.DisplayName=Manually provided key Verification Strategy (Deprecated) +KnownHostsFileHostKeyVerifier.DisplayName=Known hosts file Verification Strategy (Deprecated) 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 +MissingVerificationStrategyAdministrativeMonitor.DisplayName=Missing Verification Strategy Monitor (Deprecated) diff --git a/src/main/resources/hudson/plugins/sshslaves/mina/BlindTrustVerificationStrategy/config.jelly b/src/main/resources/hudson/plugins/sshslaves/mina/BlindTrustVerificationStrategy/config.jelly new file mode 100644 index 00000000..342359bf --- /dev/null +++ b/src/main/resources/hudson/plugins/sshslaves/mina/BlindTrustVerificationStrategy/config.jelly @@ -0,0 +1,4 @@ + + + diff --git a/src/main/resources/hudson/plugins/sshslaves/mina/KnownHostsVerificationStrategy/config.jelly b/src/main/resources/hudson/plugins/sshslaves/mina/KnownHostsVerificationStrategy/config.jelly new file mode 100644 index 00000000..342359bf --- /dev/null +++ b/src/main/resources/hudson/plugins/sshslaves/mina/KnownHostsVerificationStrategy/config.jelly @@ -0,0 +1,4 @@ + + + diff --git a/src/main/resources/hudson/plugins/sshslaves/mina/ManualKeyVerificationStrategy/config.jelly b/src/main/resources/hudson/plugins/sshslaves/mina/ManualKeyVerificationStrategy/config.jelly new file mode 100644 index 00000000..345d7158 --- /dev/null +++ b/src/main/resources/hudson/plugins/sshslaves/mina/ManualKeyVerificationStrategy/config.jelly @@ -0,0 +1,7 @@ + + + + + + diff --git a/src/main/resources/hudson/plugins/sshslaves/mina/Messages.properties b/src/main/resources/hudson/plugins/sshslaves/mina/Messages.properties new file mode 100644 index 00000000..dc0434a2 --- /dev/null +++ b/src/main/resources/hudson/plugins/sshslaves/mina/Messages.properties @@ -0,0 +1,5 @@ +BlindTrustVerificationStrategy.DisplayName=Non verifying Verification Strategy (Mina) +KnownHostsVerificationStrategy.DisplayName=Known hosts file Verification Strategy (Mina) +ManualKeyVerificationStrategy.DisplayName=Manually provided key Verification Strategy (Mina) +TrustOnFirstUseVerificationStrategy.DisplayName=Manually trusted key Verification Strategy (Mina) +MinaSSHLauncher.DisplayName=Launch agents via SSH (Apache Mina SSHD) diff --git a/src/main/resources/hudson/plugins/sshslaves/mina/MinaSSHLauncher/config.jelly b/src/main/resources/hudson/plugins/sshslaves/mina/MinaSSHLauncher/config.jelly new file mode 100644 index 00000000..77d11b17 --- /dev/null +++ b/src/main/resources/hudson/plugins/sshslaves/mina/MinaSSHLauncher/config.jelly @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/hudson/plugins/sshslaves/mina/TrustOnFirstUseVerificationStrategy/config.jelly b/src/main/resources/hudson/plugins/sshslaves/mina/TrustOnFirstUseVerificationStrategy/config.jelly new file mode 100644 index 00000000..bfb6a7b3 --- /dev/null +++ b/src/main/resources/hudson/plugins/sshslaves/mina/TrustOnFirstUseVerificationStrategy/config.jelly @@ -0,0 +1,7 @@ + + + + + + diff --git a/src/test/java/hudson/plugins/sshslaves/mina/MinaED25519ConnectionTest.java b/src/test/java/hudson/plugins/sshslaves/mina/MinaED25519ConnectionTest.java new file mode 100644 index 00000000..eebe2d5b --- /dev/null +++ b/src/test/java/hudson/plugins/sshslaves/mina/MinaED25519ConnectionTest.java @@ -0,0 +1,118 @@ +package hudson.plugins.sshslaves.mina; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.cloudbees.jenkins.plugins.sshcredentials.impl.BasicSSHUserPrivateKey; +import com.cloudbees.plugins.credentials.CredentialsProvider; +import com.cloudbees.plugins.credentials.CredentialsScope; +import com.cloudbees.plugins.credentials.CredentialsStore; +import com.cloudbees.plugins.credentials.domains.Domain; +import hudson.model.Computer; +import hudson.model.FreeStyleProject; +import hudson.model.Node.Mode; +import hudson.slaves.DumbSlave; +import hudson.slaves.OfflineCause; +import hudson.slaves.RetentionStrategy; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Iterator; +import java.util.Objects; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import org.apache.commons.io.IOUtils; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.jvnet.hudson.test.JenkinsRule; +import org.jvnet.hudson.test.junit.jupiter.WithJenkins; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +/** + * Tests SSH connection with ED25519 host key. + * Ported from CloudBees ED25519SSHLauncherTest. + */ +@Timeout(value = 10, unit = TimeUnit.MINUTES) +@WithJenkins +@Testcontainers(disabledWithoutDocker = true) +class MinaED25519ConnectionTest { + + @Container + static GenericContainer sshContainer = MinaSSHContainerFactory.createContainer("ed25519"); + + private JenkinsRule j; + + @BeforeEach + void setUp(JenkinsRule j) { + this.j = j; + } + + @Test + void sshConnectWithKeyTrustHost() throws Exception { + String host = sshContainer.getHost(); + int port = sshContainer.getMappedPort(22); + String privateKey = IOUtils.toString( + Objects.requireNonNull( + Thread.currentThread().getContextClassLoader().getResourceAsStream("rsa2048")), + StandardCharsets.UTF_8); + + Iterator stores = + CredentialsProvider.lookupStores(j.jenkins).iterator(); + assertTrue(stores.hasNext()); + CredentialsStore store = stores.next(); + + store.addCredentials( + Domain.global(), + new BasicSSHUserPrivateKey( + CredentialsScope.SYSTEM, + "simpleCredentials", + "foo", + new BasicSSHUserPrivateKey.DirectEntryPrivateKeySource(privateKey), + "theaustraliancricketteamisthebest", + null)); + + MinaSSHLauncher launcher = new MinaSSHLauncher(host, port, "simpleCredentials"); + launcher.setJavaPath("/usr/java/latest/bin/java"); + launcher.setServerKeyVerificationStrategy(new TrustOnFirstUseVerificationStrategy(false)); + + DumbSlave agent = new DumbSlave("agent" + j.jenkins.getNodes().size(), "/home/foo/agent", launcher); + agent.setMode(Mode.NORMAL); + agent.setRetentionStrategy(RetentionStrategy.INSTANCE); + j.jenkins.addNode(agent); + + Computer computer = agent.toComputer(); + try { + computer.connect(false).get(); + } catch (ExecutionException x) { + throw new AssertionError("failed to connect: " + computer.getLog(), x); + } + + assertThat(computer.getLog(), containsString("Agent successfully connected and online")); + + FreeStyleProject p = j.createFreeStyleProject(); + p.setAssignedNode(agent); + + try { + computer.disconnect(OfflineCause.create(null)).get(); + } catch (ExecutionException x) { + throw new AssertionError("failed to disconnect: " + computer.getLog(), x); + } + + // Wait for the real disconnection + Awaitility.await().atMost(Duration.ofSeconds(30)).until(() -> computer.getLog() + .contains("Connection terminated")); + + try { + computer.connect(true).get(); + } catch (ExecutionException x) { + throw new AssertionError("failed to connect: " + computer.getLog(), x); + } + + assertThat(computer.getLog(), containsString("Agent successfully connected and online")); + j.buildAndAssertSuccess(p); + } +} diff --git a/src/test/java/hudson/plugins/sshslaves/mina/MinaED25519ManualVerificationTest.java b/src/test/java/hudson/plugins/sshslaves/mina/MinaED25519ManualVerificationTest.java new file mode 100644 index 00000000..6f0f64f7 --- /dev/null +++ b/src/test/java/hudson/plugins/sshslaves/mina/MinaED25519ManualVerificationTest.java @@ -0,0 +1,127 @@ +package hudson.plugins.sshslaves.mina; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.cloudbees.jenkins.plugins.sshcredentials.impl.BasicSSHUserPrivateKey; +import com.cloudbees.plugins.credentials.CredentialsProvider; +import com.cloudbees.plugins.credentials.CredentialsScope; +import com.cloudbees.plugins.credentials.CredentialsStore; +import com.cloudbees.plugins.credentials.domains.Domain; +import hudson.model.Computer; +import hudson.model.FreeStyleProject; +import hudson.model.Node.Mode; +import hudson.slaves.DumbSlave; +import hudson.slaves.OfflineCause; +import hudson.slaves.RetentionStrategy; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Iterator; +import java.util.Objects; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import org.apache.commons.io.IOUtils; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.jvnet.hudson.test.JenkinsRule; +import org.jvnet.hudson.test.junit.jupiter.WithJenkins; +import org.testcontainers.containers.Container; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Testcontainers; + +/** + * Tests SSH connection with ED25519 manual host key verification. + * Ported from CloudBees ED25519SSHLauncherHostManualVerificationTest. + */ +@Timeout(value = 10, unit = TimeUnit.MINUTES) +@WithJenkins +@Testcontainers(disabledWithoutDocker = true) +class MinaED25519ManualVerificationTest { + + private JenkinsRule j; + + @BeforeEach + void setUp(JenkinsRule j) { + this.j = j; + } + + @Test + void sshConnectWithManualKeyVerification() throws Exception { + // Container is not static because we need to extract the host key before the test + try (GenericContainer sshContainer = MinaSSHContainerFactory.createContainer("ed25519")) { + sshContainer.start(); + String host = sshContainer.getHost(); + int port = sshContainer.getMappedPort(22); + + // Extract the dynamically generated ED25519 host key from the container + Container.ExecResult result = sshContainer.execInContainer("cat", "/etc/ssh/ssh_host_ed25519_key.pub"); + if (result.getExitCode() != 0) { + throw new AssertionError("Failed to retrieve host key: " + result.getStderr()); + } + String hostKey = result.getStdout().trim(); + + String privateKey = IOUtils.toString( + Objects.requireNonNull( + Thread.currentThread().getContextClassLoader().getResourceAsStream("rsa2048")), + StandardCharsets.UTF_8); + + Iterator stores = + CredentialsProvider.lookupStores(j.jenkins).iterator(); + assertTrue(stores.hasNext()); + CredentialsStore store = stores.next(); + + store.addCredentials( + Domain.global(), + new BasicSSHUserPrivateKey( + CredentialsScope.SYSTEM, + "simpleCredentials", + "foo", + new BasicSSHUserPrivateKey.DirectEntryPrivateKeySource(privateKey), + "theaustraliancricketteamisthebest", + null)); + + MinaSSHLauncher launcher = new MinaSSHLauncher(host, port, "simpleCredentials"); + launcher.setJavaPath("/usr/java/latest/bin/java"); + launcher.setServerKeyVerificationStrategy(new ManualKeyVerificationStrategy(hostKey)); + + DumbSlave agent = new DumbSlave("agent" + j.jenkins.getNodes().size(), "/home/foo/agent", launcher); + agent.setMode(Mode.NORMAL); + agent.setRetentionStrategy(RetentionStrategy.INSTANCE); + j.jenkins.addNode(agent); + + Computer computer = agent.toComputer(); + try { + computer.connect(false).get(); + } catch (ExecutionException x) { + throw new AssertionError("failed to connect: " + computer.getLog(), x); + } + + assertThat(computer.getLog(), containsString("Agent successfully connected and online")); + + FreeStyleProject p = j.createFreeStyleProject(); + p.setAssignedNode(agent); + + try { + computer.disconnect(OfflineCause.create(null)).get(); + } catch (ExecutionException x) { + throw new AssertionError("failed to disconnect: " + computer.getLog(), x); + } + + // Wait for the real disconnection + Awaitility.await().atMost(Duration.ofSeconds(30)).until(() -> computer.getLog() + .contains("Connection terminated")); + + try { + computer.connect(true).get(); + } catch (ExecutionException x) { + throw new AssertionError("failed to connect: " + computer.getLog(), x); + } + + assertThat(computer.getLog(), containsString("Agent successfully connected and online")); + j.buildAndAssertSuccess(p); + } + } +} diff --git a/src/test/java/hudson/plugins/sshslaves/mina/MinaRSAManualVerificationTest.java b/src/test/java/hudson/plugins/sshslaves/mina/MinaRSAManualVerificationTest.java new file mode 100644 index 00000000..02d177df --- /dev/null +++ b/src/test/java/hudson/plugins/sshslaves/mina/MinaRSAManualVerificationTest.java @@ -0,0 +1,127 @@ +package hudson.plugins.sshslaves.mina; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.cloudbees.jenkins.plugins.sshcredentials.impl.BasicSSHUserPrivateKey; +import com.cloudbees.plugins.credentials.CredentialsProvider; +import com.cloudbees.plugins.credentials.CredentialsScope; +import com.cloudbees.plugins.credentials.CredentialsStore; +import com.cloudbees.plugins.credentials.domains.Domain; +import hudson.model.Computer; +import hudson.model.FreeStyleProject; +import hudson.model.Node.Mode; +import hudson.slaves.DumbSlave; +import hudson.slaves.OfflineCause; +import hudson.slaves.RetentionStrategy; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Iterator; +import java.util.Objects; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import org.apache.commons.io.IOUtils; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.jvnet.hudson.test.JenkinsRule; +import org.jvnet.hudson.test.junit.jupiter.WithJenkins; +import org.testcontainers.containers.Container; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Testcontainers; + +/** + * Tests SSH connection with RSA manual host key verification. + * Ported from CloudBees RSASSHLauncherHostManualVerificationTest. + */ +@Timeout(value = 10, unit = TimeUnit.MINUTES) +@WithJenkins +@Testcontainers(disabledWithoutDocker = true) +class MinaRSAManualVerificationTest { + + private JenkinsRule j; + + @BeforeEach + void setUp(JenkinsRule j) { + this.j = j; + } + + @Test + void sshConnectWithManualKeyVerification() throws Exception { + // Container is not static because we need to extract the host key before the test + try (GenericContainer sshContainer = MinaSSHContainerFactory.createContainer("base")) { + sshContainer.start(); + String host = sshContainer.getHost(); + int port = sshContainer.getMappedPort(22); + + // Extract the dynamically generated RSA host key from the container + Container.ExecResult result = sshContainer.execInContainer("cat", "/etc/ssh/ssh_host_rsa_key.pub"); + if (result.getExitCode() != 0) { + throw new AssertionError("Failed to retrieve host key: " + result.getStderr()); + } + String hostKey = result.getStdout().trim(); + + String privateKey = IOUtils.toString( + Objects.requireNonNull( + Thread.currentThread().getContextClassLoader().getResourceAsStream("rsa2048")), + StandardCharsets.UTF_8); + + Iterator stores = + CredentialsProvider.lookupStores(j.jenkins).iterator(); + assertTrue(stores.hasNext()); + CredentialsStore store = stores.next(); + + store.addCredentials( + Domain.global(), + new BasicSSHUserPrivateKey( + CredentialsScope.SYSTEM, + "simpleCredentials", + "foo", + new BasicSSHUserPrivateKey.DirectEntryPrivateKeySource(privateKey), + "theaustraliancricketteamisthebest", + null)); + + MinaSSHLauncher launcher = new MinaSSHLauncher(host, port, "simpleCredentials"); + launcher.setJavaPath("/usr/java/latest/bin/java"); + launcher.setServerKeyVerificationStrategy(new ManualKeyVerificationStrategy(hostKey)); + + DumbSlave agent = new DumbSlave("agent" + j.jenkins.getNodes().size(), "/home/foo/agent", launcher); + agent.setMode(Mode.NORMAL); + agent.setRetentionStrategy(RetentionStrategy.INSTANCE); + j.jenkins.addNode(agent); + + Computer computer = agent.toComputer(); + try { + computer.connect(false).get(); + } catch (ExecutionException x) { + throw new AssertionError("failed to connect: " + computer.getLog(), x); + } + + assertThat(computer.getLog(), containsString("Agent successfully connected and online")); + + FreeStyleProject p = j.createFreeStyleProject(); + p.setAssignedNode(agent); + + try { + computer.disconnect(OfflineCause.create(null)).get(); + } catch (ExecutionException x) { + throw new AssertionError("failed to disconnect: " + computer.getLog(), x); + } + + // Wait for the real disconnection + Awaitility.await().atMost(Duration.ofSeconds(30)).until(() -> computer.getLog() + .contains("Connection terminated")); + + try { + computer.connect(true).get(); + } catch (ExecutionException x) { + throw new AssertionError("failed to connect: " + computer.getLog(), x); + } + + assertThat(computer.getLog(), containsString("Agent successfully connected and online")); + j.buildAndAssertSuccess(p); + } + } +} diff --git a/src/test/java/hudson/plugins/sshslaves/mina/MinaSSHContainerFactory.java b/src/test/java/hudson/plugins/sshslaves/mina/MinaSSHContainerFactory.java new file mode 100644 index 00000000..4673dbd1 --- /dev/null +++ b/src/test/java/hudson/plugins/sshslaves/mina/MinaSSHContainerFactory.java @@ -0,0 +1,21 @@ +package hudson.plugins.sshslaves.mina; + +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.images.builder.ImageFromDockerfile; + +/** + * Shared factory for creating SSH testcontainers for Mina SSH tests. + */ +class MinaSSHContainerFactory { + + static GenericContainer createContainer(String target) { + return new GenericContainer<>(new ImageFromDockerfile( + "localhost/testcontainers/mina-sshd-" + target, + Boolean.parseBoolean(System.getenv("CI"))) + .withFileFromClasspath(".", "/hudson/plugins/sshslaves/mina/docker") + .withTarget(target)) + .withExposedPorts(22); + } + + private MinaSSHContainerFactory() {} +} diff --git a/src/test/java/hudson/plugins/sshslaves/mina/MinaSSHLauncherCasCRoundTripTest.java b/src/test/java/hudson/plugins/sshslaves/mina/MinaSSHLauncherCasCRoundTripTest.java new file mode 100644 index 00000000..d01f2c40 --- /dev/null +++ b/src/test/java/hudson/plugins/sshslaves/mina/MinaSSHLauncherCasCRoundTripTest.java @@ -0,0 +1,42 @@ +package hudson.plugins.sshslaves.mina; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import hudson.model.Node; +import hudson.slaves.SlaveComputer; +import io.jenkins.plugins.casc.misc.junit.jupiter.AbstractRoundTripTest; +import org.jvnet.hudson.test.JenkinsRule; +import org.jvnet.hudson.test.junit.jupiter.WithJenkins; + +@WithJenkins +class MinaSSHLauncherCasCRoundTripTest extends AbstractRoundTripTest { + + @Override + protected void assertConfiguredAsExpected(JenkinsRule jenkins, String s) { + final Node node = jenkins.jenkins.getNode("mina-ssh-agent"); + assertNotNull(node); + + SlaveComputer computer = (SlaveComputer) node.toComputer(); + assertNotNull(computer); + + MinaSSHLauncher launcher = assertInstanceOf(MinaSSHLauncher.class, computer.getLauncher()); + + assertEquals("ssh-host", launcher.getHost()); + assertEquals(2222, launcher.getPort()); + assertEquals("-Xmx256m", launcher.getJvmOptions()); + assertEquals("creds", launcher.getCredentialsId()); + assertInstanceOf(BlindTrustVerificationStrategy.class, launcher.getServerKeyVerificationStrategy()); + } + + @Override + protected String stringInLogExpected() { + return "Setting class hudson.plugins.sshslaves.mina.MinaSSHLauncher.host = ssh-host"; + } + + @Override + protected String configResource() { + return "MinaSSHCasCConfig.yml"; + } +} diff --git a/src/test/java/hudson/plugins/sshslaves/mina/MinaSSHLauncherConnectionTest.java b/src/test/java/hudson/plugins/sshslaves/mina/MinaSSHLauncherConnectionTest.java new file mode 100644 index 00000000..edd7a6c3 --- /dev/null +++ b/src/test/java/hudson/plugins/sshslaves/mina/MinaSSHLauncherConnectionTest.java @@ -0,0 +1,101 @@ +package hudson.plugins.sshslaves.mina; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; + +import com.cloudbees.plugins.credentials.CredentialsScope; +import com.cloudbees.plugins.credentials.SystemCredentialsProvider; +import com.cloudbees.plugins.credentials.domains.Domain; +import com.cloudbees.plugins.credentials.impl.UsernamePasswordCredentialsImpl; +import hudson.model.Computer; +import hudson.model.FreeStyleProject; +import hudson.model.Node.Mode; +import hudson.slaves.DumbSlave; +import hudson.slaves.OfflineCause; +import hudson.slaves.RetentionStrategy; +import java.time.Duration; +import java.util.Collections; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.jvnet.hudson.test.JenkinsRule; +import org.jvnet.hudson.test.junit.jupiter.WithJenkins; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +/** + * Tests basic SSH connection with password credentials and reconnection behavior. + * Ported from CloudBees SSHLauncherTest. + */ +@Timeout(value = 10, unit = TimeUnit.MINUTES) +@WithJenkins +@Testcontainers(disabledWithoutDocker = true) +class MinaSSHLauncherConnectionTest { + + @Container + static GenericContainer sshContainer = MinaSSHContainerFactory.createContainer("base"); + + private JenkinsRule j; + + @BeforeEach + void setUp(JenkinsRule j) { + this.j = j; + } + + @Test + void logSurvivesReconnections() throws Exception { + String host = sshContainer.getHost(); + int port = sshContainer.getMappedPort(22); + + SystemCredentialsProvider.getInstance() + .getDomainCredentialsMap() + .put( + Domain.global(), + Collections.singletonList(new UsernamePasswordCredentialsImpl( + CredentialsScope.SYSTEM, "simpleCredentials", null, "foo", "beer"))); + + MinaSSHLauncher launcher = new MinaSSHLauncher(host, port, "simpleCredentials"); + launcher.setJavaPath("/usr/java/latest/bin/java"); + launcher.setServerKeyVerificationStrategy(new BlindTrustVerificationStrategy()); + + DumbSlave agent = new DumbSlave("agent" + j.jenkins.getNodes().size(), "/home/foo/agent", launcher); + agent.setMode(Mode.NORMAL); + agent.setRetentionStrategy(RetentionStrategy.INSTANCE); + j.jenkins.addNode(agent); + + Computer computer = agent.toComputer(); + try { + computer.connect(false).get(); + } catch (ExecutionException x) { + throw new AssertionError("failed to connect: " + computer.getLog(), x); + } + + assertThat(computer.getLog(), containsString("Agent successfully connected and online")); + + FreeStyleProject p = j.createFreeStyleProject(); + p.setAssignedNode(agent); + + try { + computer.disconnect(OfflineCause.create(null)).get(); + } catch (ExecutionException x) { + throw new AssertionError("failed to disconnect: " + computer.getLog(), x); + } + + // Wait for the real disconnection + Awaitility.await().atMost(Duration.ofSeconds(30)).until(() -> computer.getLog() + .contains("Connection terminated")); + + try { + computer.connect(true).get(); + } catch (ExecutionException x) { + throw new AssertionError("failed to connect: " + computer.getLog(), x); + } + + assertThat(computer.getLog(), containsString("Agent successfully connected and online")); + j.buildAndAssertSuccess(p); + } +} diff --git a/src/test/java/hudson/plugins/sshslaves/mina/MinaSSHLauncherWithKeyTest.java b/src/test/java/hudson/plugins/sshslaves/mina/MinaSSHLauncherWithKeyTest.java new file mode 100644 index 00000000..93e3851c --- /dev/null +++ b/src/test/java/hudson/plugins/sshslaves/mina/MinaSSHLauncherWithKeyTest.java @@ -0,0 +1,113 @@ +package hudson.plugins.sshslaves.mina; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; + +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.domains.Domain; +import hudson.model.Computer; +import hudson.model.FreeStyleProject; +import hudson.model.Node.Mode; +import hudson.slaves.DumbSlave; +import hudson.slaves.OfflineCause; +import hudson.slaves.RetentionStrategy; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Collections; +import java.util.Objects; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import org.apache.commons.io.IOUtils; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.jvnet.hudson.test.JenkinsRule; +import org.jvnet.hudson.test.junit.jupiter.WithJenkins; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +/** + * Tests SSH connection with private key authentication. + * Ported from CloudBees SSHLauncherWithKeyTest. + */ +@Timeout(value = 10, unit = TimeUnit.MINUTES) +@WithJenkins +@Testcontainers(disabledWithoutDocker = true) +class MinaSSHLauncherWithKeyTest { + + @Container + static GenericContainer sshContainer = MinaSSHContainerFactory.createContainer("base"); + + private JenkinsRule j; + + @BeforeEach + void setUp(JenkinsRule j) { + this.j = j; + } + + @Test + void sshConnectWithKey() throws Exception { + String host = sshContainer.getHost(); + int port = sshContainer.getMappedPort(22); + String privateKey = IOUtils.toString( + Objects.requireNonNull( + Thread.currentThread().getContextClassLoader().getResourceAsStream("rsa2048")), + StandardCharsets.UTF_8); + + SystemCredentialsProvider.getInstance() + .getDomainCredentialsMap() + .put( + Domain.global(), + Collections.singletonList(new BasicSSHUserPrivateKey( + CredentialsScope.SYSTEM, + "simpleCredentials", + "foo", + new BasicSSHUserPrivateKey.DirectEntryPrivateKeySource(privateKey), + "theaustraliancricketteamisthebest", + null))); + + MinaSSHLauncher launcher = new MinaSSHLauncher(host, port, "simpleCredentials"); + launcher.setJavaPath("/usr/java/latest/bin/java"); + launcher.setServerKeyVerificationStrategy(new TrustOnFirstUseVerificationStrategy(false)); + + DumbSlave agent = new DumbSlave("agent" + j.jenkins.getNodes().size(), "/home/foo/agent", launcher); + agent.setMode(Mode.NORMAL); + agent.setRetentionStrategy(RetentionStrategy.INSTANCE); + j.jenkins.addNode(agent); + + Computer computer = agent.toComputer(); + try { + computer.connect(false).get(); + } catch (ExecutionException x) { + throw new AssertionError("failed to connect: " + computer.getLog(), x); + } + + assertThat(computer.getLog(), containsString("Agent successfully connected and online")); + + FreeStyleProject p = j.createFreeStyleProject(); + p.setAssignedNode(agent); + + try { + computer.disconnect(OfflineCause.create(null)).get(); + } catch (ExecutionException x) { + throw new AssertionError("failed to disconnect: " + computer.getLog(), x); + } + + // Wait for the real disconnection + Awaitility.await().atMost(Duration.ofSeconds(30)).until(() -> computer.getLog() + .contains("Connection terminated")); + + try { + computer.connect(true).get(); + } catch (ExecutionException x) { + throw new AssertionError("failed to connect: " + computer.getLog(), x); + } + + assertThat(computer.getLog(), containsString("Agent successfully connected and online")); + j.buildAndAssertSuccess(p); + } +} diff --git a/src/test/resources/hudson/plugins/sshslaves/mina/MinaSSHCasCConfig.yml b/src/test/resources/hudson/plugins/sshslaves/mina/MinaSSHCasCConfig.yml new file mode 100644 index 00000000..11cb7ead --- /dev/null +++ b/src/test/resources/hudson/plugins/sshslaves/mina/MinaSSHCasCConfig.yml @@ -0,0 +1,13 @@ +jenkins: + nodes: + - permanent: + name: "mina-ssh-agent" + remoteFS: "/home/jenkins" + launcher: + sshMina: + host: ssh-host + port: 2222 + credentialsId: "creds" + jvmOptions: "-Xmx256m" + serverKeyVerificationStrategy: + minaBlindlyTrust: {} diff --git a/src/test/resources/hudson/plugins/sshslaves/mina/docker/Dockerfile b/src/test/resources/hudson/plugins/sshslaves/mina/docker/Dockerfile new file mode 100644 index 00000000..7f04c261 --- /dev/null +++ b/src/test/resources/hudson/plugins/sshslaves/mina/docker/Dockerfile @@ -0,0 +1,44 @@ +FROM ubuntu:22.04 AS base + +# install SSHD +RUN apt update -y && \ + apt install -y \ + openssh-server \ + locales \ + ucommon-utils \ + coreutils + +# Create an SSH user +RUN useradd -rm -d /home/foo -s /bin/bash -g root -G sudo -u 1000 foo +# Set the SSH user's password (replace "password" with your desired password) +RUN echo 'foo:beer' | chpasswd + +# force X25519 which is not FIPS compliant +RUN echo "KexAlgorithms curve25519-sha256,curve25519-sha256@libssh.org" >> /etc/ssh/sshd_config + +RUN echo 'HostKeyAlgorithms ssh-ed25519,rsa-sha2-512,rsa-sha2-256,ecdsa-sha2-nistp256' >> /etc/ssh/sshd_config + + +# Allow SSH access +RUN mkdir /var/run/sshd + +USER foo + +COPY --from=eclipse-temurin:21.0.9_10-jdk /opt/java/openjdk /usr/java/latest +# Expose the SSH port +EXPOSE 22 + +COPY rsa2048.pub.ssh rsa2048.pub.ssh +RUN mkdir /home/foo/.ssh/ +RUN cat rsa2048.pub.ssh >> /home/foo/.ssh/authorized_keys + +USER root + +# Start SSH server on container startup +CMD ["/usr/sbin/sshd", "-D"] + +FROM base AS ed25519 +RUN sed -i 's/HostKeyAlgorithms.*/HostKeyAlgorithms ssh-ed25519,rsa-sha2-512,rsa-sha2-256,ecdsa-sha2-nistp256/g' /etc/ssh/sshd_config + +FROM base AS fips +RUN sed -i 's/KexAlgorithms.*/KexAlgorithms diffie-hellman-group14-sha256,diffie-hellman-group-exchange-sha256/g' /etc/ssh/sshd_config diff --git a/src/test/resources/hudson/plugins/sshslaves/mina/docker/rsa2048.pub.ssh b/src/test/resources/hudson/plugins/sshslaves/mina/docker/rsa2048.pub.ssh new file mode 100644 index 00000000..c3edac4b --- /dev/null +++ b/src/test/resources/hudson/plugins/sshslaves/mina/docker/rsa2048.pub.ssh @@ -0,0 +1 @@ +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC3q6pmCe0kyFnhFzSHZutt3sncmPMjrf/wlpEApf57RRAf6raFR/gSbAWveJsC6A9SRvO6SlKcAPHOxakP3rg1Nbbcd9peg15F9rZBm7e/vFUIBFQaDzeaHfHRjPrPhSJsCErnILelZ9KqXzv7695cp8KvFCdF4XM5rEKGpv2yD9kx6QlXocqUtpqzqteknX+ACEz2rkOV/rXFjNn1ctNkhPYc97MmDTgMToiDIEeYM+CLgUySSj/dInHqa0xxv24MlZWezSnGwvpQL8KJiSoC/IiYpzbApLNIEC3k0iV8hSEBA0AKgxjP3jhcqvAgQ9/6zb7uew/95/lPFeE9Q37R diff --git a/src/test/resources/rsa2048 b/src/test/resources/rsa2048 new file mode 100644 index 00000000..ebd46d38 --- /dev/null +++ b/src/test/resources/rsa2048 @@ -0,0 +1,30 @@ +-----BEGIN ENCRYPTED PRIVATE KEY----- +MIIFJDBWBgkqhkiG9w0BBQ0wSTAxBgkqhkiG9w0BBQwwJAQQOLc7iLj3g03uXcZp +lQ+k7wICCAAwDAYIKoZIhvcNAgkFADAUBggqhkiG9w0DBwQIFSRgLbfugPgEggTI +nSQ13Z0m3okyCMg6gPJkrsT7fgES3d61yg0F5EVIY2iIrUXnu7RhjbpXiAMj9arc +QNJaHVOH9OyNhMd/djFXvKB0xLK+l+OF9vzipqFqBAWdyzRLIEmEHzmrF8qBRcCt +bJOkTbtLYOpZYIk0UEY4XwrN1X6kZupWItVOZxHOdORBrYgfAXXAzs1QA4NKzW0u +c21t/f+iaTvpSgCnLa83Xc20vkbI0VvNLkapimojXJennAjxBV1pm5R80crA2/HH +w4r8MZ7aoMJ96H/V09lLN35D+wqx3LAmF07u3nHW0QmhdH5lY8spzOSeqAYC1dPM +vTI8kRQ9xpjMzHY2DyE/FSSUxcVzz8czbVN+QvRvlxP8AYoedpsYyagTWbrUs0Fq +lVswNSAqbLxxrteMEquSbAtjz0sl2lK15FD1V+4CNzc6jOpmmeMA3duFYXTg6vb+ +ALxHHDtS1Y00b5gHnDPt3s/Uxpv6s4m7xvwZ3WztKaO9OC8rIanMK4lB+OxogPv9 +ai/HXItR1reCWDDnRU7uXfOkXRlAOKSPhSK3vgsYfP8TVc5arlenFOmUwAuwHE1x +CCFg58EKVEgTNKRfupr/63819XM9l/3MqLSEiprMb+aACYEGWCulSWEcF2xq1AOE +uuvzhhXfuzYfSg/Qg7uZRb96M0/Or5sxRY4SUct8Ml/aHd3+2TZLEZGETBGXZi7E +O2MB8mO759NToFcG2wXvcPQ1zzIyj/en37yf1zuYQjEFzis2uNA/FNg3i2gdmbxR +XlEs/Ixe3HFsETJIlgWHHzC4HHtzEvzEKOvYd4qLyU8nQEMfNSk0YCUDyGYrmOub +u0Mq1UCgzVxoJxBqUiOtJkzzwCgxkjCGFfI781zS4juViAiIv12VG0hF5TRRZwPy +VvnH1i8vDkjFa2eXkA5/XYpO4euDN0DW77vOg6XRoPJewzmsvcykTfTj8H4XvtA9 +BUxSEqd4Y++eMeQFLQ/CVrKmzAEhT2pYhWdlJ+y5PchHy5BE+HyjUxmWIVozvrBg +MXi538qFRykPZC60hDP66QFwYep2xlcDwpEdN5Wtsqur32GUzSvEV7Nl7NXouj5y +M9XiC38ze6+mgOQJqDWBcTQGZxI4AxBXTq3HPYsibFzWX8195+wJSYX/M9GaHrRN +brWuzCxAJJYZ5JMP0zY9eVQfz/BKg6V78vdKuQEm1R2+5j57tR4CkpTumYlaqGzp +L/m7LJC8ssQGMn23hoodcOGO760CXa3O5vA2zfr1jkUd56Rc0SRG8Mxgypv0JM5G ++BAmD71rbttdhNxBlfe9a3XTW0MJqSzoOKYoVMAnvyhdvSGtIZYGDM0WD0fTrK15 +DbbvyWao74VFHPjVA0Fuo2vvdzLH+pZeXA19mYK7yMw5vsveroD7fk7Z05DYuHpb +pnO9vIFns3/PhBm7/IcBBgOX/b+tHLTd0jf+TeCrIn5hZXMz5AhjLpeVxux1O8Yd +wOajN9cywUZ/GuGhU1XxbkPieouIFgchS1MF92gmr6LX8T9kqEJfuAALceJ8FlwP +A7k878FhY9HinPMyNtw3G+wx1FdiqUXoP2dh7URFjTSjZPl2GDEnQO05HeJ+NBeD +3tJ10eVgpVEBaOF9Uru6LROnpZYjdyww +-----END ENCRYPTED PRIVATE KEY-----