Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<String, String> valueMap = collectValue(sftpClient, ftpProtocol);
Expand Down Expand Up @@ -229,4 +244,33 @@ private void handleSftpCollect(CollectRep.MetricsData.Builder builder, Metrics m
}
}
}
}

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<String> 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;
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
*/
Expand Down Expand Up @@ -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)) {
Expand All @@ -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<String> fingerprints = getParsedHostKeyFingerprints();
return !fingerprints.isEmpty()
&& fingerprints.stream().allMatch(value -> SHA256_FINGERPRINT_PATTERN.matcher(value).matches());
}

public List<String> getParsedHostKeyFingerprints() {
if (StringUtils.isBlank(hostKeyFingerprint)) {
return List.of();
}
return Arrays.stream(hostKeyFingerprint.split("[,;\\r\\n]+"))
.map(String::trim)
.filter(StringUtils::isNotEmpty)
.toList();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@

class FtpProtocolTest {

private static final String VALID_SHA256_FINGERPRINT =
"SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";

@Test
void isInvalidValidAnonymousFtp() {
FtpProtocol protocol = FtpProtocol.builder()
Expand All @@ -46,6 +49,7 @@ void isInvalidValidSftp() {
.ssl("true")
.username("admin")
.password("secret")
.hostKeyFingerprint(VALID_SHA256_FINGERPRINT)
.build();
assertFalse(protocol.isInvalid());
}
Expand Down Expand Up @@ -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()
Expand Down
27 changes: 27 additions & 0 deletions hertzbeat-manager/src/main/resources/define/app-ftp.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -144,3 +169,5 @@ metrics:
direction: ^_^direction^_^
timeout: ^_^timeout^_^
ssl: ^_^ssl^_^
hostKeyFingerprint: ^_^hostKeyFingerprint^_^
insecureSkipVerify: ^_^insecureSkipVerify^_^
26 changes: 26 additions & 0 deletions home/docs/help/ftp.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading