This is an automated email from the ASF dual-hosted git repository.
Duansg 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 eb753be1f5 maintenance: extend SFTP connection options (#4293)
eb753be1f5 is described below
commit eb753be1f5796997198137051d240c1a98347141
Author: Logic <[email protected]>
AuthorDate: Thu Aug 27 00:42:23 2026 +0800
maintenance: extend SFTP connection options (#4293)
Co-authored-by: Duansg <[email protected]>
---
.../collector/collect/ftp/FtpCollectImpl.java | 58 ++++++++++-
.../collector/collect/ftp/FtpCollectImplTest.java | 104 +++++++++++++++++++
.../common/entity/job/protocol/FtpProtocol.java | 84 +++++++++++++--
.../entity/job/protocol/FtpProtocolTest.java | 113 +++++++++++++++++++++
.../src/main/resources/define/app-ftp.yml | 26 +++++
home/docs/help/ftp.md | 26 +++++
home/docs/start/upgrade.md | 18 ++++
.../current/help/ftp.md | 22 ++++
.../current/start/upgrade.md | 15 +++
.../monitor-form/monitor-form.component.spec.ts | 50 +++++++++
.../monitor/monitor-form/monitor-form.component.ts | 12 ++-
11 files changed, 512 insertions(+), 16 deletions(-)
diff --git
a/hertzbeat-collector/hertzbeat-collector-basic/src/main/java/org/apache/hertzbeat/collector/collect/ftp/FtpCollectImpl.java
b/hertzbeat-collector/hertzbeat-collector-basic/src/main/java/org/apache/hertzbeat/collector/collect/ftp/FtpCollectImpl.java
index 03307e1c7a..d7cfa6bb95 100644
---
a/hertzbeat-collector/hertzbeat-collector-basic/src/main/java/org/apache/hertzbeat/collector/collect/ftp/FtpCollectImpl.java
+++
b/hertzbeat-collector/hertzbeat-collector-basic/src/main/java/org/apache/hertzbeat/collector/collect/ftp/FtpCollectImpl.java
@@ -19,8 +19,11 @@ package org.apache.hertzbeat.collector.collect.ftp;
import java.io.IOException;
import java.util.HashMap;
+import java.util.List;
import java.util.Map;
import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.net.ftp.FTPClient;
@@ -32,7 +35,11 @@ import
org.apache.hertzbeat.common.entity.job.protocol.FtpProtocol;
import org.apache.hertzbeat.common.entity.message.CollectRep;
import org.apache.hertzbeat.common.util.CommonUtil;
import org.apache.sshd.client.SshClient;
+import org.apache.sshd.client.keyverifier.AcceptAllServerKeyVerifier;
+import org.apache.sshd.client.keyverifier.ServerKeyVerifier;
import org.apache.sshd.client.session.ClientSession;
+import org.apache.sshd.common.config.keys.KeyUtils;
+import org.apache.sshd.common.digest.BuiltinDigests;
import org.apache.sshd.sftp.client.SftpClient;
import org.apache.sshd.sftp.client.SftpClientFactory;
import org.springframework.util.Assert;
@@ -46,6 +53,8 @@ public class FtpCollectImpl extends AbstractCollect {
private static final String ANONYMOUS = "anonymous";
private static final String PASSWORD = "password";
+ private static final int MAX_INSECURE_WARNING_ENDPOINTS = 1024;
+ private static final Set<String> INSECURE_WARNING_ENDPOINTS =
ConcurrentHashMap.newKeySet();
/**
* preCheck params
@@ -56,10 +65,8 @@ public class FtpCollectImpl extends AbstractCollect {
throw new IllegalArgumentException("Ftp collect must has ftp
params.");
}
FtpProtocol ftpProtocol = metrics.getFtp();
- Assert.hasText(ftpProtocol.getHost(), "Ftp Protocol host is
required.");
- Assert.hasText(ftpProtocol.getPort(), "Ftp Protocol port is
required.");
- Assert.hasText(ftpProtocol.getDirection(), "Ftp Protocol direction is
required.");
- Assert.hasText(ftpProtocol.getTimeout(), "Ftp Protocol timeout is
required.");
+ String validationError = ftpProtocol.validationError();
+ Assert.isNull(validationError, validationError);
}
@Override
@@ -202,6 +209,7 @@ public class FtpCollectImpl extends AbstractCollect {
SshClient client = null;
try {
client = SshClient.setUpDefaultClient();
+ client.setServerKeyVerifier(createServerKeyVerifier(ftpProtocol));
session = connect(client, ftpProtocol);
sftpClient =
SftpClientFactory.instance().createSftpClient(session);
Map<String, String> valueMap = collectValue(sftpClient,
ftpProtocol);
@@ -229,4 +237,44 @@ public class FtpCollectImpl extends AbstractCollect {
}
}
}
-}
\ No newline at end of file
+
+ static ServerKeyVerifier createServerKeyVerifier(FtpProtocol ftpProtocol) {
+ if (Boolean.parseBoolean(ftpProtocol.getInsecureSkipVerify())) {
+ logInsecureVerification(ftpProtocol);
+ return AcceptAllServerKeyVerifier.INSTANCE;
+ }
+ Assert.hasText(ftpProtocol.getHostKeyFingerprint(),
+ "Sftp Protocol host key fingerprint is required. "
+ + "Obtain it through a trusted channel; see the FTP
monitor guide.");
+ Assert.isTrue(ftpProtocol.hasValidHostKeyFingerprints(),
+ "Sftp Protocol host key fingerprints must use the
SHA256:base64 format.");
+ List<String> expectedFingerprints =
ftpProtocol.parseHostKeyFingerprints();
+ Assert.notEmpty(expectedFingerprints,
+ "Sftp Protocol host key fingerprint list must not be empty.");
+ return (clientSession, remoteAddress, serverKey) -> {
+ boolean matches = serverKey != null &&
expectedFingerprints.stream()
+ .anyMatch(expectedFingerprint -> Boolean.TRUE.equals(
+ KeyUtils.checkFingerPrint(
+ expectedFingerprint,
+ BuiltinDigests.sha256,
+ serverKey).getKey()));
+ if (!matches) {
+ log.warn("[SFTPClient] server host key did not match for
{}:{}",
+ ftpProtocol.getHost(), ftpProtocol.getPort());
+ }
+ return matches;
+ };
+ }
+
+ private static void logInsecureVerification(FtpProtocol ftpProtocol) {
+ String endpoint = Objects.toString(ftpProtocol.getHost(), "<unknown>")
+ + ':' + Objects.toString(ftpProtocol.getPort(), "<unknown>");
+ if (INSECURE_WARNING_ENDPOINTS.size() < MAX_INSECURE_WARNING_ENDPOINTS
+ && INSECURE_WARNING_ENDPOINTS.add(endpoint)) {
+ log.warn("[SFTPClient] host key verification is disabled for {}; "
+ + "configure trusted host key fingerprints and re-enable
verification", endpoint);
+ } else {
+ log.debug("[SFTPClient] host key verification remains disabled for
{}", endpoint);
+ }
+ }
+}
diff --git
a/hertzbeat-collector/hertzbeat-collector-basic/src/test/java/org/apache/hertzbeat/collector/collect/ftp/FtpCollectImplTest.java
b/hertzbeat-collector/hertzbeat-collector-basic/src/test/java/org/apache/hertzbeat/collector/collect/ftp/FtpCollectImplTest.java
index 5af290d60d..916a441b40 100644
---
a/hertzbeat-collector/hertzbeat-collector-basic/src/test/java/org/apache/hertzbeat/collector/collect/ftp/FtpCollectImplTest.java
+++
b/hertzbeat-collector/hertzbeat-collector-basic/src/test/java/org/apache/hertzbeat/collector/collect/ftp/FtpCollectImplTest.java
@@ -18,15 +18,25 @@
package org.apache.hertzbeat.collector.collect.ftp;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
+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 java.io.IOException;
+import java.net.InetSocketAddress;
+import java.security.KeyPairGenerator;
import java.util.ArrayList;
import java.util.List;
import org.apache.commons.net.ftp.FTPClient;
import org.apache.hertzbeat.common.entity.job.Metrics;
import org.apache.hertzbeat.common.entity.job.protocol.FtpProtocol;
import org.apache.hertzbeat.common.entity.message.CollectRep;
+import org.apache.sshd.client.keyverifier.AcceptAllServerKeyVerifier;
+import org.apache.sshd.client.keyverifier.ServerKeyVerifier;
+import org.apache.sshd.client.session.ClientSession;
+import org.apache.sshd.common.config.keys.KeyUtils;
+import org.apache.sshd.common.digest.BuiltinDigests;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
@@ -143,5 +153,99 @@ class FtpCollectImplTest {
}
+ @Test
+ void serverKeyVerifierSupportsHostKeyRotationWindow() throws Exception {
+ var currentKey = generateEcPublicKey();
+ var nextKey = generateEcPublicKey();
+ var unrelatedKey = generateEcPublicKey();
+ FtpProtocol ftpProtocol = FtpProtocol.builder()
+ .host("sftp.example.com")
+ .port("22")
+
.hostKeyFingerprint(KeyUtils.getFingerPrint(BuiltinDigests.sha256, currentKey)
+ + System.lineSeparator()
+ + KeyUtils.getFingerPrint(BuiltinDigests.sha256,
nextKey))
+ .build();
+
+ ServerKeyVerifier verifier =
FtpCollectImpl.createServerKeyVerifier(ftpProtocol);
+ ClientSession session = Mockito.mock(ClientSession.class);
+ InetSocketAddress address =
InetSocketAddress.createUnresolved("sftp.example.com", 22);
+
+ assertTrue(verifier.verifyServerKey(session, address, currentKey));
+ assertTrue(verifier.verifyServerKey(session, address, nextKey));
+ assertFalse(verifier.verifyServerKey(session, address, unrelatedKey));
+ }
+
+ @Test
+ void serverKeyVerifierRejectsNonSha256Fingerprints() {
+ var serverKey = generateEcPublicKey();
+ FtpProtocol ftpProtocol = FtpProtocol.builder()
+
.hostKeyFingerprint(KeyUtils.getFingerPrint(BuiltinDigests.md5, serverKey))
+ .build();
+
+ assertThrows(IllegalArgumentException.class,
+ () -> FtpCollectImpl.createServerKeyVerifier(ftpProtocol));
+ }
+
+ @Test
+ void serverKeyVerifierAllowsExplicitVerificationOptOut() {
+ FtpProtocol ftpProtocol = FtpProtocol.builder()
+ .insecureSkipVerify("true")
+ .build();
+
+ assertSame(
+ AcceptAllServerKeyVerifier.INSTANCE,
+ FtpCollectImpl.createServerKeyVerifier(ftpProtocol));
+ }
+
+ @Test
+ void preCheckRejectsMalformedVerificationOptOut() {
+ FtpProtocol ftpProtocol = FtpProtocol.builder()
+ .host("sftp.example.com")
+ .port("22")
+ .direction("/data")
+ .timeout("3000")
+ .ssl("true")
+ .username("admin")
+ .password("secret")
+ .hostKeyFingerprint(KeyUtils.getFingerPrint(
+ BuiltinDigests.sha256,
+ generateEcPublicKey()))
+ .insecureSkipVerify("enabled")
+ .build();
+ Metrics metrics = new Metrics();
+ metrics.setFtp(ftpProtocol);
+ assertThrows(IllegalArgumentException.class, () ->
ftpCollectImpl.preCheck(metrics));
+ }
+
+ @Test
+ void preCheckFailsClosedForSftpWithoutHostKeyPolicy() {
+ FtpProtocol ftpProtocol = FtpProtocol.builder()
+ .host("sftp.example.com")
+ .port("22")
+ .direction("/data")
+ .timeout("3000")
+ .ssl("true")
+ .username("admin")
+ .password("secret")
+ .build();
+ Metrics metrics = new Metrics();
+ metrics.setFtp(ftpProtocol);
+
+ IllegalArgumentException exception = assertThrows(
+ IllegalArgumentException.class,
+ () -> ftpCollectImpl.preCheck(metrics));
+
+ assertTrue(exception.getMessage().contains("host key fingerprint is
required"));
+ }
+
+ private static java.security.PublicKey generateEcPublicKey() {
+ try {
+ KeyPairGenerator keyPairGenerator =
KeyPairGenerator.getInstance("EC");
+ keyPairGenerator.initialize(256);
+ return keyPairGenerator.generateKeyPair().getPublic();
+ } catch (Exception exception) {
+ throw new IllegalStateException(exception);
+ }
+ }
}
diff --git
a/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/entity/job/protocol/FtpProtocol.java
b/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/entity/job/protocol/FtpProtocol.java
index 3767620056..7742408da0 100644
---
a/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/entity/job/protocol/FtpProtocol.java
+++
b/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/entity/job/protocol/FtpProtocol.java
@@ -20,6 +20,9 @@ package org.apache.hertzbeat.common.entity.job.protocol;
import static org.apache.hertzbeat.common.util.IpDomainUtil.validPort;
import static org.apache.hertzbeat.common.util.IpDomainUtil.validateIpDomain;
+import java.util.Arrays;
+import java.util.List;
+import java.util.regex.Pattern;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
@@ -35,6 +38,10 @@ import org.apache.hertzbeat.common.util.CommonUtil;
@AllArgsConstructor
@NoArgsConstructor
public class FtpProtocol implements CommonRequestProtocol, Protocol {
+
+ private static final Pattern SHA256_FINGERPRINT_PATTERN =
+ Pattern.compile("SHA256:[A-Za-z0-9+/]{43}=?");
+ private static final String UNRESOLVED_SSL_PLACEHOLDER = "^_^ssl^_^";
/**
* Peer host ip or domain name
*/
@@ -71,19 +78,84 @@ public class FtpProtocol implements CommonRequestProtocol,
Protocol {
*/
private String ssl = "false";
+ /**
+ * Expected SFTP server host key fingerprints, separated by commas or line
+ * breaks, for example SHA256:base64.
+ */
+ private String hostKeyFingerprint;
+
+ /**
+ * Whether SFTP host key verification is explicitly disabled.
+ */
+ private String insecureSkipVerify;
+
@Override
public boolean isInvalid() {
- if (!validateIpDomain(host) || !validPort(port) ||
StringUtils.isBlank(direction) || StringUtils.isBlank(timeout)) {
- return true;
+ return validationError() != null;
+ }
+
+ /**
+ * Validate the complete FTP/SFTP protocol contract used by collectors.
+ *
+ * @return a safe operator-facing error, or {@code null} when valid
+ */
+ public String validationError() {
+ if (!validateIpDomain(host)) {
+ return "Ftp Protocol host is invalid.";
+ }
+ if (!validPort(port)) {
+ return "Ftp Protocol port is invalid.";
}
- if (!CommonUtil.isNumeric(timeout)) {
- return true;
+ if (StringUtils.isBlank(direction)) {
+ return "Ftp Protocol direction is required.";
+ }
+ if (StringUtils.isBlank(timeout) || !CommonUtil.isNumeric(timeout)) {
+ return "Ftp Protocol timeout must be numeric.";
+ }
+ if (UNRESOLVED_SSL_PLACEHOLDER.equals(ssl)) {
+ return null;
}
if (StringUtils.isNotBlank(ssl)
&& !"true".equalsIgnoreCase(ssl)
&& !"false".equalsIgnoreCase(ssl)) {
- return true;
+ return "Ftp Protocol SFTP option must be true or false.";
+ }
+ if (!"true".equalsIgnoreCase(ssl)) {
+ return null;
+ }
+ if (StringUtils.isNotBlank(insecureSkipVerify)
+ && !"true".equalsIgnoreCase(insecureSkipVerify)
+ && !"false".equalsIgnoreCase(insecureSkipVerify)) {
+ return "Sftp Protocol skip-verification option must be true or
false.";
+ }
+ if (StringUtils.isAnyBlank(username, password)) {
+ return "Sftp Protocol username and password are required.";
+ }
+ if ("true".equalsIgnoreCase(insecureSkipVerify)) {
+ return null;
+ }
+ if (StringUtils.isBlank(hostKeyFingerprint)) {
+ return "Sftp Protocol host key fingerprint is required unless
verification is explicitly skipped.";
+ }
+ if (!hasValidHostKeyFingerprints()) {
+ return "Sftp Protocol host key fingerprints must use the
SHA256:base64 format.";
+ }
+ return null;
+ }
+
+ public boolean hasValidHostKeyFingerprints() {
+ List<String> fingerprints = parseHostKeyFingerprints();
+ return !fingerprints.isEmpty()
+ && fingerprints.stream().allMatch(value ->
SHA256_FINGERPRINT_PATTERN.matcher(value).matches());
+ }
+
+ public List<String> parseHostKeyFingerprints() {
+ if (StringUtils.isBlank(hostKeyFingerprint)) {
+ return List.of();
}
- return "true".equalsIgnoreCase(ssl) &&
StringUtils.isAnyBlank(username, password);
+ return Arrays.stream(hostKeyFingerprint.split("[,;\\r\\n]+"))
+ .map(String::trim)
+ .filter(StringUtils::isNotEmpty)
+ .toList();
}
}
diff --git
a/hertzbeat-common-core/src/test/java/org/apache/hertzbeat/common/entity/job/protocol/FtpProtocolTest.java
b/hertzbeat-common-core/src/test/java/org/apache/hertzbeat/common/entity/job/protocol/FtpProtocolTest.java
index b17795b246..a60f25f9b6 100644
---
a/hertzbeat-common-core/src/test/java/org/apache/hertzbeat/common/entity/job/protocol/FtpProtocolTest.java
+++
b/hertzbeat-common-core/src/test/java/org/apache/hertzbeat/common/entity/job/protocol/FtpProtocolTest.java
@@ -20,10 +20,14 @@ package org.apache.hertzbeat.common.entity.job.protocol;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
+import org.apache.hertzbeat.common.util.JsonUtil;
import org.junit.jupiter.api.Test;
class FtpProtocolTest {
+ private static final String VALID_SHA256_FINGERPRINT =
+ "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
+
@Test
void isInvalidValidAnonymousFtp() {
FtpProtocol protocol = FtpProtocol.builder()
@@ -36,6 +40,34 @@ class FtpProtocolTest {
assertFalse(protocol.isInvalid());
}
+ @Test
+ void isValidPlainFtpWhenSftpOnlyOptionRemainsUnresolved() {
+ FtpProtocol protocol = FtpProtocol.builder()
+ .host("ftp.example.com")
+ .port("21")
+ .direction("/")
+ .timeout("3000")
+ .ssl("false")
+ .insecureSkipVerify("^_^insecureSkipVerify^_^")
+ .build();
+
+ assertFalse(protocol.isInvalid());
+ }
+
+ @Test
+ void isValidLegacyFtpWhenSslOptionRemainsUnresolved() {
+ FtpProtocol protocol = FtpProtocol.builder()
+ .host("ftp.example.com")
+ .port("21")
+ .direction("/")
+ .timeout("3000")
+ .ssl("^_^ssl^_^")
+ .insecureSkipVerify("^_^insecureSkipVerify^_^")
+ .build();
+
+ assertFalse(protocol.isInvalid());
+ }
+
@Test
void isInvalidValidSftp() {
FtpProtocol protocol = FtpProtocol.builder()
@@ -46,6 +78,7 @@ class FtpProtocolTest {
.ssl("true")
.username("admin")
.password("secret")
+ .hostKeyFingerprint(VALID_SHA256_FINGERPRINT)
.build();
assertFalse(protocol.isInvalid());
}
@@ -85,6 +118,67 @@ class FtpProtocolTest {
assertTrue(protocol.isInvalid());
}
+ @Test
+ void isInvalidSftpWithoutHostIdentityConfiguration() {
+ FtpProtocol protocol = FtpProtocol.builder()
+ .host("sftp.example.com")
+ .port("22")
+ .direction("/data")
+ .timeout("3000")
+ .ssl("true")
+ .username("admin")
+ .password("secret")
+ .build();
+ assertTrue(protocol.isInvalid());
+ }
+
+ @Test
+ void isInvalidSftpWhenSkipVerificationOptionRemainsUnresolved() {
+ FtpProtocol protocol = FtpProtocol.builder()
+ .host("sftp.example.com")
+ .port("22")
+ .direction("/data")
+ .timeout("3000")
+ .ssl("true")
+ .username("admin")
+ .password("secret")
+ .hostKeyFingerprint(VALID_SHA256_FINGERPRINT)
+ .insecureSkipVerify("^_^insecureSkipVerify^_^")
+ .build();
+
+ assertTrue(protocol.isInvalid());
+ }
+
+ @Test
+ void isValidSftpWithExplicitVerificationOptOut() {
+ FtpProtocol protocol = FtpProtocol.builder()
+ .host("sftp.example.com")
+ .port("22")
+ .direction("/data")
+ .timeout("3000")
+ .ssl("true")
+ .username("admin")
+ .password("secret")
+ .insecureSkipVerify("true")
+ .build();
+ assertFalse(protocol.isInvalid());
+ }
+
+ @Test
+ void isInvalidSftpWithMalformedHostKeyFingerprint() {
+ FtpProtocol protocol = FtpProtocol.builder()
+ .host("sftp.example.com")
+ .port("22")
+ .direction("/data")
+ .timeout("3000")
+ .ssl("true")
+ .username("admin")
+ .password("secret")
+ .hostKeyFingerprint("SHA256:not-a-valid-fingerprint")
+ .build();
+ assertTrue(protocol.isInvalid());
+ }
+
@Test
void isInvalidInvalidTimeout() {
FtpProtocol protocol = FtpProtocol.builder()
@@ -95,4 +189,23 @@ class FtpProtocolTest {
.build();
assertTrue(protocol.isInvalid());
}
+
+ @Test
+ void serializationDoesNotExposeComputedValidationProperties() {
+ FtpProtocol protocol = FtpProtocol.builder()
+ .host("sftp.example.com")
+ .port("22")
+ .direction("/data")
+ .timeout("3000")
+ .ssl("true")
+ .username("admin")
+ .password("secret")
+ .hostKeyFingerprint(VALID_SHA256_FINGERPRINT)
+ .build();
+
+ String json = JsonUtil.toJson(protocol);
+
+ assertFalse(json.contains("validationError"));
+ assertFalse(json.contains("parsedHostKeyFingerprints"));
+ }
}
diff --git a/hertzbeat-manager/src/main/resources/define/app-ftp.yml
b/hertzbeat-manager/src/main/resources/define/app-ftp.yml
index 1fabc0ad1b..1ac76be185 100644
--- a/hertzbeat-manager/src/main/resources/define/app-ftp.yml
+++ b/hertzbeat-manager/src/main/resources/define/app-ftp.yml
@@ -106,6 +106,30 @@ params:
type: boolean
# required-true or false
required: true
+ - field: hostKeyFingerprint
+ name:
+ zh-CN: SFTP主机密钥指纹(未跳过验证时必填,可填写多个)
+ en-US: SFTP Host Key Fingerprints (Required Unless Verification Is
Skipped)
+ ja-JP: SFTPホストキーフィンガープリント(検証をスキップしない場合は必須)
+ type: textarea
+ placeholder: 'SHA256:... (one per line; verify through a trusted channel)'
+ required: false
+ hide: true
+ depend:
+ ssl:
+ - true
+ - field: insecureSkipVerify
+ name:
+ zh-CN: 危险:临时跳过SFTP主机密钥验证
+ en-US: 'DANGER: Temporarily Skip SFTP Host Key Verification'
+ ja-JP: 危険:SFTPホストキー検証を一時的にスキップ
+ type: boolean
+ defaultValue: false
+ required: false
+ hide: true
+ depend:
+ ssl:
+ - true
# collect metrics config list
metrics:
# metrics - basic
@@ -144,3 +168,5 @@ metrics:
direction: ^_^direction^_^
timeout: ^_^timeout^_^
ssl: ^_^ssl^_^
+ hostKeyFingerprint: ^_^hostKeyFingerprint^_^
+ insecureSkipVerify: ^_^insecureSkipVerify^_^
diff --git a/home/docs/help/ftp.md b/home/docs/help/ftp.md
index ae0f1aca88..0d87457beb 100644
--- a/home/docs/help/ftp.md
+++ b/home/docs/help/ftp.md
@@ -20,10 +20,36 @@ keywords: [ open source monitoring tool, open source ftp
server monitoring tool,
| Timeout | Timeout for connecting to FTP server.
|
| Username | Username for connecting to the FTP server, optional.
|
| Password | Password for connecting to the FTP server, optional.
|
+| SFTP | Use SFTP instead of FTP. SFTP requires a username and
password. |
+| Host key fingerprints | Trusted SFTP server SHA-256 fingerprints, one per
line or separated by commas. Required unless verification is explicitly
skipped. |
+| Skip host key verification | **Dangerous option.** Use only for a controlled
diagnostic; it does not authenticate the SFTP server. |
| Collection interval | Interval time of monitor periodic data collection,
unit: second, and the minimum interval that can be set is 30 seconds. |
| Bind Tags | Used to classify and manage monitoring resources.
|
| Description remarks | For more information about identifying and describing
this monitoring, users can note information here. |
+## SFTP host key verification
+
+HertzBeat accepts only the configured SFTP host keys. Obtain the server keys,
+then verify their fingerprints through a trusted channel such as the server
+console, configuration management, or an administrator. `ssh-keyscan` alone
+does not authenticate a server.
+
+```shell
+ssh-keyscan -p 22 sftp.example.com > /tmp/sftp-host-keys
+ssh-keygen -lf /tmp/sftp-host-keys -E sha256
+```
+
+Copy the verified `SHA256:...` values into **SFTP Host Key Fingerprints**. The
+field accepts one value per line or comma-separated values.
+
+For a planned host-key rotation, verify the new key first, add both the current
+and new fingerprints, rotate the server key, and remove the old fingerprint
+only after all HertzBeat collectors use the new key.
+
+SFTP monitors and imported configurations must pin at least one fingerprint
+unless the operator explicitly selects the dangerous skip-verification option.
+HertzBeat does not enable that option automatically.
+
### Collection Metrics
#### Metrics Set:Basic
diff --git a/home/docs/start/upgrade.md b/home/docs/start/upgrade.md
index 4fbf39648b..a83e6f23ff 100644
--- a/home/docs/start/upgrade.md
+++ b/home/docs/start/upgrade.md
@@ -16,6 +16,24 @@ Apache HertzBeat's metadata information is stored in H2 or
Mysql, PostgreSQL rel
## Breaking Changes In 1.9.0
+### SFTP monitors require an explicit host-key policy
+
+1.9.0 stops accepting any SFTP server key by default. Every SFTP monitor must
+either pin one or more trusted `SHA256:...` host-key fingerprints or explicitly
+select the dangerous temporary skip-verification option.
+
+This is a fail-closed breaking change. HertzBeat does not automatically enable
+skip verification for 1.8.x monitors, imports, or direct API/SQL-created rows.
+Before or immediately after upgrading, edit each SFTP monitor and:
+
+1. obtain the server key and verify its fingerprint through a trusted channel;
+2. add the verified fingerprint to **SFTP Host Key Fingerprints**; and
+3. use the skip-verification option only as a short-lived recovery measure.
+
+Until one of those policies is configured, the affected SFTP monitor reports a
+configuration failure and does not connect. Plain FTP monitors are unchanged.
+See [FTP Monitor](../help/ftp) for fingerprint acquisition and key rotation.
+
### Observability (OTLP / logs / traces) API paths moved
1.9.0 consolidates the 1.8.x log module into `hertzbeat-observability`.
Metrics, logs and traces now share one ingestion prefix
(`/api/otlp/v1/{signal}`) and one query prefix (`/api/observability/**`). Any
OpenTelemetry Collector, Vector, SDK exporter, script or dashboard that was
configured against a 1.8.x path must be updated.
diff --git a/home/i18n/zh-cn/docusaurus-plugin-content-docs/current/help/ftp.md
b/home/i18n/zh-cn/docusaurus-plugin-content-docs/current/help/ftp.md
index aeff3763f2..ef5f6c61b9 100644
--- a/home/i18n/zh-cn/docusaurus-plugin-content-docs/current/help/ftp.md
+++ b/home/i18n/zh-cn/docusaurus-plugin-content-docs/current/help/ftp.md
@@ -20,10 +20,32 @@ keywords: [ 开源监控系统, 开源FTP服务器监控工具, 监控FTP指标
| 超时时间 | 连接FTP服务器超时时间,默认值:1000毫秒。 |
| 用户名 | 连接FTP服务的用户名, 可选。 |
| 密码 | 连接FTP服务的密码,可选。 |
+| 启用SFTP | 使用SFTP替代FTP;SFTP必须配置用户名和密码。 |
+| SFTP主机密钥指纹 | 可信的SFTP服务器SHA-256指纹,每行一个或使用逗号分隔;除非显式跳过验证,否则必填。 |
+| 跳过主机密钥验证 | **危险选项。** 仅应用于受控诊断;启用后无法验证SFTP服务器身份。 |
| 采集间隔 | 监控周期性采集数据间隔时间,单位秒,可设置的最小间隔为30秒。 |
| 绑定标签 | 用于对监控资源进行分类管理。 |
| 描述备注 | 更多标识和描述此监控的备注信息,用户可以在这里备注信息。 |
+## SFTP主机密钥验证
+
+HertzBeat只接受已配置的SFTP主机密钥。请先获取服务器密钥,再通过服务器控制台、
+配置管理系统或管理员等可信渠道核对指纹。单独使用`ssh-keyscan`不能证明服务器身份。
+
+```shell
+ssh-keyscan -p 22 sftp.example.com > /tmp/sftp-host-keys
+ssh-keygen -lf /tmp/sftp-host-keys -E sha256
+```
+
+将核对后的`SHA256:...`填写到“SFTP主机密钥指纹”中。可以每行填写一个,也可以用
+逗号分隔。
+
+计划轮换主机密钥时,先通过可信渠道核对新密钥,将旧、新指纹同时加入配置,再轮换
+服务器密钥;所有HertzBeat采集器都使用新密钥后,才能删除旧指纹。
+
+SFTP监控和导入的配置必须至少固定一个指纹;只有操作员显式选择危险的跳过验证
+选项时才允许省略。HertzBeat不会自动启用该选项。
+
### 采集指标
#### 指标集合:概要
diff --git
a/home/i18n/zh-cn/docusaurus-plugin-content-docs/current/start/upgrade.md
b/home/i18n/zh-cn/docusaurus-plugin-content-docs/current/start/upgrade.md
index d36d3698f2..b6026a6fad 100644
--- a/home/i18n/zh-cn/docusaurus-plugin-content-docs/current/start/upgrade.md
+++ b/home/i18n/zh-cn/docusaurus-plugin-content-docs/current/start/upgrade.md
@@ -16,6 +16,21 @@ HertzBeat 的元数据信息保存在 H2 或 Mysql, PostgreSQL 关系型数据
## 1.9.0 不兼容变更
+### SFTP监控必须显式配置主机密钥策略
+
+1.9.0不再默认接受任意SFTP服务器密钥。每个SFTP监控必须配置一个或多个可信的
+`SHA256:...`主机密钥指纹,或者由操作员显式选择危险的临时跳过验证选项。
+
+这是一个失败关闭的不兼容变更。HertzBeat不会为1.8.x监控、导入配置或通过API/SQL
+直接创建的记录自动启用跳过验证。升级前或升级后应立即编辑每个SFTP监控:
+
+1. 获取服务器密钥,并通过可信渠道核对指纹;
+2. 将核对后的指纹添加到“SFTP主机密钥指纹”;
+3. 仅把跳过验证选项用于短期故障恢复。
+
+完成上述任一策略配置前,受影响的SFTP监控会报告配置失败且不会建立连接。普通FTP
+监控不受影响。指纹获取与密钥轮换方法请参阅[FTP监控](../help/ftp)。
+
### 可观测(OTLP / 日志 / 链路)接口路径变更
1.9.0 将 1.8.x 的日志模块合并为 `hertzbeat-observability`,指标、日志、链路统一使用
`/api/otlp/v1/{signal}` 接收、`/api/observability/**` 查询。所有按 1.8.x 路径配置的
OpenTelemetry Collector、Vector、SDK exporter、脚本或看板都需要更新。
diff --git
a/web-app/src/app/routes/monitor/monitor-form/monitor-form.component.spec.ts
b/web-app/src/app/routes/monitor/monitor-form/monitor-form.component.spec.ts
index 7603f02c09..aec3b710d4 100644
--- a/web-app/src/app/routes/monitor/monitor-form/monitor-form.component.spec.ts
+++ b/web-app/src/app/routes/monitor/monitor-form/monitor-form.component.spec.ts
@@ -99,4 +99,54 @@ describe('MonitorFormComponent', () => {
expect(payloadParam.display).toBeTrue();
expect(component.hasAdvancedParams).toBeTrue();
});
+
+ it('should reevaluate dependent advanced fields when a boolean parent
changes', () => {
+ const fingerprintDefine = new ParamDefine();
+ fingerprintDefine.field = 'hostKeyFingerprint';
+ fingerprintDefine.name = 'SFTP Host Key Fingerprints';
+ fingerprintDefine.type = 'textarea';
+ (fingerprintDefine as any).depend = {
+ ssl: [true]
+ };
+
+ const fingerprintParam = new Param();
+ fingerprintParam.field = 'hostKeyFingerprint';
+ fingerprintParam.paramValue = 'SHA256:test';
+ fingerprintParam.display = false;
+
+ component.monitor.app = 'ftp';
+ component.paramDefines = [];
+ component.params = [];
+ component.sdDefines = [];
+ component.sdParams = [];
+ component.advancedParamDefines = [fingerprintDefine];
+ component.advancedParams = [fingerprintParam];
+
+ component.onParamBooleanChanged(true, 'ssl');
+ expect(fingerprintParam.display).toBeTrue();
+ expect(component.hasAdvancedParams).toBeTrue();
+
+ component.onParamBooleanChanged(false, 'ssl');
+ expect(fingerprintParam.display).toBeFalse();
+ expect(fingerprintParam.paramValue).toBeNull();
+ expect(component.hasAdvancedParams).toBeFalse();
+ });
+
+ it('should not treat a persisted false string as an enabled boolean', () => {
+ const portParam = new Param();
+ portParam.field = 'port';
+ portParam.paramValue = 21;
+
+ component.monitor.app = 'ftp';
+ component.params = [portParam];
+ component.paramDefines = [];
+ component.sdDefines = [];
+ component.sdParams = [];
+ component.advancedParamDefines = [];
+ component.advancedParams = [];
+
+ component.onParamBooleanChanged('false', 'ssl');
+
+ expect(portParam.paramValue).toBe(21);
+ });
});
diff --git
a/web-app/src/app/routes/monitor/monitor-form/monitor-form.component.ts
b/web-app/src/app/routes/monitor/monitor-form/monitor-form.component.ts
index e695d1dd88..5545857413 100644
--- a/web-app/src/app/routes/monitor/monitor-form/monitor-form.component.ts
+++ b/web-app/src/app/routes/monitor/monitor-form/monitor-form.component.ts
@@ -198,16 +198,17 @@ export class MonitorFormComponent implements OnChanges {
this.hostChange.emit(host);
}
- onParamBooleanChanged(booleanValue: boolean, field: string) {
+ onParamBooleanChanged(booleanValue: boolean | string, field: string) {
+ const enabled = booleanValue === true ||
String(booleanValue).toLowerCase() === 'true';
if (this.monitor.app === 'api') {
if (field === 'ssl') {
const portParam = this.params.find(param => param.field === 'port');
if (portParam) {
- if (booleanValue && (portParam.paramValue == null ||
parseInt(portParam.paramValue) === 80)) {
+ if (enabled && (portParam.paramValue == null ||
parseInt(portParam.paramValue) === 80)) {
portParam.paramValue = 443;
this.notifySvc.info(this.i18nSvc.fanyi('common.notice'),
this.i18nSvc.fanyi('monitor.new.notify.change-to-https'));
}
- if (!booleanValue && (portParam.paramValue == null ||
parseInt(portParam.paramValue) === 443)) {
+ if (!enabled && (portParam.paramValue == null ||
parseInt(portParam.paramValue) === 443)) {
portParam.paramValue = 80;
this.notifySvc.info(this.i18nSvc.fanyi('common.notice'),
this.i18nSvc.fanyi('monitor.new.notify.change-to-http'));
}
@@ -217,17 +218,18 @@ export class MonitorFormComponent implements OnChanges {
if (field === 'ssl') {
const portParam = this.params.find(param => param.field === 'port');
if (portParam) {
- if (booleanValue && (portParam.paramValue == null ||
parseInt(portParam.paramValue) === 21)) {
+ if (enabled && (portParam.paramValue == null ||
parseInt(portParam.paramValue) === 21)) {
portParam.paramValue = 22;
this.notifySvc.info(this.i18nSvc.fanyi('common.notice'),
this.i18nSvc.fanyi('monitor.new.notify.change-to-sftp'));
}
- if (!booleanValue && (portParam.paramValue == null ||
parseInt(portParam.paramValue) === 22)) {
+ if (!enabled && (portParam.paramValue == null ||
parseInt(portParam.paramValue) === 22)) {
portParam.paramValue = 21;
this.notifySvc.info(this.i18nSvc.fanyi('common.notice'),
this.i18nSvc.fanyi('monitor.new.notify.change-to-ftp'));
}
}
}
}
+ this.onDependChanged(String(enabled), field);
}
onDependChanged(dependValue: string, dependField: string) {
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]