maintenance: extend SFTP connection options (#4293)

Co-authored-by: Duansg <siguoduan@gmail.com>
This commit is contained in:
Logic
2026-08-27 00:42:23 +08:00
committed by GitHub
co-authored by Duansg
parent eda420d118
commit eb753be1f5
11 changed files with 512 additions and 16 deletions
@@ -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);
}
}
}
@@ -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);
}
}
}
@@ -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();
}
}
@@ -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^_^
+26
View File
@@ -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 SetBasic
+18
View File
@@ -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) {