mirror of
https://github.com/dromara/hertzbeat.git
synced 2026-09-17 09:40:58 +00:00
maintenance: extend SFTP connection options (#4293)
Co-authored-by: Duansg <siguoduan@gmail.com>
This commit is contained in:
+52
-4
@@ -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 {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+104
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+78
-6
@@ -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 (!CommonUtil.isNumeric(timeout)) {
|
||||
return true;
|
||||
if (!validPort(port)) {
|
||||
return "Ftp Protocol port is invalid.";
|
||||
}
|
||||
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.";
|
||||
}
|
||||
return "true".equalsIgnoreCase(ssl) && StringUtils.isAnyBlank(username, password);
|
||||
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 Arrays.stream(hostKeyFingerprint.split("[,;\\r\\n]+"))
|
||||
.map(String::trim)
|
||||
.filter(StringUtils::isNotEmpty)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
+113
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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^_^
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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不会自动启用该选项。
|
||||
|
||||
### 采集指标
|
||||
|
||||
#### 指标集合:概要
|
||||
|
||||
@@ -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、脚本或看板都需要更新。
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user