This is an automated email from the ASF dual-hosted git repository.
yuluo-yx pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/hertzbeat.git
The following commit(s) were added to refs/heads/master by this push:
new e57c728364 feat: fix ssh connection bug (#4198)
e57c728364 is described below
commit e57c728364545f069c5cb8085ce82f8ac5b7aa02
Author: shown <[email protected]>
AuthorDate: Sun Jul 12 16:56:05 2026 +0800
feat: fix ssh connection bug (#4198)
---
.../collector/collect/ssh/SshCollectImpl.java | 62 ++-
.../collector/collect/ssh/SshCollectImplTest.java | 167 +++++++
.../collect/common/ssh/CommonSshClient.java | 15 +-
.../collect/common/ssh/SshCircuitBreaker.java | 102 ++++
.../collector/collect/common/ssh/SshHelper.java | 483 +++++++++++++-----
.../collect/common/ssh/CommonSshClientTest.java | 22 +-
.../collect/common/ssh/SshCircuitBreakerTest.java | 111 +++++
.../collect/common/ssh/SshHelperTest.java | 551 +++++++++++++++++++++
8 files changed, 1344 insertions(+), 169 deletions(-)
diff --git
a/hertzbeat-collector/hertzbeat-collector-basic/src/main/java/org/apache/hertzbeat/collector/collect/ssh/SshCollectImpl.java
b/hertzbeat-collector/hertzbeat-collector-basic/src/main/java/org/apache/hertzbeat/collector/collect/ssh/SshCollectImpl.java
index 870d912558..41b2987446 100644
---
a/hertzbeat-collector/hertzbeat-collector-basic/src/main/java/org/apache/hertzbeat/collector/collect/ssh/SshCollectImpl.java
+++
b/hertzbeat-collector/hertzbeat-collector-basic/src/main/java/org/apache/hertzbeat/collector/collect/ssh/SshCollectImpl.java
@@ -19,6 +19,7 @@ package org.apache.hertzbeat.collector.collect.ssh;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
+import java.io.InterruptedIOException;
import java.net.ConnectException;
import java.net.SocketTimeoutException;
import java.security.GeneralSecurityException;
@@ -32,8 +33,6 @@ import java.util.Objects;
import java.util.stream.Collectors;
import lombok.extern.slf4j.Slf4j;
import org.apache.hertzbeat.collector.collect.AbstractCollect;
-import org.apache.hertzbeat.collector.collect.common.cache.CacheIdentifier;
-import
org.apache.hertzbeat.collector.collect.common.cache.GlobalConnectionCache;
import org.apache.hertzbeat.collector.collect.common.ssh.CommonSshBlacklist;
import org.apache.hertzbeat.collector.collect.common.ssh.SshHelper;
import org.apache.hertzbeat.collector.constants.CollectorConstants;
@@ -49,6 +48,7 @@ import org.apache.sshd.client.channel.ClientChannelEvent;
import org.apache.sshd.client.session.ClientSession;
import org.apache.sshd.common.SshException;
import org.apache.sshd.common.channel.exception.SshChannelOpenException;
+import org.apache.sshd.common.future.CloseFuture;
import org.apache.sshd.common.util.io.output.NoCloseOutputStream;
import org.springframework.util.StringUtils;
@@ -64,7 +64,6 @@ public class SshCollectImpl extends AbstractCollect {
private static final String PARSE_TYPE_LOG = "log";
private static final int DEFAULT_TIMEOUT = 10_000;
- private final GlobalConnectionCache connectionCommonCache =
GlobalConnectionCache.getInstance();
@Override
public void preCheck(Metrics metrics) throws IllegalArgumentException {
@@ -79,7 +78,8 @@ public class SshCollectImpl extends AbstractCollect {
long startTime = System.currentTimeMillis();
SshProtocol sshProtocol = metrics.getSsh();
boolean reuseConnection =
Boolean.parseBoolean(sshProtocol.getReuseConnection());
- boolean useProxy = Boolean.parseBoolean(sshProtocol.getUseProxy());
+ boolean useProxy = Boolean.parseBoolean(sshProtocol.getUseProxy())
+ && StringUtils.hasText(sshProtocol.getProxyHost());
int timeout = CollectUtil.getTimeout(sshProtocol.getTimeout(),
DEFAULT_TIMEOUT);
ClientChannel channel = null;
ClientSession clientSession = null;
@@ -147,17 +147,14 @@ public class SshCollectImpl extends AbstractCollect {
builder.setCode(CollectRep.Code.FAIL);
builder.setMsg(errorMsg);
} finally {
- if (channel != null && channel.isOpen()) {
- try {
- // Close the SSH channel with the 'false' parameter to
ensure the session is not kept alive.
- long st = System.currentTimeMillis();
- channel.close(false).addListener(future ->
- log.debug("channel is closed in {} ms",
System.currentTimeMillis() - st));
- } catch (Exception e) {
- log.error(e.getMessage(), e);
- }
+ boolean channelClosed = true;
+ try {
+ channelClosed = closeChannel(channel, timeout);
+ } catch (Exception e) {
+ channelClosed = false;
+ log.error("Failed to close SSH channel", e);
}
- if (clientSession != null && !reuseConnection && !useProxy) {
+ if (clientSession != null && (!channelClosed || (!reuseConnection
&& !useProxy))) {
try {
clientSession.close();
} catch (Exception e) {
@@ -167,6 +164,35 @@ public class SshCollectImpl extends AbstractCollect {
}
}
+ static boolean closeChannel(ClientChannel channel, int timeout) throws
IOException {
+ if (channel == null || channel.isClosed()) {
+ return true;
+ }
+ long startTime = System.currentTimeMillis();
+ try {
+ CloseFuture closeFuture = channel.close(false);
+ if (!closeFuture.await(timeout)) {
+ log.warn("SSH channel graceful close timed out after {} ms,
forcing local cleanup", timeout);
+ closeFuture = channel.close(true);
+ if (!closeFuture.await(timeout)) {
+ log.warn("SSH channel immediate close timed out after {}
ms", timeout);
+ return false;
+ }
+ }
+ } catch (InterruptedIOException e) {
+ try {
+ channel.close(true);
+ } catch (RuntimeException closeException) {
+ e.addSuppressed(closeException);
+ } finally {
+ Thread.currentThread().interrupt();
+ }
+ throw e;
+ }
+ log.debug("SSH channel closed in {} ms", System.currentTimeMillis() -
startTime);
+ return true;
+ }
+
@Override
public String supportProtocol() {
return DispatchConstants.PROTOCOL_SSH;
@@ -273,14 +299,6 @@ public class SshCollectImpl extends AbstractCollect {
}
}
- private void removeConnectSessionCache(SshProtocol sshProtocol) {
- CacheIdentifier identifier = CacheIdentifier.builder()
- .ip(sshProtocol.getHost()).port(sshProtocol.getPort())
-
.username(sshProtocol.getUsername()).password(sshProtocol.getPassword())
- .build();
- connectionCommonCache.removeCache(identifier);
- }
-
private ClientSession getConnectSession(SshProtocol sshProtocol, int
timeout, boolean reuseConnection, boolean useProxy)
throws IOException, GeneralSecurityException {
return SshHelper.getConnectSession(sshProtocol, timeout,
reuseConnection, useProxy);
diff --git
a/hertzbeat-collector/hertzbeat-collector-basic/src/test/java/org/apache/hertzbeat/collector/collect/ssh/SshCollectImplTest.java
b/hertzbeat-collector/hertzbeat-collector-basic/src/test/java/org/apache/hertzbeat/collector/collect/ssh/SshCollectImplTest.java
index 466d1b9c6f..2810bb354d 100644
---
a/hertzbeat-collector/hertzbeat-collector-basic/src/test/java/org/apache/hertzbeat/collector/collect/ssh/SshCollectImplTest.java
+++
b/hertzbeat-collector/hertzbeat-collector-basic/src/test/java/org/apache/hertzbeat/collector/collect/ssh/SshCollectImplTest.java
@@ -19,14 +19,32 @@ package org.apache.hertzbeat.collector.collect.ssh;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import java.io.IOException;
+import java.io.InterruptedIOException;
+import java.util.List;
+import org.apache.hertzbeat.collector.collect.common.ssh.SshHelper;
import org.apache.hertzbeat.collector.dispatch.DispatchConstants;
import org.apache.hertzbeat.common.entity.job.Metrics;
import org.apache.hertzbeat.common.entity.job.protocol.SshProtocol;
import org.apache.hertzbeat.common.entity.message.CollectRep;
+import org.apache.sshd.client.channel.ChannelExec;
+import org.apache.sshd.client.channel.ClientChannel;
+import org.apache.sshd.client.future.OpenFuture;
+import org.apache.sshd.client.session.ClientSession;
+import org.apache.sshd.common.SshException;
+import org.apache.sshd.common.future.CloseFuture;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
/**
* Test case for {@link SshCollectImpl}
@@ -74,4 +92,153 @@ class SshCollectImplTest {
void supportProtocol() {
assertEquals(DispatchConstants.PROTOCOL_SSH,
sshCollect.supportProtocol());
}
+
+ @Test
+ void closeChannelWaitsForGracefulClose() throws IOException {
+ ClientChannel channel = mock(ClientChannel.class);
+ CloseFuture closeFuture = mock(CloseFuture.class);
+ when(channel.close(false)).thenReturn(closeFuture);
+ when(closeFuture.await(1_000)).thenReturn(true);
+
+ assertTrue(SshCollectImpl.closeChannel(channel, 1_000));
+ verify(channel).close(false);
+ verify(channel, never()).close(true);
+ }
+
+ @Test
+ void closeChannelForcesCleanupAfterGracefulTimeout() throws IOException {
+ ClientChannel channel = mock(ClientChannel.class);
+ CloseFuture gracefulClose = mock(CloseFuture.class);
+ CloseFuture immediateClose = mock(CloseFuture.class);
+ when(channel.close(false)).thenReturn(gracefulClose);
+ when(gracefulClose.await(1_000)).thenReturn(false);
+ when(channel.close(true)).thenReturn(immediateClose);
+ when(immediateClose.await(1_000)).thenReturn(true);
+
+ assertTrue(SshCollectImpl.closeChannel(channel, 1_000));
+ verify(channel).close(false);
+ verify(channel).close(true);
+ }
+
+ @Test
+ void closeChannelForcesCleanupAndPreservesInterrupt() throws IOException {
+ ClientChannel channel = mock(ClientChannel.class);
+ CloseFuture gracefulClose = mock(CloseFuture.class);
+ when(channel.close(false)).thenReturn(gracefulClose);
+ when(gracefulClose.await(1_000)).thenThrow(new
InterruptedIOException("interrupted"));
+
+ try {
+ assertThrows(InterruptedIOException.class,
+ () -> SshCollectImpl.closeChannel(channel, 1_000));
+ assertTrue(Thread.currentThread().isInterrupted());
+ verify(channel).close(true);
+ } finally {
+ Thread.interrupted();
+ }
+ }
+
+ @Test
+ void closeChannelSkipsAlreadyClosedChannel() throws IOException {
+ ClientChannel channel = mock(ClientChannel.class);
+ when(channel.isClosed()).thenReturn(true);
+
+ assertTrue(SshCollectImpl.closeChannel(channel, 1_000));
+ verify(channel, never()).close(false);
+ verify(channel, never()).close(true);
+ }
+
+ @Test
+ void closeChannelSuppressesForcedCloseFailureAndPreservesInterrupt()
throws IOException {
+ ClientChannel channel = mock(ClientChannel.class);
+ CloseFuture gracefulClose = mock(CloseFuture.class);
+ InterruptedIOException interrupted = new
InterruptedIOException("interrupted");
+ IllegalStateException closeFailure = new IllegalStateException("force
close failed");
+ when(channel.close(false)).thenReturn(gracefulClose);
+ when(gracefulClose.await(1_000)).thenThrow(interrupted);
+ when(channel.close(true)).thenThrow(closeFailure);
+
+ try {
+ InterruptedIOException thrown =
assertThrows(InterruptedIOException.class,
+ () -> SshCollectImpl.closeChannel(channel, 1_000));
+ assertSame(interrupted, thrown);
+ assertEquals(List.of(closeFailure),
List.of(thrown.getSuppressed()));
+ assertTrue(Thread.currentThread().isInterrupted());
+ } finally {
+ Thread.interrupted();
+ }
+ }
+
+ @Test
+ void collectFinallyAwaitsGracefulChannelClose() throws Exception {
+ int timeout = 1_000;
+ SshProtocol protocol = protocol(timeout);
+ Metrics metrics = Metrics.builder().ssh(protocol).build();
+ ClientSession clientSession = mock(ClientSession.class);
+ ChannelExec channel = mock(ChannelExec.class);
+ OpenFuture openFuture = mock(OpenFuture.class);
+ CloseFuture closeFuture = mock(CloseFuture.class);
+ when(clientSession.createExecChannel("echo ok")).thenReturn(channel);
+ when(channel.open()).thenReturn(openFuture);
+ when(openFuture.verify(timeout)).thenThrow(new SshException("channel
open failed"));
+ when(channel.close(false)).thenReturn(closeFuture);
+ when(closeFuture.await(timeout)).thenReturn(true);
+
+ try (MockedStatic<SshHelper> sshHelper = mockStatic(SshHelper.class)) {
+ sshHelper.when(() -> SshHelper.getConnectSession(protocol,
timeout, true, false))
+ .thenReturn(clientSession);
+
+ sshCollect.collect(builder, metrics);
+ }
+
+ assertEquals(CollectRep.Code.UN_CONNECTABLE, builder.getCode());
+ verify(channel).close(false);
+ verify(closeFuture).await(timeout);
+ verify(channel, never()).close(true);
+ verify(clientSession, never()).close();
+ }
+
+ @Test
+ void collectClosesExactSessionWhenChannelCannotClose() throws Exception {
+ int timeout = 1_000;
+ SshProtocol protocol = protocol(timeout);
+ Metrics metrics = Metrics.builder().ssh(protocol).build();
+ ClientSession clientSession = mock(ClientSession.class);
+ ChannelExec channel = mock(ChannelExec.class);
+ OpenFuture openFuture = mock(OpenFuture.class);
+ CloseFuture gracefulClose = mock(CloseFuture.class);
+ CloseFuture immediateClose = mock(CloseFuture.class);
+ when(clientSession.createExecChannel("echo ok")).thenReturn(channel);
+ when(channel.open()).thenReturn(openFuture);
+ when(openFuture.verify(timeout)).thenThrow(new SshException("channel
open failed"));
+ when(channel.close(false)).thenReturn(gracefulClose);
+ when(gracefulClose.await(timeout)).thenReturn(false);
+ when(channel.close(true)).thenReturn(immediateClose);
+ when(immediateClose.await(timeout)).thenReturn(false);
+
+ try (MockedStatic<SshHelper> sshHelper = mockStatic(SshHelper.class)) {
+ sshHelper.when(() -> SshHelper.getConnectSession(protocol,
timeout, true, false))
+ .thenReturn(clientSession);
+
+ sshCollect.collect(builder, metrics);
+ }
+
+ verify(channel).close(false);
+ verify(gracefulClose).await(timeout);
+ verify(channel).close(true);
+ verify(immediateClose).await(timeout);
+ verify(clientSession).close();
+ }
+
+ private SshProtocol protocol(int timeout) {
+ return SshProtocol.builder()
+ .host("target.example.com")
+ .port("22")
+ .username("root")
+ .password("password")
+ .timeout(String.valueOf(timeout))
+ .reuseConnection("true")
+ .useProxy("false")
+ .script("echo ok")
+ .build();
+ }
}
diff --git
a/hertzbeat-collector/hertzbeat-collector-common/src/main/java/org/apache/hertzbeat/collector/collect/common/ssh/CommonSshClient.java
b/hertzbeat-collector/hertzbeat-collector-common/src/main/java/org/apache/hertzbeat/collector/collect/common/ssh/CommonSshClient.java
index 4387415dd3..03ca9b1ec8 100644
---
a/hertzbeat-collector/hertzbeat-collector-common/src/main/java/org/apache/hertzbeat/collector/collect/common/ssh/CommonSshClient.java
+++
b/hertzbeat-collector/hertzbeat-collector-common/src/main/java/org/apache/hertzbeat/collector/collect/common/ssh/CommonSshClient.java
@@ -17,12 +17,12 @@
package org.apache.hertzbeat.collector.collect.common.ssh;
+import java.time.Duration;
import lombok.extern.slf4j.Slf4j;
import org.apache.sshd.client.ClientBuilder;
import org.apache.sshd.client.SshClient;
import org.apache.sshd.client.keyverifier.AcceptAllServerKeyVerifier;
import org.apache.sshd.common.NamedFactory;
-import org.apache.sshd.common.PropertyResolverUtils;
import org.apache.sshd.common.kex.BuiltinDHFactories;
import org.apache.sshd.core.CoreModuleProperties;
import org.apache.sshd.server.forward.AcceptAllForwardingFilter;
@@ -34,19 +34,16 @@ import
org.apache.sshd.server.forward.AcceptAllForwardingFilter;
public class CommonSshClient {
private static final SshClient SSH_CLIENT;
-
+
static {
SSH_CLIENT = SshClient.setUpDefaultClient();
// accept all server key verifier, will print warn log : Server at {}
presented unverified {} key: {}
AcceptAllServerKeyVerifier verifier =
AcceptAllServerKeyVerifier.INSTANCE;
SSH_CLIENT.setServerKeyVerifier(verifier);
- // set connection heartbeat interval time 2000ms, wait for heartbeat
response timeout 300_000ms
- PropertyResolverUtils.updateProperty(
- SSH_CLIENT, CoreModuleProperties.HEARTBEAT_INTERVAL.getName(),
2000);
- PropertyResolverUtils.updateProperty(
- SSH_CLIENT,
CoreModuleProperties.HEARTBEAT_NO_REPLY_MAX.getName(), 30);
- PropertyResolverUtils.updateProperty(
- SSH_CLIENT, CoreModuleProperties.SOCKET_KEEPALIVE.getName(),
true);
+ // Reduce heartbeat overhead while keeping unresponsive sessions
bounded to about 90 seconds.
+ CoreModuleProperties.HEARTBEAT_INTERVAL.set(SSH_CLIENT,
Duration.ofSeconds(30));
+ CoreModuleProperties.HEARTBEAT_NO_REPLY_MAX.set(SSH_CLIENT, 2);
+ CoreModuleProperties.SOCKET_KEEPALIVE.set(SSH_CLIENT, true);
// set support all KeyExchange
SSH_CLIENT.setKeyExchangeFactories(NamedFactory.setUpTransformedFactories(
false,
diff --git
a/hertzbeat-collector/hertzbeat-collector-common/src/main/java/org/apache/hertzbeat/collector/collect/common/ssh/SshCircuitBreaker.java
b/hertzbeat-collector/hertzbeat-collector-common/src/main/java/org/apache/hertzbeat/collector/collect/common/ssh/SshCircuitBreaker.java
new file mode 100644
index 0000000000..f69c1fa5ee
--- /dev/null
+++
b/hertzbeat-collector/hertzbeat-collector-common/src/main/java/org/apache/hertzbeat/collector/collect/common/ssh/SshCircuitBreaker.java
@@ -0,0 +1,102 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hertzbeat.collector.collect.common.ssh;
+
+import com.google.common.base.Ticker;
+import com.google.common.cache.Cache;
+import com.google.common.cache.CacheBuilder;
+import com.google.common.util.concurrent.Striped;
+import java.io.IOException;
+import java.time.Duration;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.locks.Lock;
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * Stops repeated SSH connection attempts after consecutive authentication
failures.
+ */
+@Slf4j
+final class SshCircuitBreaker {
+
+ static final int MAX_FAILURES = 3;
+ static final Duration COOLDOWN = Duration.ofMinutes(5);
+
+ private final int maxFailures;
+ private final Cache<Target, Integer> failureCounts;
+ private final Striped<Lock> connectionLocks;
+
+ SshCircuitBreaker() {
+ this(MAX_FAILURES, COOLDOWN, Ticker.systemTicker());
+ }
+
+ SshCircuitBreaker(int maxFailures, Duration cooldown, Ticker ticker) {
+ if (maxFailures <= 0) {
+ throw new IllegalArgumentException("maxFailures must be positive");
+ }
+ if (cooldown.isZero() || cooldown.isNegative()) {
+ throw new IllegalArgumentException("cooldown must be positive");
+ }
+ this.maxFailures = maxFailures;
+ this.failureCounts = CacheBuilder.newBuilder()
+ .expireAfterWrite(cooldown.toNanos(), TimeUnit.NANOSECONDS)
+ .ticker(ticker)
+ .build();
+ this.connectionLocks = Striped.lock(1_024);
+ }
+
+ Target buildTarget(String host, String port, String username, List<String>
credentials) {
+ return new Target(host, port, username, List.copyOf(credentials));
+ }
+
+ Lock connectionLock(Target target) {
+ return connectionLocks.get(target);
+ }
+
+ void checkOpen(Target target) throws IOException {
+ Integer failureCount = failureCounts.getIfPresent(target);
+ if (failureCount != null && failureCount >= maxFailures) {
+ throw new IOException("SSH authentication is temporarily blocked
for " + target.displayName()
+ + " after " + failureCount + " consecutive failures");
+ }
+ }
+
+ void recordFailure(Target target) {
+ int failureCount = failureCounts.asMap().merge(target, 1,
Integer::sum);
+ if (failureCount == maxFailures) {
+ log.warn("SSH authentication is temporarily blocked for {} after
{} consecutive failures",
+ target.displayName(), failureCount);
+ }
+ }
+
+ void recordSuccess(Target target) {
+ failureCounts.invalidate(target);
+ }
+
+ record Target(String host, String port, String username, List<String>
credentials) {
+
+ private String displayName() {
+ return username + "@" + host + ":" + port;
+ }
+
+ @Override
+ public String toString() {
+ return displayName();
+ }
+ }
+}
diff --git
a/hertzbeat-collector/hertzbeat-collector-common/src/main/java/org/apache/hertzbeat/collector/collect/common/ssh/SshHelper.java
b/hertzbeat-collector/hertzbeat-collector-common/src/main/java/org/apache/hertzbeat/collector/collect/common/ssh/SshHelper.java
index 70094feea7..7db6e3d79c 100644
---
a/hertzbeat-collector/hertzbeat-collector-common/src/main/java/org/apache/hertzbeat/collector/collect/common/ssh/SshHelper.java
+++
b/hertzbeat-collector/hertzbeat-collector-common/src/main/java/org/apache/hertzbeat/collector/collect/common/ssh/SshHelper.java
@@ -17,29 +17,39 @@
package org.apache.hertzbeat.collector.collect.common.ssh;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
import java.io.InputStream;
+import java.io.InterruptedIOException;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.security.GeneralSecurityException;
import java.security.KeyPair;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.ArrayList;
+import java.util.HexFormat;
import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantReadWriteLock;
import lombok.extern.slf4j.Slf4j;
import org.apache.hertzbeat.collector.collect.common.cache.AbstractConnection;
import org.apache.hertzbeat.collector.collect.common.cache.CacheIdentifier;
import
org.apache.hertzbeat.collector.collect.common.cache.GlobalConnectionCache;
import org.apache.hertzbeat.collector.collect.common.cache.SshConnect;
-import org.apache.hertzbeat.collector.util.PrivateKeyUtils;
import org.apache.hertzbeat.common.entity.job.protocol.SshProtocol;
import org.apache.sshd.client.SshClient;
import org.apache.sshd.client.config.hosts.HostConfigEntry;
import org.apache.sshd.client.session.ClientSession;
+import org.apache.sshd.common.RuntimeSshException;
+import org.apache.sshd.common.SshConstants;
+import org.apache.sshd.common.SshException;
import org.apache.sshd.common.config.keys.FilePasswordProvider;
import org.apache.sshd.common.util.security.SecurityUtils;
import org.springframework.util.StringUtils;
-import java.io.FileInputStream;
-import java.io.IOException;
-import java.security.GeneralSecurityException;
-import java.util.Optional;
-import java.util.concurrent.TimeUnit;
-
/**
* ssh helper
*/
@@ -47,160 +57,377 @@ import java.util.concurrent.TimeUnit;
public class SshHelper {
private static final GlobalConnectionCache CONNECTION_COMMON_CACHE =
GlobalConnectionCache.getInstance();
+ private static final SshCircuitBreaker SSH_CIRCUIT_BREAKER = new
SshCircuitBreaker();
+ private static final ReentrantReadWriteLock CLIENT_IDENTITY_LOCK = new
ReentrantReadWriteLock(true);
public static ClientSession getConnectSession(String host, String port,
String username, String password, String privateKey,
String privateKeyPassphrase,
int timeout, boolean reuseConnection)
throws IOException, GeneralSecurityException {
- CacheIdentifier identifier = CacheIdentifier.builder()
- .ip(host).port(port)
- .username(username).password(password)
+ SshProtocol sshProtocol = SshProtocol.builder()
+ .host(host)
+ .port(port)
+ .username(username)
+ .password(password)
+ .privateKey(privateKey)
+ .privateKeyPassphrase(privateKeyPassphrase)
.build();
- ClientSession clientSession = null;
- if (reuseConnection) {
- Optional<AbstractConnection<?>> cacheOption =
CONNECTION_COMMON_CACHE.getCache(identifier, true);
- if (cacheOption.isPresent()) {
- SshConnect sshConnect = (SshConnect) cacheOption.get();
- clientSession = sshConnect.getConnection();
- try {
- if (clientSession == null || clientSession.isClosed() ||
clientSession.isClosing()) {
- clientSession = null;
- CONNECTION_COMMON_CACHE.removeCache(identifier);
- }
- } catch (Exception e) {
- log.warn(e.getMessage());
- clientSession = null;
- CONNECTION_COMMON_CACHE.removeCache(identifier);
- }
- }
+ return getConnectSession(sshProtocol, timeout, reuseConnection, false);
+ }
+
+ public static ClientSession getConnectSession(SshProtocol sshProtocol, int
timeout, boolean reuseConnection, boolean useProxy)
+ throws IOException, GeneralSecurityException {
+ boolean proxyEnabled = useProxy &&
StringUtils.hasText(sshProtocol.getProxyHost());
+ boolean cacheConnection = reuseConnection || proxyEnabled;
+ List<String> targetIdentity = connectionIdentity(
+ sshProtocol.getPassword(), sshProtocol.getPrivateKey(),
sshProtocol.getPrivateKeyPassphrase());
+ List<String> connectionIdentity = connectionIdentity(targetIdentity,
sshProtocol, proxyEnabled);
+ CacheIdentifier identifier = buildCacheIdentifier(
+ sshProtocol.getHost(), sshProtocol.getPort(),
sshProtocol.getUsername(), connectionIdentity);
+ SshCircuitBreaker.Target targetCircuit =
SSH_CIRCUIT_BREAKER.buildTarget(
+ sshProtocol.getHost(), sshProtocol.getPort(),
sshProtocol.getUsername(), targetIdentity);
+ SshCircuitBreaker.Target proxyCircuit = proxyEnabled
+ ? SSH_CIRCUIT_BREAKER.buildTarget(
+ sshProtocol.getProxyHost(),
sshProtocol.getProxyPort(), sshProtocol.getProxyUsername(),
+ proxyIdentity(sshProtocol))
+ : null;
+ // Keep the final ProxyJump session cached with its intermediate
session chain.
+ if (cacheConnection) {
+ ClientSession clientSession = getCachedSession(identifier);
if (clientSession != null) {
return clientSession;
}
}
- SshClient sshClient = CommonSshClient.getSshClient();
- clientSession = sshClient.connect(username, host,
Integer.parseInt(port))
- .verify(timeout, TimeUnit.MILLISECONDS).getSession();
- if (StringUtils.hasText(password)) {
- clientSession.addPasswordIdentity(password);
- } else if (StringUtils.hasText(privateKey)) {
- var resourceKey = PrivateKeyUtils.writePrivateKey(host,
privateKey);
- FilePasswordProvider passwordProvider = (session, resource, index)
-> {
- if (StringUtils.hasText(privateKeyPassphrase)) {
- return privateKeyPassphrase;
+ Lock connectionLock =
SSH_CIRCUIT_BREAKER.connectionLock(targetCircuit);
+ lockInterruptibly(connectionLock, "SSH connection");
+ try {
+ if (cacheConnection) {
+ ClientSession clientSession = getCachedSession(identifier);
+ if (clientSession != null) {
+ return clientSession;
}
- return null;
- };
- SecurityUtils.loadKeyPairIdentities(null, () -> resourceKey, new
FileInputStream(resourceKey), passwordProvider)
- .forEach(clientSession::addPublicKeyIdentity);
- } // else auth with localhost private public key certificates
+ }
+ SSH_CIRCUIT_BREAKER.checkOpen(targetCircuit);
+ if (proxyCircuit != null) {
+ SSH_CIRCUIT_BREAKER.checkOpen(proxyCircuit);
+ }
+ SshClient sshClient = CommonSshClient.getSshClient();
+ ClientSession clientSession = proxyEnabled
+ ? connectThroughProxy(sshClient, sshProtocol, timeout,
proxyCircuit)
+ : connectDirect(sshClient, sshProtocol.getUsername(),
sshProtocol.getHost(),
+ Integer.parseInt(sshProtocol.getPort()), timeout);
+ return authenticate(clientSession, sshProtocol.getHost(),
sshProtocol.getPassword(),
+ sshProtocol.getPrivateKey(),
sshProtocol.getPrivateKeyPassphrase(),
+ timeout, cacheConnection, identifier, targetCircuit);
+ } finally {
+ connectionLock.unlock();
+ }
+ }
- // auth
- if (!clientSession.auth().verify(timeout,
TimeUnit.MILLISECONDS).isSuccess()) {
- clientSession.close();
- throw new IllegalArgumentException("ssh auth failed.");
+ private static ClientSession authenticate(
+ ClientSession clientSession, String host, String password, String
privateKey,
+ String privateKeyPassphrase, int timeout, boolean cacheConnection,
+ CacheIdentifier identifier, SshCircuitBreaker.Target circuitTarget)
+ throws IOException, GeneralSecurityException {
+ boolean authenticated = false;
+ boolean identityReady = false;
+ try {
+ addIdentity(clientSession, host, password, privateKey,
privateKeyPassphrase);
+ identityReady = true;
+ clientSession.auth().verify(timeout, TimeUnit.MILLISECONDS);
+ authenticated = true;
+ SSH_CIRCUIT_BREAKER.recordSuccess(circuitTarget);
+ if (cacheConnection) {
+ CONNECTION_COMMON_CACHE.addCache(identifier, new
SshConnect(clientSession));
+ }
+ return clientSession;
+ } catch (IOException | GeneralSecurityException | RuntimeException e) {
+ if (!authenticated && ((!identityReady &&
isCredentialSetupFailure(e))
+ || isAuthenticationRejection(e))) {
+ SSH_CIRCUIT_BREAKER.recordFailure(circuitTarget);
+ }
+ closeSessionOnFailure(clientSession, e);
+ throw e;
}
- if (reuseConnection) {
- SshConnect sshConnect = new SshConnect(clientSession);
- CONNECTION_COMMON_CACHE.addCache(identifier, sshConnect);
+ }
+
+ private static ClientSession connectDirect(
+ SshClient sshClient, String username, String host, int port, int
timeout) throws IOException {
+ Lock identityLock = CLIENT_IDENTITY_LOCK.readLock();
+ lockInterruptibly(identityLock, "SSH client identity");
+ try {
+ return sshClient.connect(username, host, port)
+ .verify(timeout, TimeUnit.MILLISECONDS).getSession();
+ } finally {
+ identityLock.unlock();
}
- return clientSession;
}
- public static ClientSession getConnectSession(SshProtocol sshProtocol, int
timeout, boolean reuseConnection, boolean useProxy)
+ private static ClientSession connectThroughProxy(
+ SshClient sshClient, SshProtocol sshProtocol, int timeout,
SshCircuitBreaker.Target proxyCircuit)
+ throws IOException, GeneralSecurityException {
+ Lock identityLock = CLIENT_IDENTITY_LOCK.writeLock();
+ lockInterruptibly(identityLock, "SSH client identity");
+ try {
+ SSH_CIRCUIT_BREAKER.checkOpen(proxyCircuit);
+ try {
+ ClientSession clientSession =
connectThroughProxyWithIdentity(sshClient, sshProtocol, timeout);
+ SSH_CIRCUIT_BREAKER.recordSuccess(proxyCircuit);
+ return clientSession;
+ } catch (IOException | GeneralSecurityException | RuntimeException
e) {
+ if (isProxyAuthenticationFailure(e)) {
+ SSH_CIRCUIT_BREAKER.recordFailure(proxyCircuit);
+ }
+ throw e;
+ }
+ } finally {
+ identityLock.unlock();
+ }
+ }
+
+ private static ClientSession connectThroughProxyWithIdentity(
+ SshClient sshClient, SshProtocol sshProtocol, int timeout)
throws IOException, GeneralSecurityException {
- CacheIdentifier identifier = CacheIdentifier.builder()
-
.ip(sshProtocol.getHost()).port(sshProtocol.getPort())
-
.username(sshProtocol.getUsername()).password(sshProtocol.getPassword())
- .build();
+ HostConfigEntry proxyConfig = new HostConfigEntry();
+ String proxySpec = String.format("%s@%s:%d",
sshProtocol.getProxyUsername(),
+ sshProtocol.getProxyHost(),
Integer.parseInt(sshProtocol.getProxyPort()));
+ proxyConfig.setHostName(sshProtocol.getHost());
+ proxyConfig.setHost(sshProtocol.getHost());
+ proxyConfig.setPort(Integer.parseInt(sshProtocol.getPort()));
+ proxyConfig.setUsername(sshProtocol.getUsername());
+ proxyConfig.setProxyJump(proxySpec);
+
+ String proxyPassword =
StringUtils.hasText(sshProtocol.getProxyPassword())
+ ? sshProtocol.getProxyPassword()
+ : null;
+ List<KeyPair> proxyKeyPairs = List.of();
ClientSession clientSession = null;
- // When using ProxyJump, force connection reuse:
- // Apache MINA SSHD will pass the proxy password error to the target
host in proxy scenarios, causing the first connection to fail.
- // Reusing connections can skip duplicate authentication and avoid
this problem.
- if (reuseConnection || useProxy) {
- Optional<AbstractConnection<?>> cacheOption =
CONNECTION_COMMON_CACHE.getCache(identifier, true);
- if (cacheOption.isPresent()) {
- SshConnect sshConnect = (SshConnect) cacheOption.get();
- clientSession = sshConnect.getConnection();
+ try {
+ if (proxyPassword != null) {
+ sshClient.addPasswordIdentity(proxyPassword);
+ }
+ if (StringUtils.hasText(sshProtocol.getProxyPrivateKey())) {
try {
- if (clientSession == null || clientSession.isClosed() ||
clientSession.isClosing()) {
- clientSession = null;
- CONNECTION_COMMON_CACHE.removeCache(identifier);
- }
- } catch (Exception e) {
- log.warn(e.getMessage());
- clientSession = null;
- CONNECTION_COMMON_CACHE.removeCache(identifier);
+ proxyKeyPairs = loadKeyPairs(
+ sshProtocol.getProxyHost(),
sshProtocol.getProxyPrivateKey(), null);
+ } catch (IOException | GeneralSecurityException e) {
+ throw new GeneralSecurityException("Failed to load proxy
private key", e);
}
+ proxyKeyPairs.forEach(sshClient::addPublicKeyIdentity);
}
+ clientSession = sshClient.connect(proxyConfig)
+ .verify(timeout, TimeUnit.MILLISECONDS).getSession();
+ if (proxyPassword != null) {
+ clientSession.removePasswordIdentity(proxyPassword);
+ }
+ proxyKeyPairs.forEach(clientSession::removePublicKeyIdentity);
+ return clientSession;
+ } catch (IOException | GeneralSecurityException | RuntimeException e) {
if (clientSession != null) {
- return clientSession;
+ closeSessionOnFailure(clientSession, e);
}
+ throw e;
+ } finally {
+ if (proxyPassword != null) {
+ sshClient.removePasswordIdentity(proxyPassword);
+ }
+ proxyKeyPairs.forEach(sshClient::removePublicKeyIdentity);
}
- SshClient sshClient = CommonSshClient.getSshClient();
- HostConfigEntry proxyConfig = new HostConfigEntry();
- if (useProxy && StringUtils.hasText(sshProtocol.getProxyHost())) {
- String proxySpec = String.format("%s@%s:%d",
sshProtocol.getProxyUsername(), sshProtocol.getProxyHost(),
Integer.parseInt(sshProtocol.getProxyPort()));
- proxyConfig.setHostName(sshProtocol.getHost());
- proxyConfig.setHost(sshProtocol.getHost());
- proxyConfig.setPort(Integer.parseInt(sshProtocol.getPort()));
- proxyConfig.setUsername(sshProtocol.getUsername());
- proxyConfig.setProxyJump(proxySpec);
-
- // Apache SSHD requires the password for the proxy to be preloaded
into the sshClient instance before connecting
- if (StringUtils.hasText(sshProtocol.getProxyPassword())) {
- sshClient.addPasswordIdentity(sshProtocol.getProxyPassword());
- log.debug("Loaded proxy server password authentication:
{}@{}", sshProtocol.getProxyUsername(), sshProtocol.getProxyHost());
+ }
+
+ private static void addIdentity(ClientSession clientSession, String host,
String password, String privateKey,
+ String privateKeyPassphrase) throws
IOException, GeneralSecurityException {
+ if (StringUtils.hasText(password)) {
+ clientSession.addPasswordIdentity(password);
+ return;
+ }
+ if (!StringUtils.hasText(privateKey)) {
+ return;
+ }
+ loadKeyPairs(host, privateKey,
privateKeyPassphrase).forEach(clientSession::addPublicKeyIdentity);
+ }
+
+ private static List<KeyPair> loadKeyPairs(String host, String privateKey,
String privateKeyPassphrase)
+ throws IOException, GeneralSecurityException {
+ String resourceKey = "ssh-key-" + valueOrEmpty(host);
+ FilePasswordProvider passwordProvider = (session, resource, index) ->
+ StringUtils.hasText(privateKeyPassphrase) ?
privateKeyPassphrase : null;
+ try (InputStream keyStream = new
ByteArrayInputStream(privateKey.getBytes(StandardCharsets.UTF_8))) {
+ Iterable<KeyPair> keyPairs = SecurityUtils.loadKeyPairIdentities(
+ null, () -> resourceKey, keyStream, passwordProvider);
+ if (keyPairs == null) {
+ throw new GeneralSecurityException("Failed to load SSH private
key");
}
- if (StringUtils.hasText(sshProtocol.getProxyPrivateKey())) {
-
proxyConfig.setIdentities(List.of(sshProtocol.getProxyPrivateKey()));
- log.debug("Proxy private key loaded into HostConfigEntry");
+ List<KeyPair> loadedKeyPairs = new ArrayList<>();
+ keyPairs.forEach(loadedKeyPairs::add);
+ if (loadedKeyPairs.isEmpty()) {
+ throw new GeneralSecurityException("SSH private key contains
no identities");
}
+ return List.copyOf(loadedKeyPairs);
}
+ }
- if (useProxy && StringUtils.hasText(sshProtocol.getProxyHost())) {
- try {
- clientSession = sshClient.connect(proxyConfig)
- .verify(timeout,
TimeUnit.MILLISECONDS).getSession();
- }
- finally {
-
sshClient.removePasswordIdentity(sshProtocol.getProxyPassword());
+ private static ClientSession getCachedSession(CacheIdentifier identifier) {
+ Optional<AbstractConnection<?>> cacheOption =
CONNECTION_COMMON_CACHE.getCache(identifier, true);
+ if (cacheOption.isEmpty()) {
+ return null;
+ }
+ ClientSession clientSession;
+ try {
+ clientSession = ((SshConnect) cacheOption.get()).getConnection();
+ if (clientSession == null || clientSession.isClosed() ||
clientSession.isClosing()) {
+ CONNECTION_COMMON_CACHE.removeCache(identifier);
+ return null;
}
+ return clientSession;
+ } catch (Exception e) {
+ log.warn("Failed to validate cached SSH session for {}",
identifier, e);
+ CONNECTION_COMMON_CACHE.removeCache(identifier);
+ return null;
+ }
+ }
+
+ private static CacheIdentifier buildCacheIdentifier(
+ String host, String port, String username, List<String>
connectionIdentity) {
+ return CacheIdentifier.builder()
+ .ip(host)
+ .port(port)
+ .username(username)
+ .customArg(fingerprint(connectionIdentity))
+ .build();
+ }
+
+ private static List<String> connectionIdentity(
+ String password, String privateKey, String privateKeyPassphrase) {
+ List<String> identity = new ArrayList<>();
+ addAuthenticationIdentity(identity, password, privateKey,
privateKeyPassphrase);
+ return List.copyOf(identity);
+ }
+
+ private static List<String> connectionIdentity(
+ List<String> targetIdentity, SshProtocol sshProtocol, boolean
proxyEnabled) {
+ List<String> identity = new ArrayList<>(targetIdentity);
+ if (proxyEnabled) {
+ identity.add("proxy");
+ identity.add(valueOrEmpty(sshProtocol.getProxyHost()));
+ identity.add(valueOrEmpty(sshProtocol.getProxyPort()));
+ identity.add(valueOrEmpty(sshProtocol.getProxyUsername()));
+ identity.add(textOrEmpty(sshProtocol.getProxyPassword()));
+ identity.add(textOrEmpty(sshProtocol.getProxyPrivateKey()));
+ }
+ return List.copyOf(identity);
+ }
+
+ private static List<String> proxyIdentity(SshProtocol sshProtocol) {
+ List<String> identity = new ArrayList<>();
+ if (StringUtils.hasText(sshProtocol.getProxyPassword())) {
+ identity.add("proxy-password");
+ identity.add(sshProtocol.getProxyPassword());
+ }
+ if (StringUtils.hasText(sshProtocol.getProxyPrivateKey())) {
+ identity.add("proxy-private-key");
+ identity.add(sshProtocol.getProxyPrivateKey());
+ }
+ if (identity.isEmpty()) {
+ identity.add("proxy-default");
+ }
+ return List.copyOf(identity);
+ }
+
+ private static void addAuthenticationIdentity(
+ List<String> identity, String password, String privateKey, String
privateKeyPassphrase) {
+ if (StringUtils.hasText(password)) {
+ identity.add("target-password");
+ identity.add(password);
+ } else if (StringUtils.hasText(privateKey)) {
+ identity.add("target-private-key");
+ identity.add(privateKey);
+ identity.add(textOrEmpty(privateKeyPassphrase));
} else {
- clientSession = sshClient.connect(sshProtocol.getUsername(),
sshProtocol.getHost(), Integer.parseInt(sshProtocol.getPort()))
- .verify(timeout,
TimeUnit.MILLISECONDS).getSession();
- }
-
- if (StringUtils.hasText(sshProtocol.getPassword())) {
- clientSession.addPasswordIdentity(sshProtocol.getPassword());
- } else if (StringUtils.hasText(sshProtocol.getPrivateKey())) {
- var resourceKey =
PrivateKeyUtils.writePrivateKey(sshProtocol.getHost(),
sshProtocol.getPrivateKey());
- try (InputStream keyStream = new FileInputStream(resourceKey)) {
- FilePasswordProvider passwordProvider = (session, resource,
index) -> {
- if
(StringUtils.hasText(sshProtocol.getPrivateKeyPassphrase())) {
- return sshProtocol.getPrivateKeyPassphrase();
- }
- return null;
- };
- Iterable<KeyPair> keyPairs =
SecurityUtils.loadKeyPairIdentities(null, () -> resourceKey, keyStream,
passwordProvider);
- if (keyPairs != null) {
- keyPairs.forEach(clientSession::addPublicKeyIdentity);
- } else {
- log.error("Failed to load private key pairs from: {}",
resourceKey);
- }
- } catch (IOException e) {
- log.error("Error reading private key file: {}",
e.getMessage());
+ identity.add("target-default");
+ }
+ }
+
+ private static String fingerprint(List<String> connectionIdentity) {
+ try {
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ for (String component : connectionIdentity) {
+ byte[] bytes = component.getBytes(StandardCharsets.UTF_8);
+
digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(bytes.length).array());
+ digest.update(bytes);
}
- } // else auth with localhost private public key certificates
+ return HexFormat.of().formatHex(digest.digest());
+ } catch (NoSuchAlgorithmException e) {
+ throw new IllegalStateException("SHA-256 is unavailable", e);
+ }
+ }
- // auth
- if (!clientSession.auth().verify(timeout,
TimeUnit.MILLISECONDS).isSuccess()) {
- clientSession.close();
- throw new IllegalArgumentException("ssh auth failed.");
+ private static String valueOrEmpty(String value) {
+ return value == null ? "" : value;
+ }
+
+ private static String textOrEmpty(String value) {
+ return StringUtils.hasText(value) ? value : "";
+ }
+
+ private static boolean isCredentialSetupFailure(Exception exception) {
+ return !(exception instanceof InterruptedIOException)
+ && (exception instanceof IOException || exception instanceof
GeneralSecurityException);
+ }
+
+ private static boolean isProxyAuthenticationFailure(Exception exception) {
+ return exception instanceof GeneralSecurityException ||
isAuthenticationRejection(exception);
+ }
+
+ private static boolean isAuthenticationRejection(Exception exception) {
+ Throwable current = exception;
+ while (current != null) {
+ if (current instanceof InterruptedIOException) {
+ return false;
+ }
+ if (current instanceof SshException sshException) {
+ int disconnectCode = sshException.getDisconnectCode();
+ return disconnectCode ==
SshConstants.SSH2_DISCONNECT_NO_MORE_AUTH_METHODS_AVAILABLE
+ || disconnectCode ==
SshConstants.SSH2_DISCONNECT_HOST_AUTHENTICATION_FAILED
+ || disconnectCode ==
SshConstants.SSH2_DISCONNECT_ILLEGAL_USER_NAME;
+ }
+ current = current instanceof RuntimeSshException ?
current.getCause() : null;
}
- if (reuseConnection || useProxy) {
- SshConnect sshConnect = new SshConnect(clientSession);
- CONNECTION_COMMON_CACHE.addCache(identifier, sshConnect);
+ return false;
+ }
+
+ private static void lockInterruptibly(Lock lock, String operation) throws
InterruptedIOException {
+ try {
+ lock.lockInterruptibly();
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ InterruptedIOException exception = new
InterruptedIOException(operation + " interrupted");
+ exception.initCause(e);
+ throw exception;
+ }
+ }
+
+ private static void closeSessionOnFailure(ClientSession clientSession,
Exception originalException) {
+ if (clientSession == null) {
+ return;
+ }
+ if (originalException instanceof InterruptedIOException) {
+ try {
+ clientSession.close(true);
+ } catch (RuntimeException closeException) {
+ originalException.addSuppressed(closeException);
+ } finally {
+ Thread.currentThread().interrupt();
+ }
+ return;
+ }
+ try {
+ clientSession.close();
+ } catch (Exception closeException) {
+ if (closeException instanceof InterruptedIOException) {
+ Thread.currentThread().interrupt();
+ }
+ originalException.addSuppressed(closeException);
}
- return clientSession;
}
}
diff --git
a/hertzbeat-collector/hertzbeat-collector-basic/src/test/java/org/apache/hertzbeat/collector/collect/common/ssh/CommonSshClientTest.java
b/hertzbeat-collector/hertzbeat-collector-common/src/test/java/org/apache/hertzbeat/collector/collect/common/ssh/CommonSshClientTest.java
similarity index 61%
rename from
hertzbeat-collector/hertzbeat-collector-basic/src/test/java/org/apache/hertzbeat/collector/collect/common/ssh/CommonSshClientTest.java
rename to
hertzbeat-collector/hertzbeat-collector-common/src/test/java/org/apache/hertzbeat/collector/collect/common/ssh/CommonSshClientTest.java
index b4ceeedcb3..bfd3eab17c 100644
---
a/hertzbeat-collector/hertzbeat-collector-basic/src/test/java/org/apache/hertzbeat/collector/collect/common/ssh/CommonSshClientTest.java
+++
b/hertzbeat-collector/hertzbeat-collector-common/src/test/java/org/apache/hertzbeat/collector/collect/common/ssh/CommonSshClientTest.java
@@ -17,8 +17,12 @@
package org.apache.hertzbeat.collector.collect.common.ssh;
-import org.junit.jupiter.api.AfterEach;
-import org.junit.jupiter.api.BeforeEach;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.time.Duration;
+import org.apache.sshd.client.SshClient;
+import org.apache.sshd.core.CoreModuleProperties;
import org.junit.jupiter.api.Test;
/**
@@ -26,15 +30,13 @@ import org.junit.jupiter.api.Test;
*/
class CommonSshClientTest {
- @BeforeEach
- void setUp() {
- }
-
- @AfterEach
- void tearDown() {
- }
-
@Test
void getSshClient() {
+ SshClient sshClient = CommonSshClient.getSshClient();
+
+ assertTrue(sshClient.isStarted());
+ assertEquals(Duration.ofSeconds(30),
CoreModuleProperties.HEARTBEAT_INTERVAL.getRequired(sshClient));
+ assertEquals(2,
CoreModuleProperties.HEARTBEAT_NO_REPLY_MAX.getRequired(sshClient));
+
assertTrue(CoreModuleProperties.SOCKET_KEEPALIVE.getRequired(sshClient));
}
}
diff --git
a/hertzbeat-collector/hertzbeat-collector-common/src/test/java/org/apache/hertzbeat/collector/collect/common/ssh/SshCircuitBreakerTest.java
b/hertzbeat-collector/hertzbeat-collector-common/src/test/java/org/apache/hertzbeat/collector/collect/common/ssh/SshCircuitBreakerTest.java
new file mode 100644
index 0000000000..e1b46e8b4c
--- /dev/null
+++
b/hertzbeat-collector/hertzbeat-collector-common/src/test/java/org/apache/hertzbeat/collector/collect/common/ssh/SshCircuitBreakerTest.java
@@ -0,0 +1,111 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hertzbeat.collector.collect.common.ssh;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import com.google.common.base.Ticker;
+import java.io.IOException;
+import java.time.Duration;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicLong;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests for {@link SshCircuitBreaker}.
+ */
+class SshCircuitBreakerTest {
+
+ private TestTicker ticker;
+ private SshCircuitBreaker circuitBreaker;
+ private SshCircuitBreaker.Target target;
+
+ @BeforeEach
+ void setUp() {
+ ticker = new TestTicker();
+ circuitBreaker = new SshCircuitBreaker(3, Duration.ofMinutes(5),
ticker);
+ target = circuitBreaker.buildTarget(
+ "127.0.0.1", "22", "root", List.of("target-password",
"bad-password"));
+ }
+
+ @Test
+ void opensAfterThresholdAndExpiresAfterCooldown() {
+ circuitBreaker.recordFailure(target);
+ circuitBreaker.recordFailure(target);
+ assertDoesNotThrow(() -> circuitBreaker.checkOpen(target));
+
+ circuitBreaker.recordFailure(target);
+ assertThrows(IOException.class, () ->
circuitBreaker.checkOpen(target));
+
+ ticker.advance(Duration.ofMinutes(5).plusNanos(1));
+ assertDoesNotThrow(() -> circuitBreaker.checkOpen(target));
+ }
+
+ @Test
+ void successAndCredentialChangeBypassPreviousFailures() {
+ circuitBreaker.recordFailure(target);
+ circuitBreaker.recordFailure(target);
+ circuitBreaker.recordFailure(target);
+
+ SshCircuitBreaker.Target correctedCredentials =
circuitBreaker.buildTarget(
+ "127.0.0.1", "22", "root", List.of("target-password",
"correct-password"));
+ assertNotEquals(target, correctedCredentials);
+ assertDoesNotThrow(() ->
circuitBreaker.checkOpen(correctedCredentials));
+
+ circuitBreaker.recordSuccess(target);
+ assertDoesNotThrow(() -> circuitBreaker.checkOpen(target));
+ }
+
+ @Test
+ void equalTargetsShareConnectionLockWithoutExposingCredentials() {
+ SshCircuitBreaker.Target equalTarget = circuitBreaker.buildTarget(
+ "127.0.0.1", "22", "root", List.of("target-password",
"bad-password"));
+
+ assertSame(circuitBreaker.connectionLock(target),
circuitBreaker.connectionLock(equalTarget));
+ assertEquals("[email protected]:22", target.toString());
+ }
+
+ @Test
+ void rejectsInvalidConfiguration() {
+ assertThrows(IllegalArgumentException.class,
+ () -> new SshCircuitBreaker(0, Duration.ofMinutes(5), ticker));
+ assertThrows(IllegalArgumentException.class,
+ () -> new SshCircuitBreaker(3, Duration.ZERO, ticker));
+ assertThrows(IllegalArgumentException.class,
+ () -> new SshCircuitBreaker(3, Duration.ofSeconds(-1),
ticker));
+ }
+
+ private static final class TestTicker extends Ticker {
+
+ private final AtomicLong nanos = new AtomicLong();
+
+ @Override
+ public long read() {
+ return nanos.get();
+ }
+
+ private void advance(Duration duration) {
+ nanos.addAndGet(duration.toNanos());
+ }
+ }
+}
diff --git
a/hertzbeat-collector/hertzbeat-collector-common/src/test/java/org/apache/hertzbeat/collector/collect/common/ssh/SshHelperTest.java
b/hertzbeat-collector/hertzbeat-collector-common/src/test/java/org/apache/hertzbeat/collector/collect/common/ssh/SshHelperTest.java
new file mode 100644
index 0000000000..36795d776c
--- /dev/null
+++
b/hertzbeat-collector/hertzbeat-collector-common/src/test/java/org/apache/hertzbeat/collector/collect/common/ssh/SshHelperTest.java
@@ -0,0 +1,551 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hertzbeat.collector.collect.common.ssh;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.inOrder;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.io.IOException;
+import java.io.InterruptedIOException;
+import java.security.KeyPair;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.UUID;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import org.apache.hertzbeat.common.entity.job.protocol.SshProtocol;
+import org.apache.sshd.client.SshClient;
+import org.apache.sshd.client.config.hosts.HostConfigEntry;
+import org.apache.sshd.client.future.AuthFuture;
+import org.apache.sshd.client.future.ConnectFuture;
+import org.apache.sshd.client.session.ClientSession;
+import org.apache.sshd.common.SshConstants;
+import org.apache.sshd.common.SshException;
+import org.apache.sshd.common.config.keys.FilePasswordProvider;
+import org.apache.sshd.common.util.security.SecurityUtils;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InOrder;
+import org.mockito.MockedStatic;
+
+/**
+ * Tests for {@link SshHelper}.
+ */
+class SshHelperTest {
+
+ private static final int TIMEOUT = 1_000;
+
+ private String host;
+ private SshClient sshClient;
+
+ @BeforeEach
+ void setUp() {
+ host = UUID.randomUUID() + ".example.com";
+ sshClient = mock(SshClient.class);
+ }
+
+ @Test
+ void blocksSameEffectiveCredentialsAndRetriesAfterPasswordChanges() throws
Exception {
+ ConnectAttempt firstFailure = failedAttempt("first auth failure");
+ ConnectAttempt secondFailure = failedAttempt("second auth failure");
+ ConnectAttempt thirdFailure = failedAttempt("third auth failure");
+ ConnectAttempt success = successfulAttempt();
+ when(sshClient.connect("root", host, 22)).thenReturn(
+ firstFailure.connectFuture(), secondFailure.connectFuture(),
+ thirdFailure.connectFuture(), success.connectFuture());
+
+ SshProtocol badProtocol = protocol("bad-password",
"ignored-private-key-a");
+ SshProtocol samePassword = protocol("bad-password",
"ignored-private-key-b");
+ SshProtocol correctedProtocol = protocol("correct-password",
"ignored-private-key-b");
+
+ try (MockedStatic<CommonSshClient> commonSshClient =
mockStatic(CommonSshClient.class)) {
+
commonSshClient.when(CommonSshClient::getSshClient).thenReturn(sshClient);
+
+ for (int i = 0; i < SshCircuitBreaker.MAX_FAILURES; i++) {
+ assertThrows(SshException.class,
+ () -> SshHelper.getConnectSession(badProtocol,
TIMEOUT, false, false));
+ }
+ IOException blocked = assertThrows(IOException.class,
+ () -> SshHelper.getConnectSession(samePassword, TIMEOUT,
false, false));
+ assertTrue(blocked.getMessage().contains("temporarily blocked"));
+
+ assertSame(success.clientSession(),
+ SshHelper.getConnectSession(correctedProtocol, TIMEOUT,
false, false));
+ }
+
+ verify(sshClient, times(4)).connect("root", host, 22);
+ verify(firstFailure.clientSession()).close();
+ verify(secondFailure.clientSession()).close();
+ verify(thirdFailure.clientSession()).close();
+ verify(success.clientSession(), never()).close();
+
verify(success.clientSession()).addPasswordIdentity("correct-password");
+ }
+
+ @Test
+ void concurrentReuseCreatesOnePhysicalSession() throws Exception {
+ ConnectAttempt success = successfulAttempt();
+ CountDownLatch authenticationStarted = new CountDownLatch(1);
+ CountDownLatch allowAuthentication = new CountDownLatch(1);
+ when(success.authFuture().verify(TIMEOUT,
TimeUnit.MILLISECONDS)).thenAnswer(invocation -> {
+ authenticationStarted.countDown();
+ assertTrue(allowAuthentication.await(5, TimeUnit.SECONDS));
+ return success.authFuture();
+ });
+ when(success.clientSession().isClosed()).thenReturn(false);
+ when(success.clientSession().isClosing()).thenReturn(false);
+ when(sshClient.connect("root", host,
22)).thenReturn(success.connectFuture());
+
+ CountDownLatch secondStarted = new CountDownLatch(1);
+ try (ExecutorService executor = Executors.newFixedThreadPool(2)) {
+ Future<ClientSession> first = executor.submit(() ->
connectWithThreadLocalClient("password"));
+ assertTrue(authenticationStarted.await(5, TimeUnit.SECONDS));
+ Future<ClientSession> second = executor.submit(() -> {
+ secondStarted.countDown();
+ return connectWithThreadLocalClient("password");
+ });
+ assertTrue(secondStarted.await(5, TimeUnit.SECONDS));
+
+ assertThrows(TimeoutException.class, () -> second.get(200,
TimeUnit.MILLISECONDS));
+ verify(sshClient).connect("root", host, 22);
+ allowAuthentication.countDown();
+ assertSame(success.clientSession(), first.get(5,
TimeUnit.SECONDS));
+ assertSame(success.clientSession(), second.get(5,
TimeUnit.SECONDS));
+ } finally {
+ allowAuthentication.countDown();
+ }
+
+ verify(sshClient).connect("root", host, 22);
+ verify(success.clientSession()).auth();
+ verify(success.authFuture()).verify(TIMEOUT, TimeUnit.MILLISECONDS);
+ }
+
+ @Test
+ void concurrentAuthenticationFailuresCreateAtMostThreePhysicalSessions()
throws Exception {
+ ConnectAttempt firstFailure = failedAttempt("first concurrent
failure");
+ ConnectAttempt secondFailure = failedAttempt("second concurrent
failure");
+ ConnectAttempt thirdFailure = failedAttempt("third concurrent
failure");
+ when(sshClient.connect("root", host, 22)).thenReturn(
+ firstFailure.connectFuture(), secondFailure.connectFuture(),
thirdFailure.connectFuture());
+
+ int taskCount = 8;
+ CountDownLatch ready = new CountDownLatch(taskCount);
+ CountDownLatch start = new CountDownLatch(1);
+ List<Future<Exception>> futures = new ArrayList<>();
+ try (ExecutorService executor =
Executors.newFixedThreadPool(taskCount)) {
+ for (int i = 0; i < taskCount; i++) {
+ futures.add(executor.submit(() -> {
+ ready.countDown();
+ assertTrue(start.await(5, TimeUnit.SECONDS));
+ try {
+
connectWithThreadLocalClient("concurrent-bad-password", false);
+ return null;
+ } catch (Exception e) {
+ return e;
+ }
+ }));
+ }
+ assertTrue(ready.await(5, TimeUnit.SECONDS));
+ start.countDown();
+ }
+
+ int authenticationFailures = 0;
+ int blockedFailures = 0;
+ for (Future<Exception> future : futures) {
+ Exception exception = future.get(5, TimeUnit.SECONDS);
+ if (exception instanceof SshException) {
+ authenticationFailures++;
+ } else {
+ assertTrue(exception instanceof IOException);
+ assertTrue(exception.getMessage().contains("temporarily
blocked"));
+ blockedFailures++;
+ }
+ }
+ assertEquals(SshCircuitBreaker.MAX_FAILURES, authenticationFailures);
+ assertEquals(taskCount - SshCircuitBreaker.MAX_FAILURES,
blockedFailures);
+ verify(sshClient,
times(SshCircuitBreaker.MAX_FAILURES)).connect("root", host, 22);
+ verify(firstFailure.clientSession()).close();
+ verify(secondFailure.clientSession()).close();
+ verify(thirdFailure.clientSession()).close();
+ }
+
+ @Test
+ void proxyFailuresOpenCircuitAndRemoveGlobalPassword() throws Exception {
+ ConnectFuture connectFuture = mock(ConnectFuture.class);
+ ConnectAttempt correctedSuccess = successfulAttempt();
+ when(sshClient.connect(any(HostConfigEntry.class))).thenReturn(
+ connectFuture, connectFuture, connectFuture,
correctedSuccess.connectFuture());
+ when(connectFuture.verify(TIMEOUT, TimeUnit.MILLISECONDS))
+ .thenThrow(new SshException(
+
SshConstants.SSH2_DISCONNECT_NO_MORE_AUTH_METHODS_AVAILABLE, "proxy auth
failed"));
+ SshProtocol protocol = proxyProtocol("bad-proxy-password",
"target-password");
+ SshProtocol otherTarget = proxyProtocol("bad-proxy-password",
"different-target-password");
+ otherTarget.setHost(UUID.randomUUID() + ".example.com");
+ SshProtocol correctedProtocol =
proxyProtocol("correct-proxy-password", "target-password");
+
+ try (MockedStatic<CommonSshClient> commonSshClient =
mockStatic(CommonSshClient.class)) {
+
commonSshClient.when(CommonSshClient::getSshClient).thenReturn(sshClient);
+
+ for (int i = 0; i < SshCircuitBreaker.MAX_FAILURES; i++) {
+ assertThrows(SshException.class,
+ () -> SshHelper.getConnectSession(protocol, TIMEOUT,
false, true));
+ }
+ IOException blocked = assertThrows(IOException.class,
+ () -> SshHelper.getConnectSession(protocol, TIMEOUT,
false, true));
+ assertTrue(blocked.getMessage().contains("temporarily blocked"));
+ IOException sharedProxyBlocked = assertThrows(IOException.class,
+ () -> SshHelper.getConnectSession(otherTarget, TIMEOUT,
false, true));
+ assertTrue(sharedProxyBlocked.getMessage().contains("temporarily
blocked"));
+
+ assertSame(correctedSuccess.clientSession(),
+ SshHelper.getConnectSession(correctedProtocol, TIMEOUT,
false, true));
+ }
+
+ verify(sshClient, times(SshCircuitBreaker.MAX_FAILURES +
1)).connect(any(HostConfigEntry.class));
+ verify(sshClient, times(SshCircuitBreaker.MAX_FAILURES))
+ .addPasswordIdentity("bad-proxy-password");
+ verify(sshClient, times(SshCircuitBreaker.MAX_FAILURES))
+ .removePasswordIdentity("bad-proxy-password");
+ verify(sshClient).addPasswordIdentity("correct-proxy-password");
+ verify(sshClient).removePasswordIdentity("correct-proxy-password");
+ }
+
+ @Test
+ void cachedProxySessionDoesNotResetProxyAuthenticationFailures() throws
Exception {
+ ConnectAttempt cachedSuccess = successfulAttempt();
+ ConnectFuture failedProxyConnect = mock(ConnectFuture.class);
+ when(failedProxyConnect.verify(TIMEOUT, TimeUnit.MILLISECONDS))
+ .thenThrow(new SshException(
+
SshConstants.SSH2_DISCONNECT_NO_MORE_AUTH_METHODS_AVAILABLE, "proxy auth
failed"));
+ when(cachedSuccess.clientSession().isClosed()).thenReturn(false);
+ when(cachedSuccess.clientSession().isClosing()).thenReturn(false);
+ when(sshClient.connect(any(HostConfigEntry.class))).thenReturn(
+ cachedSuccess.connectFuture(), failedProxyConnect,
failedProxyConnect, failedProxyConnect);
+ String proxyPassword = UUID.randomUUID() + "-proxy-password";
+ SshProtocol cachedProtocol = proxyProtocol(proxyPassword,
"cached-target-password");
+ SshProtocol failingProtocol = proxyProtocol(proxyPassword,
"failing-target-password");
+ failingProtocol.setHost(UUID.randomUUID() + ".example.com");
+
+ try (MockedStatic<CommonSshClient> commonSshClient =
mockStatic(CommonSshClient.class)) {
+
commonSshClient.when(CommonSshClient::getSshClient).thenReturn(sshClient);
+
+ assertSame(cachedSuccess.clientSession(),
+ SshHelper.getConnectSession(cachedProtocol, TIMEOUT,
false, true));
+ for (int i = 0; i < SshCircuitBreaker.MAX_FAILURES; i++) {
+ assertThrows(SshException.class,
+ () -> SshHelper.getConnectSession(failingProtocol,
TIMEOUT, false, true));
+ assertSame(cachedSuccess.clientSession(),
+ SshHelper.getConnectSession(cachedProtocol, TIMEOUT,
false, true));
+ }
+ IOException blocked = assertThrows(IOException.class,
+ () -> SshHelper.getConnectSession(failingProtocol,
TIMEOUT, false, true));
+ assertTrue(blocked.getMessage().contains("temporarily blocked"));
+ }
+
+ verify(sshClient, times(SshCircuitBreaker.MAX_FAILURES + 1))
+ .connect(any(HostConfigEntry.class));
+ }
+
+ @Test
+ void proxyTransportFailuresDoNotOpenAuthenticationCircuit() throws
Exception {
+ ConnectFuture connectFuture = mock(ConnectFuture.class);
+
when(sshClient.connect(any(HostConfigEntry.class))).thenReturn(connectFuture);
+ when(connectFuture.verify(TIMEOUT, TimeUnit.MILLISECONDS))
+ .thenThrow(new SshException(
+ SshConstants.SSH2_DISCONNECT_CONNECTION_LOST, "proxy
transport failed"));
+ String proxyPassword = UUID.randomUUID() + "-proxy-password";
+ SshProtocol protocol = proxyProtocol(proxyPassword, "target-password");
+
+ try (MockedStatic<CommonSshClient> commonSshClient =
mockStatic(CommonSshClient.class)) {
+
commonSshClient.when(CommonSshClient::getSshClient).thenReturn(sshClient);
+
+ for (int i = 0; i < SshCircuitBreaker.MAX_FAILURES + 1; i++) {
+ assertThrows(SshException.class,
+ () -> SshHelper.getConnectSession(protocol, TIMEOUT,
false, true));
+ }
+ }
+
+ verify(sshClient, times(SshCircuitBreaker.MAX_FAILURES + 1))
+ .connect(any(HostConfigEntry.class));
+ verify(sshClient, times(SshCircuitBreaker.MAX_FAILURES + 1))
+ .addPasswordIdentity(proxyPassword);
+ verify(sshClient, times(SshCircuitBreaker.MAX_FAILURES + 1))
+ .removePasswordIdentity(proxyPassword);
+ }
+
+ @Test
+ void proxyPasswordIsRemovedBeforeTargetAuthentication() throws Exception {
+ ConnectAttempt success = successfulAttempt();
+
when(sshClient.connect(any(HostConfigEntry.class))).thenReturn(success.connectFuture());
+ SshProtocol protocol = proxyProtocol("proxy-password",
"target-password");
+
+ try (MockedStatic<CommonSshClient> commonSshClient =
mockStatic(CommonSshClient.class)) {
+
commonSshClient.when(CommonSshClient::getSshClient).thenReturn(sshClient);
+
+ assertSame(success.clientSession(),
+ SshHelper.getConnectSession(protocol, TIMEOUT, false,
true));
+ }
+
+ verify(sshClient).addPasswordIdentity("proxy-password");
+ verify(sshClient).connect(any(HostConfigEntry.class));
+ verify(success.connectFuture()).verify(TIMEOUT, TimeUnit.MILLISECONDS);
+ verify(sshClient).removePasswordIdentity("proxy-password");
+
+ InOrder order = inOrder(success.clientSession(), success.authFuture());
+
order.verify(success.clientSession()).removePasswordIdentity("proxy-password");
+
order.verify(success.clientSession()).addPasswordIdentity("target-password");
+ order.verify(success.clientSession()).auth();
+ order.verify(success.authFuture()).verify(TIMEOUT,
TimeUnit.MILLISECONDS);
+
+ ArgumentCaptor<HostConfigEntry> configCaptor =
ArgumentCaptor.forClass(HostConfigEntry.class);
+ verify(sshClient).connect(configCaptor.capture());
+ HostConfigEntry proxyConfig = configCaptor.getValue();
+ assertEquals(host, proxyConfig.getHostName());
+ assertEquals("root", proxyConfig.getUsername());
+ assertEquals("[email protected]:2222",
proxyConfig.getProxyJump());
+ }
+
+ @Test
+ void defaultIdentityReconnectsAfterCachedSessionsBecomeUnhealthy() throws
Exception {
+ ConnectAttempt first = successfulAttempt();
+ ConnectAttempt second = successfulAttempt();
+ ConnectAttempt third = successfulAttempt();
+ when(sshClient.connect("root", host, 22)).thenReturn(
+ first.connectFuture(), second.connectFuture(),
third.connectFuture());
+
+ try (MockedStatic<CommonSshClient> commonSshClient =
mockStatic(CommonSshClient.class)) {
+
commonSshClient.when(CommonSshClient::getSshClient).thenReturn(sshClient);
+
+ assertSame(first.clientSession(), SshHelper.getConnectSession(
+ host, "22", "root", null, null, null, TIMEOUT, true));
+ when(first.clientSession().isClosed()).thenReturn(true);
+ assertSame(second.clientSession(), SshHelper.getConnectSession(
+ host, "22", "root", null, null, null, TIMEOUT, true));
+ when(second.clientSession().isClosed()).thenThrow(new
IllegalStateException("health check failed"));
+ assertSame(third.clientSession(), SshHelper.getConnectSession(
+ host, "22", "root", null, null, null, TIMEOUT, true));
+ when(third.clientSession().isClosed()).thenReturn(false);
+ when(third.clientSession().isClosing()).thenReturn(false);
+ assertSame(third.clientSession(), SshHelper.getConnectSession(
+ host, "22", "root", null, null, null, TIMEOUT, true));
+ }
+
+ verify(sshClient, times(3)).connect("root", host, 22);
+ verify(first.clientSession()).close();
+ verify(second.clientSession()).close();
+ verify(third.clientSession(), never()).close();
+ verify(first.clientSession(), never()).addPasswordIdentity(any());
+ verify(first.clientSession(), never()).addPublicKeyIdentity(any());
+ }
+
+ @Test
+ void loadsTargetPrivateKeyDirectlyFromMemory() throws Exception {
+ ConnectAttempt success = successfulAttempt();
+ KeyPair keyPair = mock(KeyPair.class);
+ when(sshClient.connect("root", host,
22)).thenReturn(success.connectFuture());
+
+ try (MockedStatic<CommonSshClient> commonSshClient =
mockStatic(CommonSshClient.class);
+ MockedStatic<SecurityUtils> securityUtils =
mockStatic(SecurityUtils.class)) {
+
commonSshClient.when(CommonSshClient::getSshClient).thenReturn(sshClient);
+ securityUtils.when(() ->
SecurityUtils.loadKeyPairIdentities(any(), any(), any(), any()))
+ .thenAnswer(invocation -> {
+ FilePasswordProvider provider =
invocation.getArgument(3);
+ assertEquals("passphrase", provider.getPassword(null,
null, 0));
+ return List.of(keyPair);
+ });
+
+ assertSame(success.clientSession(), SshHelper.getConnectSession(
+ host, "22", "root", null, "private-key", "passphrase",
TIMEOUT, false));
+ }
+
+ verify(success.clientSession()).addPublicKeyIdentity(keyPair);
+ verify(success.clientSession(), never()).addPasswordIdentity(any());
+ }
+
+ @Test
+ void proxyPrivateKeyIsRemovedBeforeTargetAuthentication() throws Exception
{
+ ConnectAttempt success = successfulAttempt();
+ KeyPair keyPair = mock(KeyPair.class);
+
when(sshClient.connect(any(HostConfigEntry.class))).thenReturn(success.connectFuture());
+ SshProtocol protocol = proxyProtocol(null, "target-password");
+ protocol.setProxyPrivateKey("proxy-private-key");
+
+ try (MockedStatic<CommonSshClient> commonSshClient =
mockStatic(CommonSshClient.class);
+ MockedStatic<SecurityUtils> securityUtils =
mockStatic(SecurityUtils.class)) {
+
commonSshClient.when(CommonSshClient::getSshClient).thenReturn(sshClient);
+ securityUtils.when(() ->
SecurityUtils.loadKeyPairIdentities(any(), any(), any(), any()))
+ .thenAnswer(invocation -> {
+ FilePasswordProvider provider =
invocation.getArgument(3);
+ assertNull(provider.getPassword(null, null, 0));
+ return List.of(keyPair);
+ });
+
+ assertSame(success.clientSession(),
+ SshHelper.getConnectSession(protocol, TIMEOUT, false,
true));
+ }
+
+ verify(sshClient).addPublicKeyIdentity(keyPair);
+ verify(sshClient).removePublicKeyIdentity(keyPair);
+ verify(success.clientSession()).removePublicKeyIdentity(keyPair);
+ verify(sshClient, never()).addPasswordIdentity(any());
+ }
+
+ @Test
+ void connectionLockInterruptionIsPropagated() {
+ Thread.currentThread().interrupt();
+ try {
+ InterruptedIOException exception =
assertThrows(InterruptedIOException.class,
+ () -> SshHelper.getConnectSession(
+ host, "22", "root", "password", null, null,
TIMEOUT, true));
+
+ assertTrue(exception.getMessage().contains("SSH connection"));
+ assertTrue(exception.getCause() instanceof InterruptedException);
+ assertTrue(Thread.currentThread().isInterrupted());
+ } finally {
+ Thread.interrupted();
+ }
+ }
+
+ @Test
+ void interruptedAuthenticationForcesSessionCloseAndPreservesInterrupt()
throws Exception {
+ ConnectAttempt attempt = attempt();
+ InterruptedIOException interrupted = new
InterruptedIOException("authentication interrupted");
+ when(attempt.authFuture().verify(TIMEOUT,
TimeUnit.MILLISECONDS)).thenThrow(interrupted);
+ when(sshClient.connect("root", host,
22)).thenReturn(attempt.connectFuture());
+
+ try (MockedStatic<CommonSshClient> commonSshClient =
mockStatic(CommonSshClient.class)) {
+
commonSshClient.when(CommonSshClient::getSshClient).thenReturn(sshClient);
+ try {
+ assertSame(interrupted,
assertThrows(InterruptedIOException.class,
+ () -> SshHelper.getConnectSession(
+ host, "22", "root", "password", null, null,
TIMEOUT, false)));
+ assertTrue(Thread.currentThread().isInterrupted());
+ verify(attempt.clientSession()).close(true);
+ verify(attempt.clientSession(), never()).close();
+ } finally {
+ Thread.interrupted();
+ }
+ }
+ }
+
+ @Test
+ void closeFailureDoesNotHideAuthenticationFailure() throws Exception {
+ ConnectAttempt attempt = attempt();
+ SshException authenticationFailure = new SshException(
+ SshConstants.SSH2_DISCONNECT_NO_MORE_AUTH_METHODS_AVAILABLE,
"auth failed");
+ InterruptedIOException closeFailure = new
InterruptedIOException("close interrupted");
+ when(attempt.authFuture().verify(TIMEOUT,
TimeUnit.MILLISECONDS)).thenThrow(authenticationFailure);
+ doThrow(closeFailure).when(attempt.clientSession()).close();
+ when(sshClient.connect("root", host,
22)).thenReturn(attempt.connectFuture());
+
+ try (MockedStatic<CommonSshClient> commonSshClient =
mockStatic(CommonSshClient.class)) {
+
commonSshClient.when(CommonSshClient::getSshClient).thenReturn(sshClient);
+ try {
+ SshException thrown = assertThrows(SshException.class,
+ () -> SshHelper.getConnectSession(
+ host, "22", "root", "password", null, null,
TIMEOUT, false));
+ assertSame(authenticationFailure, thrown);
+ assertEquals(1, thrown.getSuppressed().length);
+ assertSame(closeFailure, thrown.getSuppressed()[0]);
+ assertTrue(Thread.currentThread().isInterrupted());
+ } finally {
+ Thread.interrupted();
+ }
+ }
+ }
+
+ private ClientSession connectWithThreadLocalClient(String password) throws
Exception {
+ return connectWithThreadLocalClient(password, true);
+ }
+
+ private ClientSession connectWithThreadLocalClient(String password,
boolean reuseConnection) throws Exception {
+ try (MockedStatic<CommonSshClient> commonSshClient =
mockStatic(CommonSshClient.class)) {
+
commonSshClient.when(CommonSshClient::getSshClient).thenReturn(sshClient);
+ return SshHelper.getConnectSession(
+ host, "22", "root", password, null, null, TIMEOUT,
reuseConnection);
+ }
+ }
+
+ private SshProtocol protocol(String password, String privateKey) {
+ return SshProtocol.builder()
+ .host(host)
+ .port("22")
+ .username("root")
+ .password(password)
+ .privateKey(privateKey)
+ .build();
+ }
+
+ private SshProtocol proxyProtocol(String proxyPassword, String
targetPassword) {
+ return SshProtocol.builder()
+ .host(host)
+ .port("22")
+ .username("root")
+ .password(targetPassword)
+ .proxyHost("proxy.example.com")
+ .proxyPort("2222")
+ .proxyUsername("proxy-user")
+ .proxyPassword(proxyPassword)
+ .build();
+ }
+
+ private ConnectAttempt failedAttempt(String message) throws IOException {
+ ConnectAttempt attempt = attempt();
+ when(attempt.authFuture().verify(TIMEOUT, TimeUnit.MILLISECONDS))
+ .thenThrow(new SshException(
+
SshConstants.SSH2_DISCONNECT_NO_MORE_AUTH_METHODS_AVAILABLE, message));
+ return attempt;
+ }
+
+ private ConnectAttempt successfulAttempt() throws IOException {
+ ConnectAttempt attempt = attempt();
+ when(attempt.authFuture().verify(TIMEOUT, TimeUnit.MILLISECONDS))
+ .thenReturn(attempt.authFuture());
+ return attempt;
+ }
+
+ private ConnectAttempt attempt() throws IOException {
+ ConnectFuture connectFuture = mock(ConnectFuture.class);
+ ClientSession clientSession = mock(ClientSession.class);
+ AuthFuture authFuture = mock(AuthFuture.class);
+ when(connectFuture.verify(TIMEOUT,
TimeUnit.MILLISECONDS)).thenReturn(connectFuture);
+ when(connectFuture.getSession()).thenReturn(clientSession);
+ when(clientSession.auth()).thenReturn(authFuture);
+ return new ConnectAttempt(connectFuture, clientSession, authFuture);
+ }
+
+ private record ConnectAttempt(
+ ConnectFuture connectFuture, ClientSession clientSession,
AuthFuture authFuture) {
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]