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 03307e1c7a8..e1087b81e39 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 @@ -18,7 +18,10 @@ package org.apache.hertzbeat.collector.collect.ftp; import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Objects; @@ -32,7 +35,11 @@ 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; @@ -60,6 +67,13 @@ public void preCheck(Metrics metrics) throws IllegalArgumentException{ 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."); + if (Boolean.parseBoolean(ftpProtocol.getSsl()) + && !Boolean.parseBoolean(ftpProtocol.getInsecureSkipVerify())) { + Assert.hasText(ftpProtocol.getHostKeyFingerprint(), + "Sftp Protocol host key fingerprint is required."); + Assert.isTrue(ftpProtocol.hasValidHostKeyFingerprints(), + "Sftp Protocol host key fingerprints must use the SHA256:base64 format."); + } } @Override @@ -202,6 +216,7 @@ private void handleSftpCollect(CollectRep.MetricsData.Builder builder, Metrics m SshClient client = null; try { client = SshClient.setUpDefaultClient(); + client.setServerKeyVerifier(createServerKeyVerifier(ftpProtocol)); session = connect(client, ftpProtocol); sftpClient = SftpClientFactory.instance().createSftpClient(session); Map valueMap = collectValue(sftpClient, ftpProtocol); @@ -229,4 +244,33 @@ private void handleSftpCollect(CollectRep.MetricsData.Builder builder, Metrics m } } } -} \ No newline at end of file + + static ServerKeyVerifier createServerKeyVerifier(FtpProtocol ftpProtocol) { + if (Boolean.parseBoolean(ftpProtocol.getInsecureSkipVerify())) { + log.warn("[SFTPClient] host key verification is disabled for {}:{}; " + + "configure trusted host key fingerprints and re-enable verification", + ftpProtocol.getHost(), ftpProtocol.getPort()); + 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 expectedFingerprints = ftpProtocol.getParsedHostKeyFingerprints(); + Assert.notEmpty(expectedFingerprints, + "Sftp Protocol host key fingerprint list must not be empty."); + return (clientSession, remoteAddress, serverKey) -> { + String actualFingerprint = KeyUtils.getFingerPrint(BuiltinDigests.sha256, serverKey); + boolean matches = actualFingerprint != null && expectedFingerprints.stream() + .anyMatch(expectedFingerprint -> MessageDigest.isEqual( + expectedFingerprint.getBytes(StandardCharsets.UTF_8), + actualFingerprint.getBytes(StandardCharsets.UTF_8))); + if (!matches) { + log.warn("[SFTPClient] server host key did not match for {}:{}", + ftpProtocol.getHost(), ftpProtocol.getPort()); + } + return matches; + }; + } +} 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 5af290d60db..a8ff10eaf4b 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,23 @@ 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.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.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 +151,55 @@ void testAnonymousCollect() throws IOException { } + @Test + void serverKeyVerifierSupportsHostKeyRotationWindow() throws Exception { + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("EC"); + keyPairGenerator.initialize(256); + var currentKey = keyPairGenerator.generateKeyPair().getPublic(); + var nextKey = keyPairGenerator.generateKeyPair().getPublic(); + var unrelatedKey = keyPairGenerator.generateKeyPair().getPublic(); + 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); + + assertTrue(verifier.verifyServerKey(null, null, currentKey)); + assertTrue(verifier.verifyServerKey(null, null, nextKey)); + assertFalse(verifier.verifyServerKey(null, null, unrelatedKey)); + } + + @Test + void serverKeyVerifierAlwaysUsesSha256() throws Exception { + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("EC"); + keyPairGenerator.initialize(256); + var serverKey = keyPairGenerator.generateKeyPair().getPublic(); + FtpProtocol ftpProtocol = FtpProtocol.builder() + .hostKeyFingerprint(KeyUtils.getFingerPrint(BuiltinDigests.sha256, serverKey)) + .build(); + var previousFactory = KeyUtils.getDefaultFingerPrintFactory(); + + try { + KeyUtils.setDefaultFingerPrintFactory(BuiltinDigests.md5); + ServerKeyVerifier verifier = FtpCollectImpl.createServerKeyVerifier(ftpProtocol); + assertTrue(verifier.verifyServerKey(null, null, serverKey)); + } finally { + KeyUtils.setDefaultFingerPrintFactory(previousFactory); + } + } + + @Test + void serverKeyVerifierAllowsExplicitVerificationOptOut() { + FtpProtocol ftpProtocol = FtpProtocol.builder() + .insecureSkipVerify("true") + .build(); + + assertSame( + AcceptAllServerKeyVerifier.INSTANCE, + FtpCollectImpl.createServerKeyVerifier(ftpProtocol)); + } } 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 37676200567..d3eec1fab87 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 @@ 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,9 @@ @AllArgsConstructor @NoArgsConstructor public class FtpProtocol implements CommonRequestProtocol, Protocol { + + private static final Pattern SHA256_FINGERPRINT_PATTERN = + Pattern.compile("SHA256:[A-Za-z0-9+/]{43}=?"); /** * Peer host ip or domain name */ @@ -71,6 +77,17 @@ 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)) { @@ -84,6 +101,36 @@ public boolean isInvalid() { && !"false".equalsIgnoreCase(ssl)) { return true; } - return "true".equalsIgnoreCase(ssl) && StringUtils.isAnyBlank(username, password); + if (StringUtils.isNotBlank(insecureSkipVerify) + && !"true".equalsIgnoreCase(insecureSkipVerify) + && !"false".equalsIgnoreCase(insecureSkipVerify)) { + return true; + } + if (!"true".equalsIgnoreCase(ssl)) { + return false; + } + if (StringUtils.isAnyBlank(username, password)) { + return true; + } + if (StringUtils.isNotBlank(hostKeyFingerprint) && !hasValidHostKeyFingerprints()) { + return true; + } + return !"true".equalsIgnoreCase(insecureSkipVerify) && StringUtils.isBlank(hostKeyFingerprint); + } + + public boolean hasValidHostKeyFingerprints() { + List fingerprints = getParsedHostKeyFingerprints(); + return !fingerprints.isEmpty() + && fingerprints.stream().allMatch(value -> SHA256_FINGERPRINT_PATTERN.matcher(value).matches()); + } + + public List getParsedHostKeyFingerprints() { + if (StringUtils.isBlank(hostKeyFingerprint)) { + return List.of(); + } + 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 b17795b2469..f9ee725326c 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 @@ -24,6 +24,9 @@ class FtpProtocolTest { + private static final String VALID_SHA256_FINGERPRINT = + "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + @Test void isInvalidValidAnonymousFtp() { FtpProtocol protocol = FtpProtocol.builder() @@ -46,6 +49,7 @@ void isInvalidValidSftp() { .ssl("true") .username("admin") .password("secret") + .hostKeyFingerprint(VALID_SHA256_FINGERPRINT) .build(); assertFalse(protocol.isInvalid()); } @@ -85,6 +89,50 @@ void isInvalidSftpWithoutPassword() { 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 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() diff --git a/hertzbeat-manager/src/main/resources/define/app-ftp.yml b/hertzbeat-manager/src/main/resources/define/app-ftp.yml index 1fabc0ad1bc..8e3836ace40 100644 --- a/hertzbeat-manager/src/main/resources/define/app-ftp.yml +++ b/hertzbeat-manager/src/main/resources/define/app-ftp.yml @@ -106,6 +106,31 @@ 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 + limit: 2048 + 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 +169,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 ae0f1aca88a..0d87457bebe 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/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 aeff3763f2a..ef5f6c61b93 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不会自动启用该选项。 + ### 采集指标 #### 指标集合:概要