From e533b4e10679525844ac30b9ad4e105a7cc3db20 Mon Sep 17 00:00:00 2001 From: Logic Date: Fri, 31 Jul 2026 07:55:38 +0800 Subject: [PATCH 1/2] maintenance: version cluster message envelopes --- .gitignore | 4 + README.md | 24 +- README_CN.md | 23 +- README_JP.md | 24 +- .../dispatch/entrance/CollectServerTest.java | 36 + .../CollectorRuntimeHintsRegistrar.java | 2 - .../src/main/resources/application.yml | 12 + ...rDocumentedEnvironmentIntegrationTest.java | 136 +++ .../dispatch/DispatchProperties.java | 14 + .../dispatch/entrance/CollectServer.java | 46 +- .../entrance/processor/GoOnlineProcessor.java | 14 +- .../processor/GoOnlineProcessorTest.java | 31 +- .../common/entity/dto/ServerInfo.java | 40 - .../common/entity/message/ClusterMsg.java | 1038 ++++++++++++++--- .../src/main/message/cluster_msg.proto | 14 + .../scheduler/CollectorJobScheduler.java | 4 - .../scheduler/SchedulerProperties.java | 3 + .../manager/scheduler/netty/ManageServer.java | 8 +- .../process/CollectorOnlineProcessor.java | 6 +- .../ClusterAuthenticationPackagingTest.java | 114 ++ .../manager/config/ConfigInitializerTest.java | 117 ++ .../scheduler/CollectorJobSchedulerTest.java | 24 + .../process/CollectorOnlineProcessorTest.java | 85 ++ hertzbeat-remoting/pom.xml | 8 + .../netty/ClusterMessageAuthConfig.java | 206 ++++ .../netty/ClusterMessageAuthMetrics.java | 65 ++ .../netty/ClusterMessageAuthenticator.java | 287 +++++ .../remoting/netty/NettyRemotingAbstract.java | 221 +++- .../remoting/netty/NettyRemotingClient.java | 12 + .../remoting/netty/NettyRemotingServer.java | 12 + .../remoting/RemotingServiceTest.java | 25 +- .../ClusterMessageAuthenticatorTest.java | 192 +++ .../NettyRemotingAuthenticationTest.java | 166 +++ .../netty/NettyRemotingCompatibilityTest.java | 241 ++++ .../src/main/resources/application.yml | 10 + home/docs/help/collector.md | 91 ++ home/docs/start/docker-compose-deploy.md | 11 +- home/docs/start/docker-deploy.md | 15 +- home/docs/start/native-collector.md | 4 + home/docs/start/package-deploy.md | 29 +- home/docs/start/quickstart.md | 47 +- home/docs/start/upgrade.md | 39 + script/application.yml | 10 + script/docker-compose/README.md | 5 + .../hertzbeat-mysql-iotdb/README.md | 14 +- .../hertzbeat-mysql-iotdb/README_CN.md | 13 +- .../conf/application.yml | 9 + .../hertzbeat-mysql-iotdb/docker-compose.yaml | 4 + .../hertzbeat-mysql-tdengine/README.md | 16 +- .../hertzbeat-mysql-tdengine/README_CN.md | 15 +- .../conf/application.yml | 9 + .../docker-compose.yaml | 4 + .../README.md | 14 +- .../README_CN.md | 13 +- .../conf/application.yml | 9 + .../docker-compose.yaml | 4 + .../hertzbeat-postgresql-greptimedb/README.md | 14 +- .../README_CN.md | 13 +- .../conf/application.yml | 9 + .../docker-compose.yaml | 4 + .../README.md | 14 +- .../README_CN.md | 13 +- .../conf/application.yml | 9 + .../docker-compose.yaml | 4 + 64 files changed, 3380 insertions(+), 339 deletions(-) create mode 100644 hertzbeat-collector/hertzbeat-collector-collector/src/test/java/org/apache/hertzbeat/collector/CollectorDocumentedEnvironmentIntegrationTest.java delete mode 100644 hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/entity/dto/ServerInfo.java create mode 100644 hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/config/ClusterAuthenticationPackagingTest.java create mode 100644 hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/config/ConfigInitializerTest.java create mode 100644 hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/scheduler/netty/process/CollectorOnlineProcessorTest.java create mode 100644 hertzbeat-remoting/src/main/java/org/apache/hertzbeat/remoting/netty/ClusterMessageAuthConfig.java create mode 100644 hertzbeat-remoting/src/main/java/org/apache/hertzbeat/remoting/netty/ClusterMessageAuthMetrics.java create mode 100644 hertzbeat-remoting/src/main/java/org/apache/hertzbeat/remoting/netty/ClusterMessageAuthenticator.java create mode 100644 hertzbeat-remoting/src/test/java/org/apache/hertzbeat/remoting/netty/ClusterMessageAuthenticatorTest.java create mode 100644 hertzbeat-remoting/src/test/java/org/apache/hertzbeat/remoting/netty/NettyRemotingAuthenticationTest.java create mode 100644 hertzbeat-remoting/src/test/java/org/apache/hertzbeat/remoting/netty/NettyRemotingCompatibilityTest.java diff --git a/.gitignore b/.gitignore index b3611b7620b..69c79f0af52 100644 --- a/.gitignore +++ b/.gitignore @@ -57,6 +57,10 @@ application-pg.yml /package.json /yarn.lock +# Local deployment secrets +/.env +/script/docker-compose/*/.env + # Local Codex workspace files .codex/ AGENTS.md diff --git a/README.md b/README.md index a9de0f9a607..4d057898b92 100644 --- a/README.md +++ b/README.md @@ -59,10 +59,16 @@ ##### 1:Install quickly via docker -1. Just one command to get started +1. Create and preserve two independent install-specific secrets, then start + HertzBeat. `COMMON_SECRET` is a 32-byte AES key shared by Manager and every + standalone Collector. `CLUSTER_AUTH_ACTIVE_SECRET` is a separate message + authentication secret. Do not commit `.env` or reuse one value for both. ```shell - docker run -d -p 1157:1157 -p 1158:1158 --name hertzbeat apache/hertzbeat + umask 077 + printf 'COMMON_SECRET=%s\n' "$(openssl rand -hex 16)" > .env + printf 'CLUSTER_AUTH_ACTIVE_SECRET=%s\n' "$(openssl rand -hex 32)" >> .env + docker run -d --env-file .env -p 1157:1157 -p 1158:1158 --name hertzbeat apache/hertzbeat ``` 2. Access `http://localhost:1157` to start, default account: `admin/hertzbeat` @@ -70,7 +76,7 @@ 3. Deploy collector clusters (Optional) ```shell - docker run -d -e IDENTITY=custom-collector-name -e MANAGER_HOST=127.0.0.1 -e MANAGER_PORT=1158 --name hertzbeat-collector apache/hertzbeat-collector + docker run -d --env-file .env -e IDENTITY=custom-collector-name -e MANAGER_HOST=127.0.0.1 -e MANAGER_PORT=1158 --name hertzbeat-collector apache/hertzbeat-collector ``` - `-e IDENTITY=custom-collector-name` : set the collector unique identity name. @@ -84,10 +90,14 @@ Detailed config refer to [Install HertzBeat via Docker](https://hertzbeat.apache ##### 2:Install via package 1. Download the release package `apache-hertzbeat-xx-bin.tar.gz` [Download](https://hertzbeat.apache.org/docs/download) -2. Configure the HertzBeat configuration yml file `hertzbeat/config/application.yml` (optional) -3. Run command `$ ./bin/startup.sh ` or `bin/startup.bat` -4. Access `http://localhost:1157` to start, default account: `admin/hertzbeat` -5. Deploy collector clusters (Optional) +2. Generate two independent secrets with the commands above. Configure the + same 16/24/32-byte `COMMON_SECRET` on Manager and every standalone + Collector, and separately configure the same + `CLUSTER_AUTH_ACTIVE_SECRET` on both sides. Preserve both across upgrades. +3. Configure the HertzBeat configuration yml file `hertzbeat/config/application.yml` (optional) +4. Run command `$ ./bin/startup.sh ` or `bin/startup.bat` +5. Access `http://localhost:1157` to start, default account: `admin/hertzbeat` +6. Deploy collector clusters (Optional) - Download the release package `apache-hertzbeat-collector-xx-bin.tar.gz` (JVM collector) or the native collector package for your platform, such as `apache-hertzbeat-collector-native-xx-linux-amd64-bin.tar.gz` or `apache-hertzbeat-collector-native-xx-windows-amd64-bin.zip`, to the new machine [Download](https://hertzbeat.apache.org/docs/download) - Configure the collector configuration yml file `hertzbeat-collector/config/application.yml`: unique `identity` name, running `mode` (public or private), hertzbeat `manager-host`, hertzbeat `manager-port` ```yaml diff --git a/README_CN.md b/README_CN.md index 7add763ad53..6c533047992 100644 --- a/README_CN.md +++ b/README_CN.md @@ -58,10 +58,16 @@ ##### 方式一:Docker 方式快速安装 -1. `docker` 环境仅需一条命令即可开始 +1. 创建并妥善保存两个相互独立的安装密钥。`COMMON_SECRET` 是 Manager 与所有 + 独立 Collector 必须完全相同的 32 字节 AES 密钥; + `CLUSTER_AUTH_ACTIVE_SECRET` 是另一份消息认证密钥。不要复用两个值,也不要 + 提交 `.env`。 ```shell - docker run -d -p 1157:1157 -p 1158:1158 --name hertzbeat apache/hertzbeat + umask 077 + printf 'COMMON_SECRET=%s\n' "$(openssl rand -hex 16)" > .env + printf 'CLUSTER_AUTH_ACTIVE_SECRET=%s\n' "$(openssl rand -hex 32)" >> .env + docker run -d --env-file .env -p 1157:1157 -p 1158:1158 --name hertzbeat apache/hertzbeat ``` 2. 浏览器访问 `http://localhost:1157` 即可开始,默认账号密码 `admin/hertzbeat` @@ -69,7 +75,7 @@ 3. 部署采集器集群(可选) ```shell - docker run -d -e IDENTITY=custom-collector-name -e MANAGER_HOST=127.0.0.1 -e MANAGER_PORT=1158 --name hertzbeat-collector apache/hertzbeat-collector + docker run -d --env-file .env -e IDENTITY=custom-collector-name -e MANAGER_HOST=127.0.0.1 -e MANAGER_PORT=1158 --name hertzbeat-collector apache/hertzbeat-collector ``` - `-e IDENTITY=custom-collector-name` : 配置此采集器的唯一性标识符名称,多个采集器名称不能相同,建议自定义英文名称。 @@ -83,10 +89,13 @@ ##### 方式二:通过安装包安装 1. 下载您系统环境对应的安装包 `apache-hertzbeat-xx-bin.tar.gz` [Download](https://hertzbeat.apache.org/docs/download) -2. 配置 HertzBeat 的配置文件 `hertzbeat/config/application.yml` (可选) -3. 部署启动 `$ ./bin/startup.sh ` 或 `bin/startup.bat` -4. 浏览器访问 `http://localhost:1157` 即可开始,默认账号密码 `admin/hertzbeat` -5. 部署采集器集群(可选) +2. 按上面的命令生成两份独立密钥。在 Manager 和所有独立 Collector 上配置 + 完全相同且长度为 16/24/32 字节的 `COMMON_SECRET`,并另外配置相同的 + `CLUSTER_AUTH_ACTIVE_SECRET`;升级时必须保留两者。 +3. 配置 HertzBeat 的配置文件 `hertzbeat/config/application.yml` (可选) +4. 部署启动 `$ ./bin/startup.sh ` 或 `bin/startup.bat` +5. 浏览器访问 `http://localhost:1157` 即可开始,默认账号密码 `admin/hertzbeat` +6. 部署采集器集群(可选) - 下载采集器安装包 `apache-hertzbeat-collector-xx-bin.tar.gz`(JVM 采集器)或与你目标平台匹配的 Native 采集器安装包,例如 `apache-hertzbeat-collector-native-xx-linux-amd64-bin.tar.gz`、`apache-hertzbeat-collector-native-xx-windows-amd64-bin.zip`,到规划的另一台部署主机上 [Download](https://hertzbeat.apache.org/docs/download) - 配置采集器的配置文件 `hertzbeat-collector/config/application.yml` 里面的连接主 HertzBeat 服务的对外 IP,端口,当前采集器名称(需保证唯一性)等参数 `identity` `mode` (public or private) `manager-host` `manager-port` ```yaml diff --git a/README_JP.md b/README_JP.md index 38143487b59..9ead318bd82 100644 --- a/README_JP.md +++ b/README_JP.md @@ -57,10 +57,16 @@ ##### 方式1:Docker -1. `docker` で以下の指令を実行します: +1. インストール専用の独立した 2 つのシークレットを作成します。 + `COMMON_SECRET` は Manager とすべてのスタンドアロン Collector で同一にする + 32 バイトの AES キーです。`CLUSTER_AUTH_ACTIVE_SECRET` は別のメッセージ認証 + シークレットです。2 つの値を再利用せず、`.env` をコミットしないでください。 ```shell - docker run -d -p 1157:1157 -p 1158:1158 --name hertzbeat apache/hertzbeat + umask 077 + printf 'COMMON_SECRET=%s\n' "$(openssl rand -hex 16)" > .env + printf 'CLUSTER_AUTH_ACTIVE_SECRET=%s\n' "$(openssl rand -hex 32)" >> .env + docker run -d --env-file .env -p 1157:1157 -p 1158:1158 --name hertzbeat apache/hertzbeat ``` 2. スタート:`http://localhost:1157`にアクセスします。デフォルトのアカウントとパスワード:`admin/hertzbeat`。 @@ -68,7 +74,7 @@ 3. コレクタークラスタのデプロイメント(オプション) ```shell - docker run -d -e IDENTITY=custom-collector-name -e MANAGER_HOST=127.0.0.1 -e MANAGER_PORT=1158 --name hertzbeat-collector apache/hertzbeat-collector + docker run -d --env-file .env -e IDENTITY=custom-collector-name -e MANAGER_HOST=127.0.0.1 -e MANAGER_PORT=1158 --name hertzbeat-collector apache/hertzbeat-collector ``` - `-e IDENTITY=custom-collector-name` :コレクターのユニーク ID。 @@ -82,10 +88,14 @@ ##### 方式2:インストールパッケージ 1. リリースパッケージ `apache-hertzbeat-xx-bin.tar.gz` をダウンロードします [Download](https://hertzbeat.apache.org/docs/download) -2. HertzBeat の設定ファイル `hertzbeat/config/application.yml` を編集します(任意) -3. コマンド `$ ./bin/startup.sh` または `bin/startup.bat` を実行します -4. ブラウザで `http://localhost:1157` にアクセスします。デフォルトのアカウントとパスワードは `admin/hertzbeat` です -5. コレクタークラスタのデプロイメント(オプション) +2. 上記のコマンドで 2 つの独立したシークレットを生成します。Manager とすべての + スタンドアロン Collector に同一で 16/24/32 バイトの `COMMON_SECRET` を設定し、 + さらに同一の `CLUSTER_AUTH_ACTIVE_SECRET` を設定します。アップグレード時も + 両方を保持してください。 +3. HertzBeat の設定ファイル `hertzbeat/config/application.yml` を編集します(任意) +4. コマンド `$ ./bin/startup.sh` または `bin/startup.bat` を実行します +5. ブラウザで `http://localhost:1157` にアクセスします。デフォルトのアカウントとパスワードは `admin/hertzbeat` です +6. コレクタークラスタのデプロイメント(オプション) - 別ホストにコレクターのインストールパッケージ `apache-hertzbeat-collector-xx-bin.tar.gz`(JVM コレクター)または対象プラットフォーム向けの Native コレクターパッケージ(例: `apache-hertzbeat-collector-native-xx-linux-amd64-bin.tar.gz`、`apache-hertzbeat-collector-native-xx-windows-amd64-bin.zip`)をダウンロードします [Download](https://hertzbeat.apache.org/docs/download) - コレクターの設定ファイル `hertzbeat-collector/config/application.yml` を編集します ```yaml diff --git a/hertzbeat-collector/hertzbeat-collector-basic/src/test/java/org/apache/hertzbeat/collector/dispatch/entrance/CollectServerTest.java b/hertzbeat-collector/hertzbeat-collector-basic/src/test/java/org/apache/hertzbeat/collector/dispatch/entrance/CollectServerTest.java index 03a6977da63..596ae6168f2 100644 --- a/hertzbeat-collector/hertzbeat-collector-basic/src/test/java/org/apache/hertzbeat/collector/dispatch/entrance/CollectServerTest.java +++ b/hertzbeat-collector/hertzbeat-collector-basic/src/test/java/org/apache/hertzbeat/collector/dispatch/entrance/CollectServerTest.java @@ -20,6 +20,7 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; @@ -39,7 +40,10 @@ import org.apache.hertzbeat.common.concurrent.BackgroundTaskExecutor; import org.apache.hertzbeat.common.config.VirtualThreadProperties; import org.apache.hertzbeat.common.entity.message.ClusterMsg; +import org.apache.hertzbeat.common.util.AesUtil; import org.apache.hertzbeat.remoting.RemotingClient; +import org.apache.hertzbeat.remoting.netty.ClusterMessageAuthConfig; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -79,15 +83,22 @@ class CollectServerTest { private CollectServer.CollectNettyEventListener collectNettyEventListener; + @AfterEach + void tearDown() { + AesUtil.setDefaultSecretKey(AesUtil.DEFAULT_ENCODE_RULES); + } + @BeforeEach void setUp() { when(nettyProperties.getManagerHost()).thenReturn("127.0.0.1"); when(nettyProperties.getManagerPort()).thenReturn(8080); + when(nettyProperties.getAuthentication()).thenReturn(new ClusterMessageAuthConfig()); when(entranceProperties.getNetty()).thenReturn(nettyProperties); when(properties.getEntrance()).thenReturn(entranceProperties); collectServer = new CollectServer(collectJobService, timerDispatch, properties, threadPool, infoProperties); + ReflectionTestUtils.setField(collectServer, "commonSecret", "local-key-123456"); collectNettyEventListener = collectServer.new CollectNettyEventListener(); } @@ -100,6 +111,31 @@ void testRun() throws Exception { collectServer.run(); verify(remotingClient, times(1)).start(); + assertEquals("local-key-123456", AesUtil.getDefaultSecretKey()); + } + + @Test + void testRunRejectsMissingCommonSecret() { + RemotingClient remotingClient = mock(RemotingClient.class); + ReflectionTestUtils.setField(collectServer, "remotingClient", remotingClient); + ReflectionTestUtils.setField(collectServer, "commonSecret", " "); + + IllegalStateException exception = assertThrows(IllegalStateException.class, collectServer::run); + + assertTrue(exception.getMessage().contains("COMMON_SECRET")); + verify(remotingClient, times(0)).start(); + } + + @Test + void testRunRejectsInvalidCommonSecretLength() { + RemotingClient remotingClient = mock(RemotingClient.class); + ReflectionTestUtils.setField(collectServer, "remotingClient", remotingClient); + ReflectionTestUtils.setField(collectServer, "commonSecret", "too-short"); + + IllegalStateException exception = assertThrows(IllegalStateException.class, collectServer::run); + + assertTrue(exception.getMessage().contains("16, 24, or 32")); + verify(remotingClient, times(0)).start(); } @Test diff --git a/hertzbeat-collector/hertzbeat-collector-collector/src/main/java/org/apache/hertzbeat/collector/nativex/CollectorRuntimeHintsRegistrar.java b/hertzbeat-collector/hertzbeat-collector-collector/src/main/java/org/apache/hertzbeat/collector/nativex/CollectorRuntimeHintsRegistrar.java index 3464caa1b1b..7b6f75ca64b 100644 --- a/hertzbeat-collector/hertzbeat-collector-collector/src/main/java/org/apache/hertzbeat/collector/nativex/CollectorRuntimeHintsRegistrar.java +++ b/hertzbeat-collector/hertzbeat-collector-collector/src/main/java/org/apache/hertzbeat/collector/nativex/CollectorRuntimeHintsRegistrar.java @@ -32,7 +32,6 @@ import org.apache.arrow.vector.types.pojo.Field; import org.apache.arrow.vector.types.pojo.FieldType; import org.apache.arrow.vector.types.pojo.Schema; -import org.apache.hertzbeat.common.entity.dto.ServerInfo; import org.springframework.aot.hint.BindingReflectionHintsRegistrar; import org.springframework.aot.hint.MemberCategory; import org.springframework.aot.hint.RuntimeHints; @@ -55,7 +54,6 @@ public class CollectorRuntimeHintsRegistrar implements RuntimeHintsRegistrar { @Override public void registerHints(@NonNull RuntimeHints hints, ClassLoader classLoader) { BindingReflectionHintsRegistrar bindingRegistrar = new BindingReflectionHintsRegistrar(); - registerType(bindingRegistrar, hints, ServerInfo.class); scanBindingPackage(classLoader, bindingRegistrar, hints, JOB_PACKAGE); scanBindingPackage(classLoader, bindingRegistrar, hints, JOB_PROTOCOL_PACKAGE); hints.reflection().registerType(NettyAllocationManager.class, MemberCategory.DECLARED_FIELDS); diff --git a/hertzbeat-collector/hertzbeat-collector-collector/src/main/resources/application.yml b/hertzbeat-collector/hertzbeat-collector-collector/src/main/resources/application.yml index 1fb329d7d01..c7eff12b2c2 100644 --- a/hertzbeat-collector/hertzbeat-collector-collector/src/main/resources/application.yml +++ b/hertzbeat-collector/hertzbeat-collector-collector/src/main/resources/application.yml @@ -64,12 +64,24 @@ collector: mode: ${MODE:public} manager-host: ${MANAGER_HOST:}${MANAGER_IP:} manager-port: ${MANAGER_PORT:1158} + authentication: + # Required by default. Set optional explicitly only during a mixed-version rolling upgrade. + mode: ${CLUSTER_AUTH_MODE:required} + active-key-id: ${CLUSTER_AUTH_ACTIVE_KEY_ID:primary} + # Leave empty to derive a domain-separated key from common.secret. + active-secret: ${CLUSTER_AUTH_ACTIVE_SECRET:} + previous-key-id: ${CLUSTER_AUTH_PREVIOUS_KEY_ID:} + previous-secret: ${CLUSTER_AUTH_PREVIOUS_SECRET:} + max-clock-skew: ${CLUSTER_AUTH_MAX_CLOCK_SKEW:5m} + handshake-timeout: ${CLUSTER_AUTH_HANDSHAKE_TIMEOUT:3s} push: uri: "127.0.0.1:1157" common: + # Use the same key as Manager. Inject it through deployment configuration, never over Netty. + secret: ${COMMON_SECRET:} queue: type: netty diff --git a/hertzbeat-collector/hertzbeat-collector-collector/src/test/java/org/apache/hertzbeat/collector/CollectorDocumentedEnvironmentIntegrationTest.java b/hertzbeat-collector/hertzbeat-collector-collector/src/test/java/org/apache/hertzbeat/collector/CollectorDocumentedEnvironmentIntegrationTest.java new file mode 100644 index 00000000000..09cd097f05b --- /dev/null +++ b/hertzbeat-collector/hertzbeat-collector-collector/src/test/java/org/apache/hertzbeat/collector/CollectorDocumentedEnvironmentIntegrationTest.java @@ -0,0 +1,136 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hertzbeat.collector; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +import java.io.BufferedReader; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.time.Duration; +import java.time.Instant; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; + +class CollectorDocumentedEnvironmentIntegrationTest { + + private static final String DOCUMENTED_COMMON_SECRET = + "0123456789abcdef0123456789abcdef"; + + private static final String DOCUMENTED_CLUSTER_AUTH_SECRET = + "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"; + + @Test + void documentedIndependentSecretsStartStandaloneCollectorProcess() { + org.junit.jupiter.api.Assertions.assertTimeoutPreemptively( + Duration.ofSeconds(120), + this::startCollectorAndVerifyStableProcess); + } + + private void startCollectorAndVerifyStableProcess() throws Exception { + assertEquals(32, DOCUMENTED_COMMON_SECRET.getBytes(StandardCharsets.UTF_8).length); + assertTrue(DOCUMENTED_CLUSTER_AUTH_SECRET.getBytes(StandardCharsets.UTF_8).length >= 32); + assertFalse(DOCUMENTED_COMMON_SECRET.equals(DOCUMENTED_CLUSTER_AUTH_SECRET)); + + String testClasspath = System.getProperty( + "surefire.test.class.path", + System.getProperty("java.class.path")); + Path java = Path.of(System.getProperty("java.home"), "bin", "java"); + ProcessBuilder processBuilder = new ProcessBuilder( + java.toString(), + "-cp", + testClasspath, + Collector.class.getName(), + "--spring.main.banner-mode=off"); + processBuilder.directory(Path.of("target").toAbsolutePath().toFile()); + processBuilder.redirectErrorStream(true); + Map environment = processBuilder.environment(); + environment.put("COMMON_SECRET", DOCUMENTED_COMMON_SECRET); + environment.put("CLUSTER_AUTH_ACTIVE_SECRET", DOCUMENTED_CLUSTER_AUTH_SECRET); + environment.put("IDENTITY", "documented-environment-test"); + environment.put("MANAGER_HOST", "127.0.0.1"); + environment.put("MANAGER_PORT", "65534"); + environment.put("SERVER_PORT", "0"); + + Process process = processBuilder.start(); + StringBuilder output = new StringBuilder(); + Thread outputReader = Thread.ofVirtual().start(() -> readOutput(process, output)); + try { + waitForStableStartup(process, output); + } finally { + process.destroy(); + if (!process.waitFor(10, TimeUnit.SECONDS)) { + process.destroyForcibly(); + process.waitFor(10, TimeUnit.SECONDS); + } + outputReader.join(TimeUnit.SECONDS.toMillis(10)); + } + } + + private void waitForStableStartup(Process process, StringBuilder output) + throws InterruptedException { + Instant deadline = Instant.now().plusSeconds(90); + while (Instant.now().isBefore(deadline)) { + String currentOutput = snapshot(output); + if (!process.isAlive()) { + fail("Collector exited before stable startup:\n" + currentOutput); + } + if (currentOutput.contains("Started Collector")) { + Thread.sleep(TimeUnit.SECONDS.toMillis(5)); + String stableOutput = snapshot(output); + assertTrue(process.isAlive(), "Collector exited after startup:\n" + stableOutput); + assertFalse( + stableOutput.contains("A standalone Collector must configure"), + stableOutput); + assertFalse(stableOutput.contains("common.secret must be"), stableOutput); + return; + } + Thread.sleep(200); + } + fail("Collector did not start before the deadline:\n" + snapshot(output)); + } + + private void readOutput(Process process, StringBuilder output) { + try (BufferedReader reader = + process.inputReader(StandardCharsets.UTF_8)) { + String line; + while ((line = reader.readLine()) != null) { + synchronized (output) { + output.append(line).append(System.lineSeparator()); + } + } + } catch (IOException exception) { + synchronized (output) { + output.append("Unable to read Collector output: ") + .append(exception.getMessage()) + .append(System.lineSeparator()); + } + } + } + + private String snapshot(StringBuilder output) { + synchronized (output) { + return output.toString(); + } + } +} diff --git a/hertzbeat-collector/hertzbeat-collector-common/src/main/java/org/apache/hertzbeat/collector/dispatch/DispatchProperties.java b/hertzbeat-collector/hertzbeat-collector-common/src/main/java/org/apache/hertzbeat/collector/dispatch/DispatchProperties.java index a34677f7b41..ca217f21df5 100644 --- a/hertzbeat-collector/hertzbeat-collector-common/src/main/java/org/apache/hertzbeat/collector/dispatch/DispatchProperties.java +++ b/hertzbeat-collector/hertzbeat-collector-common/src/main/java/org/apache/hertzbeat/collector/dispatch/DispatchProperties.java @@ -19,6 +19,7 @@ import org.apache.hertzbeat.common.constants.ConfigConstants; import org.apache.hertzbeat.common.constants.SignConstants; +import org.apache.hertzbeat.remoting.netty.ClusterMessageAuthConfig; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; @@ -109,6 +110,11 @@ public static class NettyProperties { * connect cluster master port */ private int managerPort = 1158; + + /** + * Versioned message authentication and rolling-upgrade settings. + */ + private ClusterMessageAuthConfig authentication = new ClusterMessageAuthConfig(); public boolean isEnabled() { return enabled; @@ -149,6 +155,14 @@ public int getManagerPort() { public void setManagerPort(int managerPort) { this.managerPort = managerPort; } + + public ClusterMessageAuthConfig getAuthentication() { + return authentication; + } + + public void setAuthentication(ClusterMessageAuthConfig authentication) { + this.authentication = authentication; + } } } diff --git a/hertzbeat-collector/hertzbeat-collector-common/src/main/java/org/apache/hertzbeat/collector/dispatch/entrance/CollectServer.java b/hertzbeat-collector/hertzbeat-collector-common/src/main/java/org/apache/hertzbeat/collector/dispatch/entrance/CollectServer.java index a5bf494750f..3936e8dab8d 100644 --- a/hertzbeat-collector/hertzbeat-collector-common/src/main/java/org/apache/hertzbeat/collector/dispatch/entrance/CollectServer.java +++ b/hertzbeat-collector/hertzbeat-collector-common/src/main/java/org/apache/hertzbeat/collector/dispatch/entrance/CollectServer.java @@ -20,7 +20,15 @@ import com.google.common.util.concurrent.ThreadFactoryBuilder; import com.google.protobuf.ByteString; import io.netty.channel.Channel; +import java.nio.charset.StandardCharsets; +import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; import org.apache.hertzbeat.collector.dispatch.CollectorInfoProperties; import org.apache.hertzbeat.collector.dispatch.DispatchProperties; import org.apache.hertzbeat.collector.dispatch.entrance.internal.CollectJobService; @@ -36,25 +44,21 @@ import org.apache.hertzbeat.common.config.VirtualThreadProperties; import org.apache.hertzbeat.common.entity.dto.CollectorInfo; import org.apache.hertzbeat.common.entity.message.ClusterMsg; +import org.apache.hertzbeat.common.util.AesUtil; import org.apache.hertzbeat.common.util.JsonUtil; import org.apache.hertzbeat.remoting.RemotingClient; import org.apache.hertzbeat.remoting.event.NettyEventListener; import org.apache.hertzbeat.remoting.netty.NettyClientConfig; import org.apache.hertzbeat.remoting.netty.NettyRemotingClient; -import org.springframework.context.ConfigurableApplicationContext; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.SpringApplication; +import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.CommandLineRunner; +import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.ConfigurableApplicationContext; import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.ThreadFactory; -import java.util.concurrent.TimeUnit; - /** * collect server */ @@ -65,6 +69,8 @@ @Slf4j public class CollectServer implements CommandLineRunner { + private static final Set AES_KEY_LENGTHS = Set.of(16, 24, 32); + private final CollectJobService collectJobService; private final TimerDispatch timerDispatch; @@ -85,6 +91,9 @@ public class CollectServer implements CommandLineRunner { private final Runnable closeApplicationAction; + @Value("${common.secret:}") + private String commonSecret; + public CollectServer(final CollectJobService collectJobService, final TimerDispatch timerDispatch, final DispatchProperties properties, @@ -133,7 +142,12 @@ private void init(final DispatchProperties properties, final BackgroundTaskExecu DispatchProperties.EntranceProperties.NettyProperties nettyProperties = properties.getEntrance().getNetty(); nettyClientConfig.setServerHost(nettyProperties.getManagerHost()); nettyClientConfig.setServerPort(nettyProperties.getManagerPort()); - this.remotingClient = new NettyRemotingClient(nettyClientConfig, new CollectNettyEventListener(), threadPool); + this.remotingClient = new NettyRemotingClient( + nettyClientConfig, + new CollectNettyEventListener(), + threadPool, + nettyProperties.getAuthentication(), + AesUtil::getDefaultSecretKey); this.remotingClient.registerProcessor(ClusterMsg.MessageType.HEARTBEAT, new HeartbeatProcessor()); this.remotingClient.registerProcessor(ClusterMsg.MessageType.ISSUE_CYCLIC_TASK, new CollectCyclicDataProcessor(this)); @@ -181,9 +195,23 @@ void dispatchHeartbeat(String identity) { @Override public void run(String... args) throws Exception { + initializeAesSecret(); this.remotingClient.start(); } + private void initializeAesSecret() { + if (StringUtils.isBlank(commonSecret)) { + throw new IllegalStateException( + "A standalone Collector must configure the same AES key as Manager via common.secret " + + "or COMMON_SECRET"); + } + int secretLength = commonSecret.getBytes(StandardCharsets.UTF_8).length; + if (!AES_KEY_LENGTHS.contains(secretLength)) { + throw new IllegalStateException("common.secret must be 16, 24, or 32 bytes in UTF-8"); + } + AesUtil.setDefaultSecretKey(commonSecret); + } + /** * CollectNettyEventListener */ diff --git a/hertzbeat-collector/hertzbeat-collector-common/src/main/java/org/apache/hertzbeat/collector/dispatch/entrance/processor/GoOnlineProcessor.java b/hertzbeat-collector/hertzbeat-collector-common/src/main/java/org/apache/hertzbeat/collector/dispatch/entrance/processor/GoOnlineProcessor.java index e35c66a151b..af7294cd2a6 100644 --- a/hertzbeat-collector/hertzbeat-collector-common/src/main/java/org/apache/hertzbeat/collector/dispatch/entrance/processor/GoOnlineProcessor.java +++ b/hertzbeat-collector/hertzbeat-collector-common/src/main/java/org/apache/hertzbeat/collector/dispatch/entrance/processor/GoOnlineProcessor.java @@ -22,10 +22,7 @@ import lombok.extern.slf4j.Slf4j; import org.apache.hertzbeat.collector.timer.TimerDispatch; import org.apache.hertzbeat.common.constants.CommonConstants; -import org.apache.hertzbeat.common.entity.dto.ServerInfo; import org.apache.hertzbeat.common.entity.message.ClusterMsg; -import org.apache.hertzbeat.common.util.AesUtil; -import org.apache.hertzbeat.common.util.JsonUtil; import org.apache.hertzbeat.remoting.netty.NettyRemotingProcessor; /** @@ -43,16 +40,7 @@ public GoOnlineProcessor(TimerDispatch timerDispatch) { @Override public ClusterMsg.Message handle(ChannelHandlerContext ctx, ClusterMsg.Message message) { - if (message.getMsg().isEmpty()) { - log.warn("The message that server response to collector is empty, please upgrade server"); - } else { - ServerInfo serverInfo = JsonUtil.fromJson(message.getMsg().toStringUtf8(), ServerInfo.class); - if (serverInfo == null || serverInfo.getAesSecret() == null) { - log.warn("The message that server response to collector has not secret empty, please check"); - } else { - AesUtil.setDefaultSecretKey(serverInfo.getAesSecret()); - } - } + // Ignore the response payload so an unauthenticated Manager response cannot override the local AES key. if (ClusterMsg.Direction.REQUEST.equals(message.getDirection())) { timerDispatch.goOnline(); } diff --git a/hertzbeat-collector/hertzbeat-collector-common/src/test/java/org/apache/hertzbeat/collector/dispatch/entrance/processor/GoOnlineProcessorTest.java b/hertzbeat-collector/hertzbeat-collector-common/src/test/java/org/apache/hertzbeat/collector/dispatch/entrance/processor/GoOnlineProcessorTest.java index b544c8c39bd..69fb3914ea6 100644 --- a/hertzbeat-collector/hertzbeat-collector-common/src/test/java/org/apache/hertzbeat/collector/dispatch/entrance/processor/GoOnlineProcessorTest.java +++ b/hertzbeat-collector/hertzbeat-collector-common/src/test/java/org/apache/hertzbeat/collector/dispatch/entrance/processor/GoOnlineProcessorTest.java @@ -19,17 +19,15 @@ import com.google.common.collect.Lists; import com.google.protobuf.ByteString; -import io.netty.channel.ChannelHandlerContext; import org.apache.hertzbeat.collector.timer.TimerDispatcher; import org.apache.hertzbeat.common.entity.job.Job; import org.apache.hertzbeat.common.entity.job.Metrics; import org.apache.hertzbeat.common.entity.message.ClusterMsg; +import org.apache.hertzbeat.common.util.AesUtil; import org.apache.hertzbeat.common.util.JsonUtil; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.mockito.Mock; -import org.mockito.MockitoAnnotations; import java.lang.reflect.Field; import java.util.Map; @@ -44,12 +42,8 @@ class GoOnlineProcessorTest { private GoOnlineProcessor goOnlineProcessor; private TimerDispatcher timerDispatcher; - @Mock - private ChannelHandlerContext channelHandlerContext; - @BeforeEach void setUp() { - MockitoAnnotations.openMocks(this); timerDispatcher = new TimerDispatcher(); goOnlineProcessor = new GoOnlineProcessor(timerDispatcher); } @@ -81,7 +75,7 @@ void verifyTaskMapPreservation() throws Exception { .setMsg(ByteString.copyFromUtf8(JsonUtil.toJson(job))) .setIdentity("test-identity") .build(); - goOnlineProcessor.handle(channelHandlerContext, responseMsg); + goOnlineProcessor.handle(null, responseMsg); assertEquals(1, currentCyclicTaskMap.size(), "Task map should still have 1 job after receiving RESPONSE"); ClusterMsg.Message requestMsg = ClusterMsg.Message.newBuilder() @@ -90,7 +84,26 @@ void verifyTaskMapPreservation() throws Exception { .setMsg(ByteString.copyFromUtf8(JsonUtil.toJson(job))) .setIdentity("test-identity") .build(); - goOnlineProcessor.handle(channelHandlerContext, requestMsg); + goOnlineProcessor.handle(null, requestMsg); assertEquals(0, currentCyclicTaskMap.size(), "Task map should be empty after receiving REQUEST"); } + + @Test + void shouldIgnoreAesSecretFromNetworkResponse() { + String localSecret = "local-key-123456"; + AesUtil.setDefaultSecretKey(localSecret); + try { + ClusterMsg.Message response = ClusterMsg.Message.newBuilder() + .setType(ClusterMsg.MessageType.GO_ONLINE) + .setDirection(ClusterMsg.Direction.RESPONSE) + .setMsg(ByteString.copyFromUtf8("{\"aesSecret\":\"network-key-1234\"}")) + .build(); + + goOnlineProcessor.handle(null, response); + + assertEquals(localSecret, AesUtil.getDefaultSecretKey()); + } finally { + AesUtil.setDefaultSecretKey(AesUtil.DEFAULT_ENCODE_RULES); + } + } } diff --git a/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/entity/dto/ServerInfo.java b/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/entity/dto/ServerInfo.java deleted file mode 100644 index 4b3fe8a4fc2..00000000000 --- a/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/entity/dto/ServerInfo.java +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.apache.hertzbeat.common.entity.dto; - -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -/** - * collector info - */ -@Data -@Builder -@AllArgsConstructor -@NoArgsConstructor -@Schema(description = "manager server info") -public class ServerInfo { - - /** - * the aes secret key value for aes encode decode - */ - private String aesSecret; -} diff --git a/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/entity/message/ClusterMsg.java b/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/entity/message/ClusterMsg.java index 18181be994c..a1adbbccfca 100644 --- a/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/entity/message/ClusterMsg.java +++ b/hertzbeat-common-core/src/main/java/org/apache/hertzbeat/common/entity/message/ClusterMsg.java @@ -1,20 +1,18 @@ /* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * http://www.apache.org/licenses/LICENSE-2.0 * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. */ // Generated by the protocol buffer compiler. DO NOT EDIT! @@ -73,7 +71,7 @@ public enum MessageType GO_CLOSE(3), /** *
-     * issue cyclic collect task 
+     * issue cyclic collect task
      * 
* * ISSUE_CYCLIC_TASK = 4; @@ -119,6 +117,14 @@ public enum MessageType * RESPONSE_CYCLIC_TASK_SD_DATA = 9; */ RESPONSE_CYCLIC_TASK_SD_DATA(9), + /** + *
+     * internal authentication capability and channel-binding challenge
+     * 
+ * + * AUTH_CHALLENGE = 10; + */ + AUTH_CHALLENGE(10), UNRECOGNIZED(-1), ; @@ -156,7 +162,7 @@ public enum MessageType public static final int GO_CLOSE_VALUE = 3; /** *
-     * issue cyclic collect task 
+     * issue cyclic collect task
      * 
* * ISSUE_CYCLIC_TASK = 4; @@ -202,11 +208,19 @@ public enum MessageType * RESPONSE_CYCLIC_TASK_SD_DATA = 9; */ public static final int RESPONSE_CYCLIC_TASK_SD_DATA_VALUE = 9; + /** + *
+     * internal authentication capability and channel-binding challenge
+     * 
+ * + * AUTH_CHALLENGE = 10; + */ + public static final int AUTH_CHALLENGE_VALUE = 10; public final int getNumber() { if (this == UNRECOGNIZED) { - throw new IllegalArgumentException( + throw new java.lang.IllegalArgumentException( "Can't get the number of an unknown enum value."); } return value; @@ -217,7 +231,7 @@ public final int getNumber() { * @return The enum associated with the given numeric wire value. * @deprecated Use {@link #forNumber(int)} instead. */ - @Deprecated + @java.lang.Deprecated public static MessageType valueOf(int value) { return forNumber(value); } @@ -238,6 +252,7 @@ public static MessageType forNumber(int value) { case 7: return RESPONSE_ONE_TIME_TASK_DATA; case 8: return RESPONSE_CYCLIC_TASK_DATA; case 9: return RESPONSE_CYCLIC_TASK_SD_DATA; + case 10: return AUTH_CHALLENGE; default: return null; } } @@ -257,7 +272,7 @@ public MessageType findValueByNumber(int number) { public final com.google.protobuf.Descriptors.EnumValueDescriptor getValueDescriptor() { if (this == UNRECOGNIZED) { - throw new IllegalStateException( + throw new java.lang.IllegalStateException( "Can't get the descriptor of an unrecognized enum value."); } return getDescriptor().getValues().get(ordinal()); @@ -268,7 +283,7 @@ public MessageType findValueByNumber(int number) { } public static final com.google.protobuf.Descriptors.EnumDescriptor getDescriptor() { - return ClusterMsg.getDescriptor().getEnumTypes().get(0); + return org.apache.hertzbeat.common.entity.message.ClusterMsg.getDescriptor().getEnumTypes().get(0); } private static final MessageType[] VALUES = values(); @@ -276,7 +291,7 @@ public MessageType findValueByNumber(int number) { public static MessageType valueOf( com.google.protobuf.Descriptors.EnumValueDescriptor desc) { if (desc.getType() != getDescriptor()) { - throw new IllegalArgumentException( + throw new java.lang.IllegalArgumentException( "EnumValueDescriptor is not for this type."); } if (desc.getIndex() == -1) { @@ -338,7 +353,7 @@ public enum Direction public final int getNumber() { if (this == UNRECOGNIZED) { - throw new IllegalArgumentException( + throw new java.lang.IllegalArgumentException( "Can't get the number of an unknown enum value."); } return value; @@ -349,7 +364,7 @@ public final int getNumber() { * @return The enum associated with the given numeric wire value. * @deprecated Use {@link #forNumber(int)} instead. */ - @Deprecated + @java.lang.Deprecated public static Direction valueOf(int value) { return forNumber(value); } @@ -381,7 +396,7 @@ public Direction findValueByNumber(int number) { public final com.google.protobuf.Descriptors.EnumValueDescriptor getValueDescriptor() { if (this == UNRECOGNIZED) { - throw new IllegalStateException( + throw new java.lang.IllegalStateException( "Can't get the descriptor of an unrecognized enum value."); } return getDescriptor().getValues().get(ordinal()); @@ -392,7 +407,7 @@ public Direction findValueByNumber(int number) { } public static final com.google.protobuf.Descriptors.EnumDescriptor getDescriptor() { - return ClusterMsg.getDescriptor().getEnumTypes().get(1); + return org.apache.hertzbeat.common.entity.message.ClusterMsg.getDescriptor().getEnumTypes().get(1); } private static final Direction[] VALUES = values(); @@ -400,7 +415,7 @@ public Direction findValueByNumber(int number) { public static Direction valueOf( com.google.protobuf.Descriptors.EnumValueDescriptor desc) { if (desc.getType() != getDescriptor()) { - throw new IllegalArgumentException( + throw new java.lang.IllegalArgumentException( "EnumValueDescriptor is not for this type."); } if (desc.getIndex() == -1) { @@ -430,7 +445,7 @@ public interface MessageOrBuilder extends * string identity = 1; * @return The identity. */ - String getIdentity(); + java.lang.String getIdentity(); /** *
      * collector identity
@@ -459,7 +474,7 @@ public interface MessageOrBuilder extends
      * .org.apache.hertzbeat.common.entity.message.Direction direction = 2;
      * @return The direction.
      */
-    Direction getDirection();
+    org.apache.hertzbeat.common.entity.message.ClusterMsg.Direction getDirection();
 
     /**
      * 
@@ -478,7 +493,7 @@ public interface MessageOrBuilder extends
      * .org.apache.hertzbeat.common.entity.message.MessageType type = 3;
      * @return The type.
      */
-    MessageType getType();
+    org.apache.hertzbeat.common.entity.message.ClusterMsg.MessageType getType();
 
     /**
      * 
@@ -489,6 +504,76 @@ public interface MessageOrBuilder extends
      * @return The msg.
      */
     com.google.protobuf.ByteString getMsg();
+
+    /**
+     * 
+     * sender timestamp used to reject stale messages
+     * 
+ * + * int64 auth_timestamp = 5; + * @return The authTimestamp. + */ + long getAuthTimestamp(); + + /** + *
+     * per-message random value used to reject replay
+     * 
+ * + * bytes auth_nonce = 6; + * @return The authNonce. + */ + com.google.protobuf.ByteString getAuthNonce(); + + /** + *
+     * HMAC-SHA256 over the message with this field cleared
+     * 
+ * + * bytes auth_signature = 7; + * @return The authSignature. + */ + com.google.protobuf.ByteString getAuthSignature(); + + /** + *
+     * authentication envelope version; zero identifies a legacy unsigned message
+     * 
+ * + * uint32 auth_version = 8; + * @return The authVersion. + */ + int getAuthVersion(); + + /** + *
+     * key identifier used for non-disruptive key rotation
+     * 
+ * + * string auth_key_id = 9; + * @return The authKeyId. + */ + java.lang.String getAuthKeyId(); + /** + *
+     * key identifier used for non-disruptive key rotation
+     * 
+ * + * string auth_key_id = 9; + * @return The bytes for authKeyId. + */ + com.google.protobuf.ByteString + getAuthKeyIdBytes(); + + /** + *
+     * server-generated nonce binding a signed message to one Netty connection
+     * 
+ * + * bytes auth_channel_nonce = 10; + * @return The authChannelNonce. + */ + com.google.protobuf.ByteString getAuthChannelNonce(); } /** * Protobuf type {@code org.apache.hertzbeat.common.entity.message.Message} @@ -507,16 +592,20 @@ private Message() { direction_ = 0; type_ = 0; msg_ = com.google.protobuf.ByteString.EMPTY; + authNonce_ = com.google.protobuf.ByteString.EMPTY; + authSignature_ = com.google.protobuf.ByteString.EMPTY; + authKeyId_ = ""; + authChannelNonce_ = com.google.protobuf.ByteString.EMPTY; } - @Override + @java.lang.Override @SuppressWarnings({"unused"}) - protected Object newInstance( + protected java.lang.Object newInstance( UnusedPrivateParameter unused) { return new Message(); } - @Override + @java.lang.Override public final com.google.protobuf.UnknownFieldSet getUnknownFields() { return this.unknownFields; @@ -527,7 +616,7 @@ private Message( throws com.google.protobuf.InvalidProtocolBufferException { this(); if (extensionRegistry == null) { - throw new NullPointerException(); + throw new java.lang.NullPointerException(); } com.google.protobuf.UnknownFieldSet.Builder unknownFields = com.google.protobuf.UnknownFieldSet.newBuilder(); @@ -540,7 +629,7 @@ private Message( done = true; break; case 10: { - String s = input.readStringRequireUtf8(); + java.lang.String s = input.readStringRequireUtf8(); identity_ = s; break; @@ -562,6 +651,37 @@ private Message( msg_ = input.readBytes(); break; } + case 40: { + + authTimestamp_ = input.readInt64(); + break; + } + case 50: { + + authNonce_ = input.readBytes(); + break; + } + case 58: { + + authSignature_ = input.readBytes(); + break; + } + case 64: { + + authVersion_ = input.readUInt32(); + break; + } + case 74: { + java.lang.String s = input.readStringRequireUtf8(); + + authKeyId_ = s; + break; + } + case 82: { + + authChannelNonce_ = input.readBytes(); + break; + } default: { if (!parseUnknownField( input, unknownFields, extensionRegistry, tag)) { @@ -573,8 +693,6 @@ private Message( } } catch (com.google.protobuf.InvalidProtocolBufferException e) { throw e.setUnfinishedMessage(this); - } catch (com.google.protobuf.UninitializedMessageException e) { - throw e.asInvalidProtocolBufferException().setUnfinishedMessage(this); } catch (java.io.IOException e) { throw new com.google.protobuf.InvalidProtocolBufferException( e).setUnfinishedMessage(this); @@ -585,19 +703,19 @@ private Message( } public static final com.google.protobuf.Descriptors.Descriptor getDescriptor() { - return ClusterMsg.internal_static_org_apache_hertzbeat_common_entity_message_Message_descriptor; + return org.apache.hertzbeat.common.entity.message.ClusterMsg.internal_static_org_apache_hertzbeat_common_entity_message_Message_descriptor; } - @Override - protected FieldAccessorTable + @java.lang.Override + protected com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internalGetFieldAccessorTable() { - return ClusterMsg.internal_static_org_apache_hertzbeat_common_entity_message_Message_fieldAccessorTable + return org.apache.hertzbeat.common.entity.message.ClusterMsg.internal_static_org_apache_hertzbeat_common_entity_message_Message_fieldAccessorTable .ensureFieldAccessorsInitialized( - Message.class, Builder.class); + org.apache.hertzbeat.common.entity.message.ClusterMsg.Message.class, org.apache.hertzbeat.common.entity.message.ClusterMsg.Message.Builder.class); } public static final int IDENTITY_FIELD_NUMBER = 1; - private volatile Object identity_; + private volatile java.lang.Object identity_; /** *
      * collector identity
@@ -606,15 +724,15 @@ private Message(
      * string identity = 1;
      * @return The identity.
      */
-    @Override
-    public String getIdentity() {
-      Object ref = identity_;
-      if (ref instanceof String) {
-        return (String) ref;
+    @java.lang.Override
+    public java.lang.String getIdentity() {
+      java.lang.Object ref = identity_;
+      if (ref instanceof java.lang.String) {
+        return (java.lang.String) ref;
       } else {
-        com.google.protobuf.ByteString bs = 
+        com.google.protobuf.ByteString bs =
             (com.google.protobuf.ByteString) ref;
-        String s = bs.toStringUtf8();
+        java.lang.String s = bs.toStringUtf8();
         identity_ = s;
         return s;
       }
@@ -627,14 +745,14 @@ public String getIdentity() {
      * string identity = 1;
      * @return The bytes for identity.
      */
-    @Override
+    @java.lang.Override
     public com.google.protobuf.ByteString
         getIdentityBytes() {
-      Object ref = identity_;
-      if (ref instanceof String) {
-        com.google.protobuf.ByteString b = 
+      java.lang.Object ref = identity_;
+      if (ref instanceof java.lang.String) {
+        com.google.protobuf.ByteString b =
             com.google.protobuf.ByteString.copyFromUtf8(
-                (String) ref);
+                (java.lang.String) ref);
         identity_ = b;
         return b;
       } else {
@@ -652,7 +770,7 @@ public String getIdentity() {
      * .org.apache.hertzbeat.common.entity.message.Direction direction = 2;
      * @return The enum numeric value on the wire for direction.
      */
-    @Override public int getDirectionValue() {
+    @java.lang.Override public int getDirectionValue() {
       return direction_;
     }
     /**
@@ -663,10 +781,10 @@ public String getIdentity() {
      * .org.apache.hertzbeat.common.entity.message.Direction direction = 2;
      * @return The direction.
      */
-    @Override public Direction getDirection() {
+    @java.lang.Override public org.apache.hertzbeat.common.entity.message.ClusterMsg.Direction getDirection() {
       @SuppressWarnings("deprecation")
-      Direction result = Direction.valueOf(direction_);
-      return result == null ? Direction.UNRECOGNIZED : result;
+      org.apache.hertzbeat.common.entity.message.ClusterMsg.Direction result = org.apache.hertzbeat.common.entity.message.ClusterMsg.Direction.valueOf(direction_);
+      return result == null ? org.apache.hertzbeat.common.entity.message.ClusterMsg.Direction.UNRECOGNIZED : result;
     }
 
     public static final int TYPE_FIELD_NUMBER = 3;
@@ -679,7 +797,7 @@ public String getIdentity() {
      * .org.apache.hertzbeat.common.entity.message.MessageType type = 3;
      * @return The enum numeric value on the wire for type.
      */
-    @Override public int getTypeValue() {
+    @java.lang.Override public int getTypeValue() {
       return type_;
     }
     /**
@@ -690,10 +808,10 @@ public String getIdentity() {
      * .org.apache.hertzbeat.common.entity.message.MessageType type = 3;
      * @return The type.
      */
-    @Override public MessageType getType() {
+    @java.lang.Override public org.apache.hertzbeat.common.entity.message.ClusterMsg.MessageType getType() {
       @SuppressWarnings("deprecation")
-      MessageType result = MessageType.valueOf(type_);
-      return result == null ? MessageType.UNRECOGNIZED : result;
+      org.apache.hertzbeat.common.entity.message.ClusterMsg.MessageType result = org.apache.hertzbeat.common.entity.message.ClusterMsg.MessageType.valueOf(type_);
+      return result == null ? org.apache.hertzbeat.common.entity.message.ClusterMsg.MessageType.UNRECOGNIZED : result;
     }
 
     public static final int MSG_FIELD_NUMBER = 4;
@@ -706,13 +824,134 @@ public String getIdentity() {
      * bytes msg = 4;
      * @return The msg.
      */
-    @Override
+    @java.lang.Override
     public com.google.protobuf.ByteString getMsg() {
       return msg_;
     }
 
+    public static final int AUTH_TIMESTAMP_FIELD_NUMBER = 5;
+    private long authTimestamp_;
+    /**
+     * 
+     * sender timestamp used to reject stale messages
+     * 
+ * + * int64 auth_timestamp = 5; + * @return The authTimestamp. + */ + @java.lang.Override + public long getAuthTimestamp() { + return authTimestamp_; + } + + public static final int AUTH_NONCE_FIELD_NUMBER = 6; + private com.google.protobuf.ByteString authNonce_; + /** + *
+     * per-message random value used to reject replay
+     * 
+ * + * bytes auth_nonce = 6; + * @return The authNonce. + */ + @java.lang.Override + public com.google.protobuf.ByteString getAuthNonce() { + return authNonce_; + } + + public static final int AUTH_SIGNATURE_FIELD_NUMBER = 7; + private com.google.protobuf.ByteString authSignature_; + /** + *
+     * HMAC-SHA256 over the message with this field cleared
+     * 
+ * + * bytes auth_signature = 7; + * @return The authSignature. + */ + @java.lang.Override + public com.google.protobuf.ByteString getAuthSignature() { + return authSignature_; + } + + public static final int AUTH_VERSION_FIELD_NUMBER = 8; + private int authVersion_; + /** + *
+     * authentication envelope version; zero identifies a legacy unsigned message
+     * 
+ * + * uint32 auth_version = 8; + * @return The authVersion. + */ + @java.lang.Override + public int getAuthVersion() { + return authVersion_; + } + + public static final int AUTH_KEY_ID_FIELD_NUMBER = 9; + private volatile java.lang.Object authKeyId_; + /** + *
+     * key identifier used for non-disruptive key rotation
+     * 
+ * + * string auth_key_id = 9; + * @return The authKeyId. + */ + @java.lang.Override + public java.lang.String getAuthKeyId() { + java.lang.Object ref = authKeyId_; + if (ref instanceof java.lang.String) { + return (java.lang.String) ref; + } else { + com.google.protobuf.ByteString bs = + (com.google.protobuf.ByteString) ref; + java.lang.String s = bs.toStringUtf8(); + authKeyId_ = s; + return s; + } + } + /** + *
+     * key identifier used for non-disruptive key rotation
+     * 
+ * + * string auth_key_id = 9; + * @return The bytes for authKeyId. + */ + @java.lang.Override + public com.google.protobuf.ByteString + getAuthKeyIdBytes() { + java.lang.Object ref = authKeyId_; + if (ref instanceof java.lang.String) { + com.google.protobuf.ByteString b = + com.google.protobuf.ByteString.copyFromUtf8( + (java.lang.String) ref); + authKeyId_ = b; + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + + public static final int AUTH_CHANNEL_NONCE_FIELD_NUMBER = 10; + private com.google.protobuf.ByteString authChannelNonce_; + /** + *
+     * server-generated nonce binding a signed message to one Netty connection
+     * 
+ * + * bytes auth_channel_nonce = 10; + * @return The authChannelNonce. + */ + @java.lang.Override + public com.google.protobuf.ByteString getAuthChannelNonce() { + return authChannelNonce_; + } + private byte memoizedIsInitialized = -1; - @Override + @java.lang.Override public final boolean isInitialized() { byte isInitialized = memoizedIsInitialized; if (isInitialized == 1) return true; @@ -722,38 +961,56 @@ public final boolean isInitialized() { return true; } - @Override + @java.lang.Override public void writeTo(com.google.protobuf.CodedOutputStream output) throws java.io.IOException { - if (!com.google.protobuf.GeneratedMessageV3.isStringEmpty(identity_)) { + if (!getIdentityBytes().isEmpty()) { com.google.protobuf.GeneratedMessageV3.writeString(output, 1, identity_); } - if (direction_ != Direction.REQUEST.getNumber()) { + if (direction_ != org.apache.hertzbeat.common.entity.message.ClusterMsg.Direction.REQUEST.getNumber()) { output.writeEnum(2, direction_); } - if (type_ != MessageType.HEARTBEAT.getNumber()) { + if (type_ != org.apache.hertzbeat.common.entity.message.ClusterMsg.MessageType.HEARTBEAT.getNumber()) { output.writeEnum(3, type_); } if (!msg_.isEmpty()) { output.writeBytes(4, msg_); } + if (authTimestamp_ != 0L) { + output.writeInt64(5, authTimestamp_); + } + if (!authNonce_.isEmpty()) { + output.writeBytes(6, authNonce_); + } + if (!authSignature_.isEmpty()) { + output.writeBytes(7, authSignature_); + } + if (authVersion_ != 0) { + output.writeUInt32(8, authVersion_); + } + if (!getAuthKeyIdBytes().isEmpty()) { + com.google.protobuf.GeneratedMessageV3.writeString(output, 9, authKeyId_); + } + if (!authChannelNonce_.isEmpty()) { + output.writeBytes(10, authChannelNonce_); + } unknownFields.writeTo(output); } - @Override + @java.lang.Override public int getSerializedSize() { int size = memoizedSize; if (size != -1) return size; size = 0; - if (!com.google.protobuf.GeneratedMessageV3.isStringEmpty(identity_)) { + if (!getIdentityBytes().isEmpty()) { size += com.google.protobuf.GeneratedMessageV3.computeStringSize(1, identity_); } - if (direction_ != Direction.REQUEST.getNumber()) { + if (direction_ != org.apache.hertzbeat.common.entity.message.ClusterMsg.Direction.REQUEST.getNumber()) { size += com.google.protobuf.CodedOutputStream .computeEnumSize(2, direction_); } - if (type_ != MessageType.HEARTBEAT.getNumber()) { + if (type_ != org.apache.hertzbeat.common.entity.message.ClusterMsg.MessageType.HEARTBEAT.getNumber()) { size += com.google.protobuf.CodedOutputStream .computeEnumSize(3, type_); } @@ -761,20 +1018,43 @@ public int getSerializedSize() { size += com.google.protobuf.CodedOutputStream .computeBytesSize(4, msg_); } + if (authTimestamp_ != 0L) { + size += com.google.protobuf.CodedOutputStream + .computeInt64Size(5, authTimestamp_); + } + if (!authNonce_.isEmpty()) { + size += com.google.protobuf.CodedOutputStream + .computeBytesSize(6, authNonce_); + } + if (!authSignature_.isEmpty()) { + size += com.google.protobuf.CodedOutputStream + .computeBytesSize(7, authSignature_); + } + if (authVersion_ != 0) { + size += com.google.protobuf.CodedOutputStream + .computeUInt32Size(8, authVersion_); + } + if (!getAuthKeyIdBytes().isEmpty()) { + size += com.google.protobuf.GeneratedMessageV3.computeStringSize(9, authKeyId_); + } + if (!authChannelNonce_.isEmpty()) { + size += com.google.protobuf.CodedOutputStream + .computeBytesSize(10, authChannelNonce_); + } size += unknownFields.getSerializedSize(); memoizedSize = size; return size; } - @Override - public boolean equals(final Object obj) { + @java.lang.Override + public boolean equals(final java.lang.Object obj) { if (obj == this) { return true; } - if (!(obj instanceof Message)) { + if (!(obj instanceof org.apache.hertzbeat.common.entity.message.ClusterMsg.Message)) { return super.equals(obj); } - Message other = (Message) obj; + org.apache.hertzbeat.common.entity.message.ClusterMsg.Message other = (org.apache.hertzbeat.common.entity.message.ClusterMsg.Message) obj; if (!getIdentity() .equals(other.getIdentity())) return false; @@ -782,11 +1062,23 @@ public boolean equals(final Object obj) { if (type_ != other.type_) return false; if (!getMsg() .equals(other.getMsg())) return false; + if (getAuthTimestamp() + != other.getAuthTimestamp()) return false; + if (!getAuthNonce() + .equals(other.getAuthNonce())) return false; + if (!getAuthSignature() + .equals(other.getAuthSignature())) return false; + if (getAuthVersion() + != other.getAuthVersion()) return false; + if (!getAuthKeyId() + .equals(other.getAuthKeyId())) return false; + if (!getAuthChannelNonce() + .equals(other.getAuthChannelNonce())) return false; if (!unknownFields.equals(other.unknownFields)) return false; return true; } - @Override + @java.lang.Override public int hashCode() { if (memoizedHashCode != 0) { return memoizedHashCode; @@ -801,74 +1093,87 @@ public int hashCode() { hash = (53 * hash) + type_; hash = (37 * hash) + MSG_FIELD_NUMBER; hash = (53 * hash) + getMsg().hashCode(); + hash = (37 * hash) + AUTH_TIMESTAMP_FIELD_NUMBER; + hash = (53 * hash) + com.google.protobuf.Internal.hashLong( + getAuthTimestamp()); + hash = (37 * hash) + AUTH_NONCE_FIELD_NUMBER; + hash = (53 * hash) + getAuthNonce().hashCode(); + hash = (37 * hash) + AUTH_SIGNATURE_FIELD_NUMBER; + hash = (53 * hash) + getAuthSignature().hashCode(); + hash = (37 * hash) + AUTH_VERSION_FIELD_NUMBER; + hash = (53 * hash) + getAuthVersion(); + hash = (37 * hash) + AUTH_KEY_ID_FIELD_NUMBER; + hash = (53 * hash) + getAuthKeyId().hashCode(); + hash = (37 * hash) + AUTH_CHANNEL_NONCE_FIELD_NUMBER; + hash = (53 * hash) + getAuthChannelNonce().hashCode(); hash = (29 * hash) + unknownFields.hashCode(); memoizedHashCode = hash; return hash; } - public static Message parseFrom( + public static org.apache.hertzbeat.common.entity.message.ClusterMsg.Message parseFrom( java.nio.ByteBuffer data) throws com.google.protobuf.InvalidProtocolBufferException { return PARSER.parseFrom(data); } - public static Message parseFrom( + public static org.apache.hertzbeat.common.entity.message.ClusterMsg.Message parseFrom( java.nio.ByteBuffer data, com.google.protobuf.ExtensionRegistryLite extensionRegistry) throws com.google.protobuf.InvalidProtocolBufferException { return PARSER.parseFrom(data, extensionRegistry); } - public static Message parseFrom( + public static org.apache.hertzbeat.common.entity.message.ClusterMsg.Message parseFrom( com.google.protobuf.ByteString data) throws com.google.protobuf.InvalidProtocolBufferException { return PARSER.parseFrom(data); } - public static Message parseFrom( + public static org.apache.hertzbeat.common.entity.message.ClusterMsg.Message parseFrom( com.google.protobuf.ByteString data, com.google.protobuf.ExtensionRegistryLite extensionRegistry) throws com.google.protobuf.InvalidProtocolBufferException { return PARSER.parseFrom(data, extensionRegistry); } - public static Message parseFrom(byte[] data) + public static org.apache.hertzbeat.common.entity.message.ClusterMsg.Message parseFrom(byte[] data) throws com.google.protobuf.InvalidProtocolBufferException { return PARSER.parseFrom(data); } - public static Message parseFrom( + public static org.apache.hertzbeat.common.entity.message.ClusterMsg.Message parseFrom( byte[] data, com.google.protobuf.ExtensionRegistryLite extensionRegistry) throws com.google.protobuf.InvalidProtocolBufferException { return PARSER.parseFrom(data, extensionRegistry); } - public static Message parseFrom(java.io.InputStream input) + public static org.apache.hertzbeat.common.entity.message.ClusterMsg.Message parseFrom(java.io.InputStream input) throws java.io.IOException { return com.google.protobuf.GeneratedMessageV3 .parseWithIOException(PARSER, input); } - public static Message parseFrom( + public static org.apache.hertzbeat.common.entity.message.ClusterMsg.Message parseFrom( java.io.InputStream input, com.google.protobuf.ExtensionRegistryLite extensionRegistry) throws java.io.IOException { return com.google.protobuf.GeneratedMessageV3 .parseWithIOException(PARSER, input, extensionRegistry); } - public static Message parseDelimitedFrom(java.io.InputStream input) + public static org.apache.hertzbeat.common.entity.message.ClusterMsg.Message parseDelimitedFrom(java.io.InputStream input) throws java.io.IOException { return com.google.protobuf.GeneratedMessageV3 .parseDelimitedWithIOException(PARSER, input); } - public static Message parseDelimitedFrom( + public static org.apache.hertzbeat.common.entity.message.ClusterMsg.Message parseDelimitedFrom( java.io.InputStream input, com.google.protobuf.ExtensionRegistryLite extensionRegistry) throws java.io.IOException { return com.google.protobuf.GeneratedMessageV3 .parseDelimitedWithIOException(PARSER, input, extensionRegistry); } - public static Message parseFrom( + public static org.apache.hertzbeat.common.entity.message.ClusterMsg.Message parseFrom( com.google.protobuf.CodedInputStream input) throws java.io.IOException { return com.google.protobuf.GeneratedMessageV3 .parseWithIOException(PARSER, input); } - public static Message parseFrom( + public static org.apache.hertzbeat.common.entity.message.ClusterMsg.Message parseFrom( com.google.protobuf.CodedInputStream input, com.google.protobuf.ExtensionRegistryLite extensionRegistry) throws java.io.IOException { @@ -876,23 +1181,23 @@ public static Message parseFrom( .parseWithIOException(PARSER, input, extensionRegistry); } - @Override + @java.lang.Override public Builder newBuilderForType() { return newBuilder(); } public static Builder newBuilder() { return DEFAULT_INSTANCE.toBuilder(); } - public static Builder newBuilder(Message prototype) { + public static Builder newBuilder(org.apache.hertzbeat.common.entity.message.ClusterMsg.Message prototype) { return DEFAULT_INSTANCE.toBuilder().mergeFrom(prototype); } - @Override + @java.lang.Override public Builder toBuilder() { return this == DEFAULT_INSTANCE ? new Builder() : new Builder().mergeFrom(this); } - @Override + @java.lang.Override protected Builder newBuilderForType( - BuilderParent parent) { + com.google.protobuf.GeneratedMessageV3.BuilderParent parent) { Builder builder = new Builder(parent); return builder; } @@ -902,18 +1207,18 @@ protected Builder newBuilderForType( public static final class Builder extends com.google.protobuf.GeneratedMessageV3.Builder implements // @@protoc_insertion_point(builder_implements:org.apache.hertzbeat.common.entity.message.Message) - MessageOrBuilder { + org.apache.hertzbeat.common.entity.message.ClusterMsg.MessageOrBuilder { public static final com.google.protobuf.Descriptors.Descriptor getDescriptor() { - return ClusterMsg.internal_static_org_apache_hertzbeat_common_entity_message_Message_descriptor; + return org.apache.hertzbeat.common.entity.message.ClusterMsg.internal_static_org_apache_hertzbeat_common_entity_message_Message_descriptor; } - @Override - protected FieldAccessorTable + @java.lang.Override + protected com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internalGetFieldAccessorTable() { - return ClusterMsg.internal_static_org_apache_hertzbeat_common_entity_message_Message_fieldAccessorTable + return org.apache.hertzbeat.common.entity.message.ClusterMsg.internal_static_org_apache_hertzbeat_common_entity_message_Message_fieldAccessorTable .ensureFieldAccessorsInitialized( - Message.class, Builder.class); + org.apache.hertzbeat.common.entity.message.ClusterMsg.Message.class, org.apache.hertzbeat.common.entity.message.ClusterMsg.Message.Builder.class); } // Construct using org.apache.hertzbeat.common.entity.message.ClusterMsg.Message.newBuilder() @@ -922,7 +1227,7 @@ private Builder() { } private Builder( - BuilderParent parent) { + com.google.protobuf.GeneratedMessageV3.BuilderParent parent) { super(parent); maybeForceBuilderInitialization(); } @@ -931,7 +1236,7 @@ private void maybeForceBuilderInitialization() { .alwaysUseFieldBuilders) { } } - @Override + @java.lang.Override public Builder clear() { super.clear(); identity_ = ""; @@ -942,84 +1247,102 @@ public Builder clear() { msg_ = com.google.protobuf.ByteString.EMPTY; + authTimestamp_ = 0L; + + authNonce_ = com.google.protobuf.ByteString.EMPTY; + + authSignature_ = com.google.protobuf.ByteString.EMPTY; + + authVersion_ = 0; + + authKeyId_ = ""; + + authChannelNonce_ = com.google.protobuf.ByteString.EMPTY; + return this; } - @Override + @java.lang.Override public com.google.protobuf.Descriptors.Descriptor getDescriptorForType() { - return ClusterMsg.internal_static_org_apache_hertzbeat_common_entity_message_Message_descriptor; + return org.apache.hertzbeat.common.entity.message.ClusterMsg.internal_static_org_apache_hertzbeat_common_entity_message_Message_descriptor; } - @Override - public Message getDefaultInstanceForType() { - return Message.getDefaultInstance(); + @java.lang.Override + public org.apache.hertzbeat.common.entity.message.ClusterMsg.Message getDefaultInstanceForType() { + return org.apache.hertzbeat.common.entity.message.ClusterMsg.Message.getDefaultInstance(); } - @Override - public Message build() { - Message result = buildPartial(); + @java.lang.Override + public org.apache.hertzbeat.common.entity.message.ClusterMsg.Message build() { + org.apache.hertzbeat.common.entity.message.ClusterMsg.Message result = buildPartial(); if (!result.isInitialized()) { throw newUninitializedMessageException(result); } return result; } - @Override - public Message buildPartial() { - Message result = new Message(this); + @java.lang.Override + public org.apache.hertzbeat.common.entity.message.ClusterMsg.Message buildPartial() { + org.apache.hertzbeat.common.entity.message.ClusterMsg.Message result = new org.apache.hertzbeat.common.entity.message.ClusterMsg.Message(this); result.identity_ = identity_; result.direction_ = direction_; result.type_ = type_; result.msg_ = msg_; + result.authTimestamp_ = authTimestamp_; + result.authNonce_ = authNonce_; + result.authSignature_ = authSignature_; + result.authVersion_ = authVersion_; + result.authKeyId_ = authKeyId_; + result.authChannelNonce_ = authChannelNonce_; onBuilt(); return result; } - @Override + @java.lang.Override public Builder clone() { return super.clone(); } - @Override + @java.lang.Override public Builder setField( com.google.protobuf.Descriptors.FieldDescriptor field, - Object value) { + java.lang.Object value) { return super.setField(field, value); } - @Override + @java.lang.Override public Builder clearField( com.google.protobuf.Descriptors.FieldDescriptor field) { return super.clearField(field); } - @Override + @java.lang.Override public Builder clearOneof( com.google.protobuf.Descriptors.OneofDescriptor oneof) { return super.clearOneof(oneof); } - @Override + @java.lang.Override public Builder setRepeatedField( com.google.protobuf.Descriptors.FieldDescriptor field, - int index, Object value) { + int index, java.lang.Object value) { return super.setRepeatedField(field, index, value); } - @Override + @java.lang.Override public Builder addRepeatedField( com.google.protobuf.Descriptors.FieldDescriptor field, - Object value) { + java.lang.Object value) { return super.addRepeatedField(field, value); } - @Override + @java.lang.Override public Builder mergeFrom(com.google.protobuf.Message other) { - if (other instanceof Message) { - return mergeFrom((Message)other); + if (other instanceof org.apache.hertzbeat.common.entity.message.ClusterMsg.Message) { + return mergeFrom((org.apache.hertzbeat.common.entity.message.ClusterMsg.Message)other); } else { super.mergeFrom(other); return this; } } - public Builder mergeFrom(Message other) { - if (other == Message.getDefaultInstance()) return this; + public Builder mergeFrom(org.apache.hertzbeat.common.entity.message.ClusterMsg.Message other) { + if (other == org.apache.hertzbeat.common.entity.message.ClusterMsg.Message.getDefaultInstance()) return this; if (!other.getIdentity().isEmpty()) { identity_ = other.identity_; onChanged(); @@ -1033,26 +1356,45 @@ public Builder mergeFrom(Message other) { if (other.getMsg() != com.google.protobuf.ByteString.EMPTY) { setMsg(other.getMsg()); } + if (other.getAuthTimestamp() != 0L) { + setAuthTimestamp(other.getAuthTimestamp()); + } + if (other.getAuthNonce() != com.google.protobuf.ByteString.EMPTY) { + setAuthNonce(other.getAuthNonce()); + } + if (other.getAuthSignature() != com.google.protobuf.ByteString.EMPTY) { + setAuthSignature(other.getAuthSignature()); + } + if (other.getAuthVersion() != 0) { + setAuthVersion(other.getAuthVersion()); + } + if (!other.getAuthKeyId().isEmpty()) { + authKeyId_ = other.authKeyId_; + onChanged(); + } + if (other.getAuthChannelNonce() != com.google.protobuf.ByteString.EMPTY) { + setAuthChannelNonce(other.getAuthChannelNonce()); + } this.mergeUnknownFields(other.unknownFields); onChanged(); return this; } - @Override + @java.lang.Override public final boolean isInitialized() { return true; } - @Override + @java.lang.Override public Builder mergeFrom( com.google.protobuf.CodedInputStream input, com.google.protobuf.ExtensionRegistryLite extensionRegistry) throws java.io.IOException { - Message parsedMessage = null; + org.apache.hertzbeat.common.entity.message.ClusterMsg.Message parsedMessage = null; try { parsedMessage = PARSER.parsePartialFrom(input, extensionRegistry); } catch (com.google.protobuf.InvalidProtocolBufferException e) { - parsedMessage = (Message) e.getUnfinishedMessage(); + parsedMessage = (org.apache.hertzbeat.common.entity.message.ClusterMsg.Message) e.getUnfinishedMessage(); throw e.unwrapIOException(); } finally { if (parsedMessage != null) { @@ -1062,7 +1404,7 @@ public Builder mergeFrom( return this; } - private Object identity_ = ""; + private java.lang.Object identity_ = ""; /** *
        * collector identity
@@ -1071,16 +1413,16 @@ public Builder mergeFrom(
        * string identity = 1;
        * @return The identity.
        */
-      public String getIdentity() {
-        Object ref = identity_;
-        if (!(ref instanceof String)) {
+      public java.lang.String getIdentity() {
+        java.lang.Object ref = identity_;
+        if (!(ref instanceof java.lang.String)) {
           com.google.protobuf.ByteString bs =
               (com.google.protobuf.ByteString) ref;
-          String s = bs.toStringUtf8();
+          java.lang.String s = bs.toStringUtf8();
           identity_ = s;
           return s;
         } else {
-          return (String) ref;
+          return (java.lang.String) ref;
         }
       }
       /**
@@ -1093,11 +1435,11 @@ public String getIdentity() {
        */
       public com.google.protobuf.ByteString
           getIdentityBytes() {
-        Object ref = identity_;
+        java.lang.Object ref = identity_;
         if (ref instanceof String) {
-          com.google.protobuf.ByteString b = 
+          com.google.protobuf.ByteString b =
               com.google.protobuf.ByteString.copyFromUtf8(
-                  (String) ref);
+                  (java.lang.String) ref);
           identity_ = b;
           return b;
         } else {
@@ -1114,11 +1456,11 @@ public String getIdentity() {
        * @return This builder for chaining.
        */
       public Builder setIdentity(
-          String value) {
+          java.lang.String value) {
         if (value == null) {
     throw new NullPointerException();
   }
-  
+
         identity_ = value;
         onChanged();
         return this;
@@ -1132,7 +1474,7 @@ public Builder setIdentity(
        * @return This builder for chaining.
        */
       public Builder clearIdentity() {
-        
+
         identity_ = getDefaultInstance().getIdentity();
         onChanged();
         return this;
@@ -1152,7 +1494,7 @@ public Builder setIdentityBytes(
     throw new NullPointerException();
   }
   checkByteStringIsUtf8(value);
-        
+
         identity_ = value;
         onChanged();
         return this;
@@ -1167,7 +1509,7 @@ public Builder setIdentityBytes(
        * .org.apache.hertzbeat.common.entity.message.Direction direction = 2;
        * @return The enum numeric value on the wire for direction.
        */
-      @Override public int getDirectionValue() {
+      @java.lang.Override public int getDirectionValue() {
         return direction_;
       }
       /**
@@ -1180,7 +1522,7 @@ public Builder setIdentityBytes(
        * @return This builder for chaining.
        */
       public Builder setDirectionValue(int value) {
-        
+
         direction_ = value;
         onChanged();
         return this;
@@ -1193,11 +1535,11 @@ public Builder setDirectionValue(int value) {
        * .org.apache.hertzbeat.common.entity.message.Direction direction = 2;
        * @return The direction.
        */
-      @Override
-      public Direction getDirection() {
+      @java.lang.Override
+      public org.apache.hertzbeat.common.entity.message.ClusterMsg.Direction getDirection() {
         @SuppressWarnings("deprecation")
-        Direction result = Direction.valueOf(direction_);
-        return result == null ? Direction.UNRECOGNIZED : result;
+        org.apache.hertzbeat.common.entity.message.ClusterMsg.Direction result = org.apache.hertzbeat.common.entity.message.ClusterMsg.Direction.valueOf(direction_);
+        return result == null ? org.apache.hertzbeat.common.entity.message.ClusterMsg.Direction.UNRECOGNIZED : result;
       }
       /**
        * 
@@ -1208,11 +1550,11 @@ public Direction getDirection() {
        * @param value The direction to set.
        * @return This builder for chaining.
        */
-      public Builder setDirection(Direction value) {
+      public Builder setDirection(org.apache.hertzbeat.common.entity.message.ClusterMsg.Direction value) {
         if (value == null) {
           throw new NullPointerException();
         }
-        
+
         direction_ = value.getNumber();
         onChanged();
         return this;
@@ -1226,7 +1568,7 @@ public Builder setDirection(Direction value) {
        * @return This builder for chaining.
        */
       public Builder clearDirection() {
-        
+
         direction_ = 0;
         onChanged();
         return this;
@@ -1241,7 +1583,7 @@ public Builder clearDirection() {
        * .org.apache.hertzbeat.common.entity.message.MessageType type = 3;
        * @return The enum numeric value on the wire for type.
        */
-      @Override public int getTypeValue() {
+      @java.lang.Override public int getTypeValue() {
         return type_;
       }
       /**
@@ -1254,7 +1596,7 @@ public Builder clearDirection() {
        * @return This builder for chaining.
        */
       public Builder setTypeValue(int value) {
-        
+
         type_ = value;
         onChanged();
         return this;
@@ -1267,11 +1609,11 @@ public Builder setTypeValue(int value) {
        * .org.apache.hertzbeat.common.entity.message.MessageType type = 3;
        * @return The type.
        */
-      @Override
-      public MessageType getType() {
+      @java.lang.Override
+      public org.apache.hertzbeat.common.entity.message.ClusterMsg.MessageType getType() {
         @SuppressWarnings("deprecation")
-        MessageType result = MessageType.valueOf(type_);
-        return result == null ? MessageType.UNRECOGNIZED : result;
+        org.apache.hertzbeat.common.entity.message.ClusterMsg.MessageType result = org.apache.hertzbeat.common.entity.message.ClusterMsg.MessageType.valueOf(type_);
+        return result == null ? org.apache.hertzbeat.common.entity.message.ClusterMsg.MessageType.UNRECOGNIZED : result;
       }
       /**
        * 
@@ -1282,11 +1624,11 @@ public MessageType getType() {
        * @param value The type to set.
        * @return This builder for chaining.
        */
-      public Builder setType(MessageType value) {
+      public Builder setType(org.apache.hertzbeat.common.entity.message.ClusterMsg.MessageType value) {
         if (value == null) {
           throw new NullPointerException();
         }
-        
+
         type_ = value.getNumber();
         onChanged();
         return this;
@@ -1300,7 +1642,7 @@ public Builder setType(MessageType value) {
        * @return This builder for chaining.
        */
       public Builder clearType() {
-        
+
         type_ = 0;
         onChanged();
         return this;
@@ -1315,7 +1657,7 @@ public Builder clearType() {
        * bytes msg = 4;
        * @return The msg.
        */
-      @Override
+      @java.lang.Override
       public com.google.protobuf.ByteString getMsg() {
         return msg_;
       }
@@ -1332,7 +1674,7 @@ public Builder setMsg(com.google.protobuf.ByteString value) {
         if (value == null) {
     throw new NullPointerException();
   }
-  
+
         msg_ = value;
         onChanged();
         return this;
@@ -1346,18 +1688,338 @@ public Builder setMsg(com.google.protobuf.ByteString value) {
        * @return This builder for chaining.
        */
       public Builder clearMsg() {
-        
+
         msg_ = getDefaultInstance().getMsg();
         onChanged();
         return this;
       }
-      @Override
+
+      private long authTimestamp_ ;
+      /**
+       * 
+       * sender timestamp used to reject stale messages
+       * 
+ * + * int64 auth_timestamp = 5; + * @return The authTimestamp. + */ + @java.lang.Override + public long getAuthTimestamp() { + return authTimestamp_; + } + /** + *
+       * sender timestamp used to reject stale messages
+       * 
+ * + * int64 auth_timestamp = 5; + * @param value The authTimestamp to set. + * @return This builder for chaining. + */ + public Builder setAuthTimestamp(long value) { + + authTimestamp_ = value; + onChanged(); + return this; + } + /** + *
+       * sender timestamp used to reject stale messages
+       * 
+ * + * int64 auth_timestamp = 5; + * @return This builder for chaining. + */ + public Builder clearAuthTimestamp() { + + authTimestamp_ = 0L; + onChanged(); + return this; + } + + private com.google.protobuf.ByteString authNonce_ = com.google.protobuf.ByteString.EMPTY; + /** + *
+       * per-message random value used to reject replay
+       * 
+ * + * bytes auth_nonce = 6; + * @return The authNonce. + */ + @java.lang.Override + public com.google.protobuf.ByteString getAuthNonce() { + return authNonce_; + } + /** + *
+       * per-message random value used to reject replay
+       * 
+ * + * bytes auth_nonce = 6; + * @param value The authNonce to set. + * @return This builder for chaining. + */ + public Builder setAuthNonce(com.google.protobuf.ByteString value) { + if (value == null) { + throw new NullPointerException(); + } + + authNonce_ = value; + onChanged(); + return this; + } + /** + *
+       * per-message random value used to reject replay
+       * 
+ * + * bytes auth_nonce = 6; + * @return This builder for chaining. + */ + public Builder clearAuthNonce() { + + authNonce_ = getDefaultInstance().getAuthNonce(); + onChanged(); + return this; + } + + private com.google.protobuf.ByteString authSignature_ = com.google.protobuf.ByteString.EMPTY; + /** + *
+       * HMAC-SHA256 over the message with this field cleared
+       * 
+ * + * bytes auth_signature = 7; + * @return The authSignature. + */ + @java.lang.Override + public com.google.protobuf.ByteString getAuthSignature() { + return authSignature_; + } + /** + *
+       * HMAC-SHA256 over the message with this field cleared
+       * 
+ * + * bytes auth_signature = 7; + * @param value The authSignature to set. + * @return This builder for chaining. + */ + public Builder setAuthSignature(com.google.protobuf.ByteString value) { + if (value == null) { + throw new NullPointerException(); + } + + authSignature_ = value; + onChanged(); + return this; + } + /** + *
+       * HMAC-SHA256 over the message with this field cleared
+       * 
+ * + * bytes auth_signature = 7; + * @return This builder for chaining. + */ + public Builder clearAuthSignature() { + + authSignature_ = getDefaultInstance().getAuthSignature(); + onChanged(); + return this; + } + + private int authVersion_ ; + /** + *
+       * authentication envelope version; zero identifies a legacy unsigned message
+       * 
+ * + * uint32 auth_version = 8; + * @return The authVersion. + */ + @java.lang.Override + public int getAuthVersion() { + return authVersion_; + } + /** + *
+       * authentication envelope version; zero identifies a legacy unsigned message
+       * 
+ * + * uint32 auth_version = 8; + * @param value The authVersion to set. + * @return This builder for chaining. + */ + public Builder setAuthVersion(int value) { + + authVersion_ = value; + onChanged(); + return this; + } + /** + *
+       * authentication envelope version; zero identifies a legacy unsigned message
+       * 
+ * + * uint32 auth_version = 8; + * @return This builder for chaining. + */ + public Builder clearAuthVersion() { + + authVersion_ = 0; + onChanged(); + return this; + } + + private java.lang.Object authKeyId_ = ""; + /** + *
+       * key identifier used for non-disruptive key rotation
+       * 
+ * + * string auth_key_id = 9; + * @return The authKeyId. + */ + public java.lang.String getAuthKeyId() { + java.lang.Object ref = authKeyId_; + if (!(ref instanceof java.lang.String)) { + com.google.protobuf.ByteString bs = + (com.google.protobuf.ByteString) ref; + java.lang.String s = bs.toStringUtf8(); + authKeyId_ = s; + return s; + } else { + return (java.lang.String) ref; + } + } + /** + *
+       * key identifier used for non-disruptive key rotation
+       * 
+ * + * string auth_key_id = 9; + * @return The bytes for authKeyId. + */ + public com.google.protobuf.ByteString + getAuthKeyIdBytes() { + java.lang.Object ref = authKeyId_; + if (ref instanceof String) { + com.google.protobuf.ByteString b = + com.google.protobuf.ByteString.copyFromUtf8( + (java.lang.String) ref); + authKeyId_ = b; + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + /** + *
+       * key identifier used for non-disruptive key rotation
+       * 
+ * + * string auth_key_id = 9; + * @param value The authKeyId to set. + * @return This builder for chaining. + */ + public Builder setAuthKeyId( + java.lang.String value) { + if (value == null) { + throw new NullPointerException(); + } + + authKeyId_ = value; + onChanged(); + return this; + } + /** + *
+       * key identifier used for non-disruptive key rotation
+       * 
+ * + * string auth_key_id = 9; + * @return This builder for chaining. + */ + public Builder clearAuthKeyId() { + + authKeyId_ = getDefaultInstance().getAuthKeyId(); + onChanged(); + return this; + } + /** + *
+       * key identifier used for non-disruptive key rotation
+       * 
+ * + * string auth_key_id = 9; + * @param value The bytes for authKeyId to set. + * @return This builder for chaining. + */ + public Builder setAuthKeyIdBytes( + com.google.protobuf.ByteString value) { + if (value == null) { + throw new NullPointerException(); + } + checkByteStringIsUtf8(value); + + authKeyId_ = value; + onChanged(); + return this; + } + + private com.google.protobuf.ByteString authChannelNonce_ = com.google.protobuf.ByteString.EMPTY; + /** + *
+       * server-generated nonce binding a signed message to one Netty connection
+       * 
+ * + * bytes auth_channel_nonce = 10; + * @return The authChannelNonce. + */ + @java.lang.Override + public com.google.protobuf.ByteString getAuthChannelNonce() { + return authChannelNonce_; + } + /** + *
+       * server-generated nonce binding a signed message to one Netty connection
+       * 
+ * + * bytes auth_channel_nonce = 10; + * @param value The authChannelNonce to set. + * @return This builder for chaining. + */ + public Builder setAuthChannelNonce(com.google.protobuf.ByteString value) { + if (value == null) { + throw new NullPointerException(); + } + + authChannelNonce_ = value; + onChanged(); + return this; + } + /** + *
+       * server-generated nonce binding a signed message to one Netty connection
+       * 
+ * + * bytes auth_channel_nonce = 10; + * @return This builder for chaining. + */ + public Builder clearAuthChannelNonce() { + + authChannelNonce_ = getDefaultInstance().getAuthChannelNonce(); + onChanged(); + return this; + } + @java.lang.Override public final Builder setUnknownFields( final com.google.protobuf.UnknownFieldSet unknownFields) { return super.setUnknownFields(unknownFields); } - @Override + @java.lang.Override public final Builder mergeUnknownFields( final com.google.protobuf.UnknownFieldSet unknownFields) { return super.mergeUnknownFields(unknownFields); @@ -1368,18 +2030,18 @@ public final Builder mergeUnknownFields( } // @@protoc_insertion_point(class_scope:org.apache.hertzbeat.common.entity.message.Message) - private static final Message DEFAULT_INSTANCE; + private static final org.apache.hertzbeat.common.entity.message.ClusterMsg.Message DEFAULT_INSTANCE; static { - DEFAULT_INSTANCE = new Message(); + DEFAULT_INSTANCE = new org.apache.hertzbeat.common.entity.message.ClusterMsg.Message(); } - public static Message getDefaultInstance() { + public static org.apache.hertzbeat.common.entity.message.ClusterMsg.Message getDefaultInstance() { return DEFAULT_INSTANCE; } private static final com.google.protobuf.Parser PARSER = new com.google.protobuf.AbstractParser() { - @Override + @java.lang.Override public Message parsePartialFrom( com.google.protobuf.CodedInputStream input, com.google.protobuf.ExtensionRegistryLite extensionRegistry) @@ -1392,13 +2054,13 @@ public static com.google.protobuf.Parser parser() { return PARSER; } - @Override + @java.lang.Override public com.google.protobuf.Parser getParserForType() { return PARSER; } - @Override - public Message getDefaultInstanceForType() { + @java.lang.Override + public org.apache.hertzbeat.common.entity.message.ClusterMsg.Message getDefaultInstanceForType() { return DEFAULT_INSTANCE; } @@ -1406,7 +2068,7 @@ public Message getDefaultInstanceForType() { private static final com.google.protobuf.Descriptors.Descriptor internal_static_org_apache_hertzbeat_common_entity_message_Message_descriptor; - private static final + private static final com.google.protobuf.GeneratedMessageV3.FieldAccessorTable internal_static_org_apache_hertzbeat_common_entity_message_Message_fieldAccessorTable; @@ -1417,21 +2079,25 @@ public Message getDefaultInstanceForType() { private static com.google.protobuf.Descriptors.FileDescriptor descriptor; static { - String[] descriptorData = { + java.lang.String[] descriptorData = { "\n\021cluster_msg.proto\022*org.apache.hertzbea" + - "t.common.entity.message\"\271\001\n\007Message\022\020\n\010i" + + "t.common.entity.message\"\304\002\n\007Message\022\020\n\010i" + "dentity\030\001 \001(\t\022H\n\tdirection\030\002 \001(\01625.org.a" + "pache.hertzbeat.common.entity.message.Di" + "rection\022E\n\004type\030\003 \001(\01627.org.apache.hertz" + "beat.common.entity.message.MessageType\022\013" + - "\n\003msg\030\004 \001(\014*\363\001\n\013MessageType\022\r\n\tHEARTBEAT" + - "\020\000\022\r\n\tGO_ONLINE\020\001\022\016\n\nGO_OFFLINE\020\002\022\014\n\010GO_" + - "CLOSE\020\003\022\025\n\021ISSUE_CYCLIC_TASK\020\004\022\026\n\022DELETE" + - "_CYCLIC_TASK\020\005\022\027\n\023ISSUE_ONE_TIME_TASK\020\006\022" + - "\037\n\033RESPONSE_ONE_TIME_TASK_DATA\020\007\022\035\n\031RESP" + - "ONSE_CYCLIC_TASK_DATA\020\010\022 \n\034RESPONSE_CYCL" + - "IC_TASK_SD_DATA\020\t*&\n\tDirection\022\013\n\007REQUES" + - "T\020\000\022\014\n\010RESPONSE\020\001b\006proto3" + "\n\003msg\030\004 \001(\014\022\026\n\016auth_timestamp\030\005 \001(\003\022\022\n\na" + + "uth_nonce\030\006 \001(\014\022\026\n\016auth_signature\030\007 \001(\014\022" + + "\024\n\014auth_version\030\010 \001(\r\022\023\n\013auth_key_id\030\t \001" + + "(\t\022\032\n\022auth_channel_nonce\030\n \001(\014*\207\002\n\013Messa" + + "geType\022\r\n\tHEARTBEAT\020\000\022\r\n\tGO_ONLINE\020\001\022\016\n\n" + + "GO_OFFLINE\020\002\022\014\n\010GO_CLOSE\020\003\022\025\n\021ISSUE_CYCL" + + "IC_TASK\020\004\022\026\n\022DELETE_CYCLIC_TASK\020\005\022\027\n\023ISS" + + "UE_ONE_TIME_TASK\020\006\022\037\n\033RESPONSE_ONE_TIME_" + + "TASK_DATA\020\007\022\035\n\031RESPONSE_CYCLIC_TASK_DATA" + + "\020\010\022 \n\034RESPONSE_CYCLIC_TASK_SD_DATA\020\t\022\022\n\016" + + "AUTH_CHALLENGE\020\n*&\n\tDirection\022\013\n\007REQUEST" + + "\020\000\022\014\n\010RESPONSE\020\001b\006proto3" }; descriptor = com.google.protobuf.Descriptors.FileDescriptor .internalBuildGeneratedFileFrom(descriptorData, @@ -1442,7 +2108,7 @@ public Message getDefaultInstanceForType() { internal_static_org_apache_hertzbeat_common_entity_message_Message_fieldAccessorTable = new com.google.protobuf.GeneratedMessageV3.FieldAccessorTable( internal_static_org_apache_hertzbeat_common_entity_message_Message_descriptor, - new String[] { "Identity", "Direction", "Type", "Msg", }); + new java.lang.String[] { "Identity", "Direction", "Type", "Msg", "AuthTimestamp", "AuthNonce", "AuthSignature", "AuthVersion", "AuthKeyId", "AuthChannelNonce", }); } // @@protoc_insertion_point(outer_class_scope) diff --git a/hertzbeat-common-core/src/main/message/cluster_msg.proto b/hertzbeat-common-core/src/main/message/cluster_msg.proto index bcc1335fc30..b4c9a98970c 100644 --- a/hertzbeat-common-core/src/main/message/cluster_msg.proto +++ b/hertzbeat-common-core/src/main/message/cluster_msg.proto @@ -28,6 +28,18 @@ message Message MessageType type = 3; // message content bytes msg = 4; + // sender timestamp used to reject stale messages + int64 auth_timestamp = 5; + // per-message random value used to reject replay + bytes auth_nonce = 6; + // HMAC-SHA256 over the message with this field cleared + bytes auth_signature = 7; + // authentication envelope version; zero identifies a legacy unsigned message + uint32 auth_version = 8; + // key identifier used for non-disruptive key rotation + string auth_key_id = 9; + // server-generated nonce binding a signed message to one Netty connection + bytes auth_channel_nonce = 10; } enum MessageType @@ -52,6 +64,8 @@ enum MessageType RESPONSE_CYCLIC_TASK_DATA = 8; // response cyclic service discovery data RESPONSE_CYCLIC_TASK_SD_DATA = 9; + // internal authentication capability and channel-binding challenge + AUTH_CHALLENGE = 10; } enum Direction { diff --git a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/scheduler/CollectorJobScheduler.java b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/scheduler/CollectorJobScheduler.java index b9512f60ec6..eb10f51a3c5 100644 --- a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/scheduler/CollectorJobScheduler.java +++ b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/scheduler/CollectorJobScheduler.java @@ -36,7 +36,6 @@ import org.apache.hertzbeat.collector.dispatch.entrance.internal.CollectResponseEventListener; import org.apache.hertzbeat.common.constants.CommonConstants; import org.apache.hertzbeat.common.entity.dto.CollectorInfo; -import org.apache.hertzbeat.common.entity.dto.ServerInfo; import org.apache.hertzbeat.common.entity.job.Configmap; import org.apache.hertzbeat.common.entity.job.Job; import org.apache.hertzbeat.common.entity.job.RuntimeParamDefine; @@ -46,7 +45,6 @@ import org.apache.hertzbeat.common.entity.manager.Param; import org.apache.hertzbeat.common.entity.message.ClusterMsg; import org.apache.hertzbeat.common.entity.message.CollectRep; -import org.apache.hertzbeat.common.util.AesUtil; import org.apache.hertzbeat.common.util.JsonUtil; import org.apache.hertzbeat.common.util.SnowFlakeIdGenerator; import org.apache.hertzbeat.manager.dao.CollectorDao; @@ -265,11 +263,9 @@ public boolean onlineCollector(String identity) { if (Objects.isNull(collector)) { return false; } - ServerInfo serverInfo = ServerInfo.builder().aesSecret(AesUtil.getDefaultSecretKey()).build(); ClusterMsg.Message message = ClusterMsg.Message.newBuilder() .setType(ClusterMsg.MessageType.GO_ONLINE) .setDirection(ClusterMsg.Direction.REQUEST) - .setMsg(ByteString.copyFromUtf8(JsonUtil.toJson(serverInfo))) .setIdentity(identity) .build(); ClusterMsg.Message response = this.manageServer.sendMsgSync(identity, message); diff --git a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/scheduler/SchedulerProperties.java b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/scheduler/SchedulerProperties.java index 826a8049275..540435d0e37 100644 --- a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/scheduler/SchedulerProperties.java +++ b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/scheduler/SchedulerProperties.java @@ -20,6 +20,7 @@ import lombok.Getter; import lombok.Setter; import org.apache.hertzbeat.common.constants.ConfigConstants; +import org.apache.hertzbeat.remoting.netty.ClusterMessageAuthConfig; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; @@ -54,6 +55,8 @@ public static class ServerProperties { */ private int idleStateEventTriggerTime = 100; + private ClusterMessageAuthConfig authentication = new ClusterMessageAuthConfig(); + } } diff --git a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/scheduler/netty/ManageServer.java b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/scheduler/netty/ManageServer.java index 8895cbbb48d..1c8d280b140 100644 --- a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/scheduler/netty/ManageServer.java +++ b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/scheduler/netty/ManageServer.java @@ -30,6 +30,7 @@ import org.apache.hertzbeat.common.config.VirtualThreadProperties; import org.apache.hertzbeat.common.entity.message.ClusterMsg; import org.apache.hertzbeat.common.queue.CommonDataQueue; +import org.apache.hertzbeat.common.util.AesUtil; import org.apache.hertzbeat.manager.scheduler.CollectorJobScheduler; import org.apache.hertzbeat.manager.scheduler.SchedulerProperties; import org.apache.hertzbeat.manager.scheduler.netty.process.CollectCyclicDataResponseProcessor; @@ -108,7 +109,12 @@ private void init(final SchedulerProperties schedulerProperties, final Backgroun nettyServerConfig.setPort(schedulerProperties.getServer().getPort()); nettyServerConfig.setIdleStateEventTriggerTime(schedulerProperties.getServer().getIdleStateEventTriggerTime()); NettyEventListener nettyEventListener = new ManageNettyEventListener(); - this.remotingServer = new NettyRemotingServer(nettyServerConfig, nettyEventListener, threadPool); + this.remotingServer = new NettyRemotingServer( + nettyServerConfig, + nettyEventListener, + threadPool, + schedulerProperties.getServer().getAuthentication(), + AesUtil::getDefaultSecretKey); // register processor this.remotingServer.registerProcessor(ClusterMsg.MessageType.HEARTBEAT, new HeartbeatProcessor(this)); diff --git a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/scheduler/netty/process/CollectorOnlineProcessor.java b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/scheduler/netty/process/CollectorOnlineProcessor.java index 9a7e4f4e082..7f67ca55c03 100644 --- a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/scheduler/netty/process/CollectorOnlineProcessor.java +++ b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/scheduler/netty/process/CollectorOnlineProcessor.java @@ -17,15 +17,12 @@ package org.apache.hertzbeat.manager.scheduler.netty.process; -import com.google.protobuf.ByteString; import io.netty.channel.ChannelHandlerContext; import java.net.InetSocketAddress; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.apache.hertzbeat.common.entity.dto.CollectorInfo; -import org.apache.hertzbeat.common.entity.dto.ServerInfo; import org.apache.hertzbeat.common.entity.message.ClusterMsg; -import org.apache.hertzbeat.common.util.AesUtil; import org.apache.hertzbeat.common.util.JsonUtil; import org.apache.hertzbeat.manager.scheduler.netty.ManageServer; import org.apache.hertzbeat.remoting.netty.NettyRemotingProcessor; @@ -55,11 +52,10 @@ public ClusterMsg.Message handle(ChannelHandlerContext ctx, ClusterMsg.Message m } this.manageServer.addChannel(collector, ctx.channel()); this.manageServer.getCollectorAndJobScheduler().collectorGoOnline(collector, collectorInfo); - ServerInfo serverInfo = ServerInfo.builder().aesSecret(AesUtil.getDefaultSecretKey()).build(); + // The AES key must be configured locally on both Manager and Collector, never sent over plaintext Netty. return ClusterMsg.Message.newBuilder() .setIdentity(message.getIdentity()) .setDirection(ClusterMsg.Direction.RESPONSE) - .setMsg(ByteString.copyFromUtf8(JsonUtil.toJson(serverInfo))) .setType(ClusterMsg.MessageType.GO_ONLINE) .build(); } diff --git a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/config/ClusterAuthenticationPackagingTest.java b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/config/ClusterAuthenticationPackagingTest.java new file mode 100644 index 00000000000..a2ee21ba6f9 --- /dev/null +++ b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/config/ClusterAuthenticationPackagingTest.java @@ -0,0 +1,114 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.hertzbeat.manager.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import org.junit.jupiter.api.Test; + +class ClusterAuthenticationPackagingTest { + + private static final String REQUIRED_SECRET = + "CLUSTER_AUTH_ACTIVE_SECRET: ${CLUSTER_AUTH_ACTIVE_SECRET:?"; + + private static final String REQUIRED_COMMON_SECRET = + "COMMON_SECRET: ${COMMON_SECRET:?"; + + @Test + void everyDockerComposeDistributionRequiresAndDocumentsAnInstallSecret() + throws IOException { + Path repository = repositoryRoot(); + List distributions; + try (var paths = Files.list(repository.resolve("script/docker-compose"))) { + distributions = paths + .filter(Files::isDirectory) + .filter(path -> path.getFileName().toString().startsWith("hertzbeat-")) + .sorted() + .toList(); + } + assertEquals(5, distributions.size()); + + for (Path distribution : distributions) { + assertTrue( + Files.readString(distribution.resolve("docker-compose.yaml")) + .contains(REQUIRED_SECRET), + distribution + " must fail Compose interpolation before an unset secret reaches HertzBeat"); + assertTrue( + Files.readString(distribution.resolve("docker-compose.yaml")) + .contains(REQUIRED_COMMON_SECRET), + distribution + " must require the shared Manager/Collector AES secret"); + String application = Files.readString(distribution.resolve("conf/application.yml")); + assertTrue(application.contains("authentication:")); + assertTrue(application.contains("active-secret: ${CLUSTER_AUTH_ACTIVE_SECRET:}")); + assertTrue(application.contains("secret: ${COMMON_SECRET:}")); + for (String readme : List.of("README.md", "README_CN.md")) { + String instructions = Files.readString(distribution.resolve(readme)); + assertTrue(instructions.contains("openssl rand -hex 32"), distribution + "/" + readme); + assertTrue(instructions.contains("openssl rand -hex 16"), distribution + "/" + readme); + assertTrue(instructions.contains("CLUSTER_AUTH_ACTIVE_SECRET"), distribution + "/" + readme); + assertTrue(instructions.contains("COMMON_SECRET"), distribution + "/" + readme); + } + } + } + + @Test + void packageAndUpgradeDocumentationCoverClusterSecretProvisioning() throws IOException { + Path repository = repositoryRoot(); + for (String document : List.of( + "README.md", + "README_CN.md", + "README_JP.md", + "home/docs/start/docker-deploy.md", + "home/docs/start/docker-compose-deploy.md", + "home/docs/start/package-deploy.md", + "home/docs/start/quickstart.md", + "home/docs/start/upgrade.md", + "home/docs/help/collector.md")) { + String instructions = Files.readString(repository.resolve(document)); + assertTrue(instructions.contains("CLUSTER_AUTH_ACTIVE_SECRET"), document); + assertTrue(instructions.contains("COMMON_SECRET"), document); + } + + String upgrade = Files.readString(repository.resolve("home/docs/start/upgrade.md")); + assertTrue(upgrade.contains("openssl rand -hex 32")); + assertTrue(upgrade.contains("openssl rand -hex 16")); + assertTrue(upgrade.contains("CLUSTER_AUTH_MODE=optional")); + String collector = Files.readString(repository.resolve("home/docs/help/collector.md")); + assertTrue(collector.contains("openssl rand -hex 32")); + assertTrue(collector.contains("openssl rand -hex 16")); + } + + private Path repositoryRoot() { + Path current = Path.of("").toAbsolutePath(); + while (current != null) { + if (Files.isDirectory(current.resolve("script/docker-compose")) + && Files.isDirectory(current.resolve("hertzbeat-manager"))) { + return current; + } + current = current.getParent(); + } + throw new IllegalStateException("Cannot locate the HertzBeat repository root"); + } +} diff --git a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/config/ConfigInitializerTest.java b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/config/ConfigInitializerTest.java new file mode 100644 index 00000000000..b2e138c5d07 --- /dev/null +++ b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/config/ConfigInitializerTest.java @@ -0,0 +1,117 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.hertzbeat.manager.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.apache.hertzbeat.base.dao.GeneralConfigDao; +import org.apache.hertzbeat.common.util.AesUtil; +import org.apache.hertzbeat.manager.pojo.dto.MuteConfig; +import org.apache.hertzbeat.manager.pojo.dto.SystemConfig; +import org.apache.hertzbeat.manager.pojo.dto.SystemSecret; +import org.apache.hertzbeat.manager.service.AppService; +import org.apache.hertzbeat.manager.service.impl.MuteGeneralConfigServiceImpl; +import org.apache.hertzbeat.manager.service.impl.SystemGeneralConfigServiceImpl; +import org.apache.hertzbeat.manager.service.impl.SystemSecretServiceImpl; +import org.apache.hertzbeat.manager.service.impl.TemplateConfigServiceImpl; +import org.apache.hertzbeat.remoting.netty.ClusterMessageAuthConfig; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.test.util.ReflectionTestUtils; + +class ConfigInitializerTest { + + @AfterEach + void resetAesSecret() { + AesUtil.setDefaultSecretKey(AesUtil.DEFAULT_ENCODE_RULES); + } + + @Test + void defaultManagerBootstrapPersistsAndReusesClusterAuthenticationRoot() { + SystemSecretServiceImpl firstSecretService = mock(SystemSecretServiceImpl.class); + ConfigInitializer first = initializer(firstSecretService, null); + + first.start(); + + ArgumentCaptor persisted = ArgumentCaptor.forClass(SystemSecret.class); + verify(firstSecretService).saveConfig(persisted.capture()); + String generatedSecret = persisted.getValue().getAesSecret(); + assertNotEquals(AesUtil.DEFAULT_ENCODE_RULES, generatedSecret); + assertEquals(16, generatedSecret.length()); + assertEquals(generatedSecret, AesUtil.getDefaultSecretKey()); + ClusterMessageAuthConfig authentication = new ClusterMessageAuthConfig(); + authentication.validate(AesUtil::getDefaultSecretKey); + + SystemSecretServiceImpl restartSecretService = mock(SystemSecretServiceImpl.class); + ConfigInitializer restart = initializer( + restartSecretService, + SystemSecret.builder().aesSecret(generatedSecret).build()); + + restart.start(); + + assertEquals(generatedSecret, AesUtil.getDefaultSecretKey()); + assertTrue(restart.isRunning()); + verify(restartSecretService, never()).saveConfig(any()); + } + + private ConfigInitializer initializer( + SystemSecretServiceImpl systemSecretService, + SystemSecret storedSecret) { + SystemGeneralConfigServiceImpl systemConfigService = + mock(SystemGeneralConfigServiceImpl.class); + when(systemConfigService.getConfig()).thenReturn(SystemConfig.builder() + .timeZoneId("UTC") + .locale("en_US") + .theme("default") + .build()); + when(systemSecretService.getConfig()).thenReturn(storedSecret); + TemplateConfigServiceImpl templateConfigService = + mock(TemplateConfigServiceImpl.class); + MuteGeneralConfigServiceImpl muteConfigService = + mock(MuteGeneralConfigServiceImpl.class); + when(muteConfigService.getConfig()).thenReturn(MuteConfig.builder().mute(true).build()); + + ConfigInitializer initializer = new ConfigInitializer(); + ReflectionTestUtils.setField( + initializer, + "currentJwtSecret", + "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY="); + ReflectionTestUtils.setField( + initializer, "currentAesSecret", AesUtil.DEFAULT_ENCODE_RULES); + ReflectionTestUtils.setField( + initializer, "systemGeneralConfigService", systemConfigService); + ReflectionTestUtils.setField(initializer, "systemSecretService", systemSecretService); + ReflectionTestUtils.setField( + initializer, "templateConfigService", templateConfigService); + ReflectionTestUtils.setField(initializer, "muteGeneralConfigService", muteConfigService); + ReflectionTestUtils.setField(initializer, "appService", mock(AppService.class)); + ReflectionTestUtils.setField( + initializer, "generalConfigDao", mock(GeneralConfigDao.class)); + return initializer; + } +} diff --git a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/scheduler/CollectorJobSchedulerTest.java b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/scheduler/CollectorJobSchedulerTest.java index 10cc1761427..402ed7e578f 100644 --- a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/scheduler/CollectorJobSchedulerTest.java +++ b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/scheduler/CollectorJobSchedulerTest.java @@ -47,6 +47,7 @@ import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; @@ -191,4 +192,27 @@ public void testCollectorGoOnlinePropagatesCronSchedule() { assertEquals("0 55 7 * * ?", job.getCronExpression()); } + @Test + void testOnlineCollectorDoesNotSendAesSecret() { + String identity = "collector-1"; + org.apache.hertzbeat.common.entity.manager.Collector collector = + org.apache.hertzbeat.common.entity.manager.Collector.builder() + .name(identity) + .status(CommonConstants.COLLECTOR_STATUS_ONLINE) + .build(); + when(collectorDao.findCollectorByName(identity)).thenReturn(Optional.of(collector)); + ManageServer manageServer = mock(ManageServer.class); + collectorJobScheduler.setManageServer(manageServer); + ClusterMsg.Message successResponse = ClusterMsg.Message.newBuilder() + .setMsg(com.google.protobuf.ByteString.copyFromUtf8(String.valueOf(CommonConstants.SUCCESS_CODE))) + .build(); + when(manageServer.sendMsgSync(eq(identity), any(ClusterMsg.Message.class))).thenReturn(successResponse); + + assertTrue(collectorJobScheduler.onlineCollector(identity)); + + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(ClusterMsg.Message.class); + verify(manageServer).sendMsgSync(eq(identity), messageCaptor.capture()); + assertTrue(messageCaptor.getValue().getMsg().isEmpty()); + } + } diff --git a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/scheduler/netty/process/CollectorOnlineProcessorTest.java b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/scheduler/netty/process/CollectorOnlineProcessorTest.java new file mode 100644 index 00000000000..ac1edc52c22 --- /dev/null +++ b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/scheduler/netty/process/CollectorOnlineProcessorTest.java @@ -0,0 +1,85 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hertzbeat.manager.scheduler.netty.process; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.google.protobuf.ByteString; +import io.netty.channel.Channel; +import io.netty.channel.ChannelHandlerContext; +import org.apache.hertzbeat.common.entity.dto.CollectorInfo; +import org.apache.hertzbeat.common.entity.message.ClusterMsg; +import org.apache.hertzbeat.common.util.JsonUtil; +import org.apache.hertzbeat.manager.scheduler.CollectorJobScheduler; +import org.apache.hertzbeat.manager.scheduler.netty.ManageServer; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +/** + * Security regression tests for {@link CollectorOnlineProcessor}. + */ +@ExtendWith(MockitoExtension.class) +class CollectorOnlineProcessorTest { + + @Mock + private ManageServer manageServer; + + @Mock + private CollectorJobScheduler collectorJobScheduler; + + @Mock + private ChannelHandlerContext channelHandlerContext; + + @Mock + private Channel channel; + + private CollectorOnlineProcessor processor; + + @BeforeEach + void setUp() { + when(channelHandlerContext.channel()).thenReturn(channel); + when(manageServer.getCollectorAndJobScheduler()).thenReturn(collectorJobScheduler); + processor = new CollectorOnlineProcessor(manageServer); + } + + @Test + void shouldNotReturnAesSecretWhenCollectorGoesOnline() { + CollectorInfo collectorInfo = CollectorInfo.builder() + .name("collector-1") + .ip("127.0.0.1") + .mode("public") + .version("1.0.0") + .build(); + ClusterMsg.Message request = ClusterMsg.Message.newBuilder() + .setIdentity("collector-1") + .setType(ClusterMsg.MessageType.GO_ONLINE) + .setMsg(ByteString.copyFromUtf8(JsonUtil.toJson(collectorInfo))) + .build(); + + ClusterMsg.Message response = processor.handle(channelHandlerContext, request); + + assertTrue(response.getMsg().isEmpty()); + verify(manageServer).addChannel("collector-1", channel); + verify(collectorJobScheduler).collectorGoOnline("collector-1", collectorInfo); + } +} diff --git a/hertzbeat-remoting/pom.xml b/hertzbeat-remoting/pom.xml index c9d4f53d37e..133290d92af 100644 --- a/hertzbeat-remoting/pom.xml +++ b/hertzbeat-remoting/pom.xml @@ -29,10 +29,18 @@ ${project.artifactId} + + com.github.ben-manes.caffeine + caffeine + io.netty netty-all + + io.micrometer + micrometer-core + org.apache.hertzbeat hertzbeat-common-core diff --git a/hertzbeat-remoting/src/main/java/org/apache/hertzbeat/remoting/netty/ClusterMessageAuthConfig.java b/hertzbeat-remoting/src/main/java/org/apache/hertzbeat/remoting/netty/ClusterMessageAuthConfig.java new file mode 100644 index 00000000000..5586b641cb6 --- /dev/null +++ b/hertzbeat-remoting/src/main/java/org/apache/hertzbeat/remoting/netty/ClusterMessageAuthConfig.java @@ -0,0 +1,206 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hertzbeat.remoting.netty; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.function.Supplier; +import java.util.regex.Pattern; +import org.apache.hertzbeat.common.util.AesUtil; + +/** + * Versioned cluster-message authentication and rollout settings. + * + *

The active secret may be configured independently. When it is absent, + * the caller-provided root secret is domain-separated before HMAC use.

+ */ +public final class ClusterMessageAuthConfig { + + private static final int MIN_EXPLICIT_SECRET_BYTES = 32; + private static final int MIN_FALLBACK_SECRET_BYTES = 16; + private static final int MIN_PREVIOUS_SECRET_BYTES = 16; + private static final Duration MAX_ALLOWED_CLOCK_SKEW = Duration.ofMinutes(30); + private static final Pattern KEY_ID_PATTERN = Pattern.compile("[A-Za-z0-9._-]{1,64}"); + + private Mode mode = Mode.REQUIRED; + private String activeKeyId = "primary"; + private String activeSecret; + private String previousKeyId; + private String previousSecret; + private Duration maxClockSkew = Duration.ofMinutes(5); + private Duration handshakeTimeout = Duration.ofSeconds(3); + + /** + * Optional accepts legacy unsigned peers during rollout; required rejects them. + */ + public enum Mode { + OPTIONAL, + REQUIRED + } + + public Mode getMode() { + return mode; + } + + public void setMode(Mode mode) { + this.mode = mode; + } + + public String getActiveKeyId() { + return activeKeyId; + } + + public void setActiveKeyId(String activeKeyId) { + this.activeKeyId = activeKeyId; + } + + public String getActiveSecret() { + return activeSecret; + } + + public void setActiveSecret(String activeSecret) { + this.activeSecret = activeSecret; + } + + public String getPreviousKeyId() { + return previousKeyId; + } + + public void setPreviousKeyId(String previousKeyId) { + this.previousKeyId = previousKeyId; + } + + public String getPreviousSecret() { + return previousSecret; + } + + public void setPreviousSecret(String previousSecret) { + this.previousSecret = previousSecret; + } + + public Duration getMaxClockSkew() { + return maxClockSkew; + } + + public void setMaxClockSkew(Duration maxClockSkew) { + this.maxClockSkew = maxClockSkew; + } + + public Duration getHandshakeTimeout() { + return handshakeTimeout; + } + + public void setHandshakeTimeout(Duration handshakeTimeout) { + this.handshakeTimeout = handshakeTimeout; + } + + /** + * Validate all settings and resolved secrets before opening a Netty listener. + * + * @param fallbackSecret supplies the locally configured root secret when no + * independent active secret is configured + */ + public void validate(Supplier fallbackSecret) { + resolve(fallbackSecret); + } + + ResolvedSecrets resolve(Supplier fallbackSecret) { + if (mode == null) { + throw new IllegalStateException("Cluster message authentication mode must be configured"); + } + validateKeyId(activeKeyId, "active"); + validateDuration(maxClockSkew, "max clock skew", MAX_ALLOWED_CLOCK_SKEW); + validateDuration(handshakeTimeout, "handshake timeout", Duration.ofMinutes(1)); + + boolean independentSecret = !isBlank(activeSecret); + String resolvedActiveSecret = independentSecret + ? activeSecret + : fallbackSecret == null ? null : fallbackSecret.get(); + validateSecret( + resolvedActiveSecret, + independentSecret ? MIN_EXPLICIT_SECRET_BYTES : MIN_FALLBACK_SECRET_BYTES, + "active"); + + boolean hasPreviousKeyId = !isBlank(previousKeyId); + boolean hasPreviousSecret = !isBlank(previousSecret); + if (hasPreviousKeyId != hasPreviousSecret) { + throw new IllegalStateException( + "Previous cluster authentication key id and secret must be configured together"); + } + if (hasPreviousKeyId) { + validateKeyId(previousKeyId, "previous"); + // Existing common.secret values are valid 16/24/32-byte AES roots. + // Accepting a 16-byte previous root is required to rotate away from + // the domain-separated fallback without interrupting a cluster. + validateSecret(previousSecret, MIN_PREVIOUS_SECRET_BYTES, "previous"); + if (activeKeyId.equals(previousKeyId)) { + throw new IllegalStateException("Active and previous cluster authentication key ids must differ"); + } + if (resolvedActiveSecret.equals(previousSecret)) { + throw new IllegalStateException("Active and previous cluster authentication secrets must differ"); + } + } + return new ResolvedSecrets( + activeKeyId, + resolvedActiveSecret, + hasPreviousKeyId ? previousKeyId : null, + hasPreviousSecret ? previousSecret : null); + } + + private static void validateDuration(Duration value, String name, Duration maximum) { + if (value == null || value.isZero() || value.isNegative() || value.compareTo(maximum) > 0) { + throw new IllegalStateException( + "Cluster message authentication " + name + " must be positive and no more than " + maximum); + } + } + + private static void validateKeyId(String keyId, String name) { + if (keyId == null || !KEY_ID_PATTERN.matcher(keyId).matches()) { + throw new IllegalStateException( + "Cluster message authentication " + name + " key id must match " + + KEY_ID_PATTERN.pattern()); + } + } + + private static void validateSecret(String secret, int minimumBytes, String name) { + if (isBlank(secret)) { + throw new IllegalStateException( + "Cluster message authentication " + name + " secret must be configured"); + } + if (AesUtil.DEFAULT_ENCODE_RULES.equals(secret)) { + throw new IllegalStateException( + "Cluster message authentication " + name + " secret must not use the default value"); + } + if (secret.getBytes(StandardCharsets.UTF_8).length < minimumBytes) { + throw new IllegalStateException( + "Cluster message authentication " + name + " secret must contain at least " + + minimumBytes + " UTF-8 bytes"); + } + } + + private static boolean isBlank(String value) { + return value == null || value.isBlank(); + } + + record ResolvedSecrets( + String activeKeyId, + String activeSecret, + String previousKeyId, + String previousSecret) { + } +} diff --git a/hertzbeat-remoting/src/main/java/org/apache/hertzbeat/remoting/netty/ClusterMessageAuthMetrics.java b/hertzbeat-remoting/src/main/java/org/apache/hertzbeat/remoting/netty/ClusterMessageAuthMetrics.java new file mode 100644 index 00000000000..be7139c6de7 --- /dev/null +++ b/hertzbeat-remoting/src/main/java/org/apache/hertzbeat/remoting/netty/ClusterMessageAuthMetrics.java @@ -0,0 +1,65 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hertzbeat.remoting.netty; + +import io.micrometer.core.instrument.Metrics; +import java.util.Locale; + +/** + * Low-cardinality authentication rollout and rejection counters. + */ +final class ClusterMessageAuthMetrics { + + private static final String REJECTED_COUNTER = + "hertzbeat.cluster.message.authentication.rejected"; + private static final String LEGACY_COUNTER = + "hertzbeat.cluster.message.authentication.legacy.accepted"; + private static final String HANDSHAKE_COUNTER = + "hertzbeat.cluster.message.authentication.handshake.timeout"; + + private ClusterMessageAuthMetrics() { + } + + static void recordVerification( + ClusterMessageAuthenticator.VerificationResult result, + NettyRemotingAbstract.EndpointRole endpointRole) { + if (result == ClusterMessageAuthenticator.VerificationResult.LEGACY_UNSIGNED) { + Metrics.counter(LEGACY_COUNTER, "endpoint", endpointRole.metricTag()).increment(); + } else if (!result.accepted()) { + Metrics.counter( + REJECTED_COUNTER, + "endpoint", + endpointRole.metricTag(), + "reason", + result.name().toLowerCase(Locale.ROOT)) + .increment(); + } + } + + static void recordHandshakeTimeout( + NettyRemotingAbstract.EndpointRole endpointRole, + ClusterMessageAuthConfig.Mode mode) { + Metrics.counter( + HANDSHAKE_COUNTER, + "endpoint", + endpointRole.metricTag(), + "mode", + mode.name().toLowerCase(Locale.ROOT)) + .increment(); + } +} diff --git a/hertzbeat-remoting/src/main/java/org/apache/hertzbeat/remoting/netty/ClusterMessageAuthenticator.java b/hertzbeat-remoting/src/main/java/org/apache/hertzbeat/remoting/netty/ClusterMessageAuthenticator.java new file mode 100644 index 00000000000..f3a4c4ee8b4 --- /dev/null +++ b/hertzbeat-remoting/src/main/java/org/apache/hertzbeat/remoting/netty/ClusterMessageAuthenticator.java @@ -0,0 +1,287 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hertzbeat.remoting.netty; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.google.protobuf.ByteString; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.time.Clock; +import java.time.Duration; +import java.util.function.Supplier; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import org.apache.hertzbeat.common.entity.message.ClusterMsg; + +/** + * Signs versioned cluster envelopes and verifies integrity, freshness, + * key rotation, connection binding, and replay state. + */ +final class ClusterMessageAuthenticator { + + static final int AUTH_VERSION = 1; + static final int CHANNEL_NONCE_BYTES = 32; + + private static final String HMAC_ALGORITHM = "HmacSHA256"; + private static final int MESSAGE_NONCE_BYTES = 16; + private static final int SIGNATURE_BYTES = 32; + private static final long MAX_REPLAY_ENTRIES = 65_536; + private static final Duration DEFAULT_REPLAY_WINDOW = Duration.ofMinutes(10); + private static final Duration MAX_CONFIGURED_CLOCK_SKEW = Duration.ofMinutes(30); + private static final byte[] HKDF_SALT = + "HertzBeat cluster message authentication salt v1".getBytes(StandardCharsets.UTF_8); + private static final byte[] HKDF_INFO = + "HertzBeat Netty cluster envelope HMAC key v1".getBytes(StandardCharsets.UTF_8); + + private final ClusterMessageAuthConfig config; + private final Supplier fallbackSecretSupplier; + private final Clock clock; + private final Supplier nonceSupplier; + private final SecureRandom secureRandom = new SecureRandom(); + private final Cache acceptedSignatures; + + private volatile KeyRing keyRing; + + ClusterMessageAuthenticator( + ClusterMessageAuthConfig config, + Supplier fallbackSecretSupplier) { + SecureRandom messageRandom = new SecureRandom(); + this.config = config; + this.fallbackSecretSupplier = fallbackSecretSupplier; + this.clock = Clock.systemUTC(); + this.nonceSupplier = () -> { + byte[] nonce = new byte[MESSAGE_NONCE_BYTES]; + messageRandom.nextBytes(nonce); + return ByteString.copyFrom(nonce); + }; + this.acceptedSignatures = newReplayCache(); + } + + ClusterMessageAuthenticator( + ClusterMessageAuthConfig config, + Supplier fallbackSecretSupplier, + Clock clock, + Supplier nonceSupplier) { + this.config = config; + this.fallbackSecretSupplier = fallbackSecretSupplier; + this.clock = clock; + this.nonceSupplier = nonceSupplier; + this.acceptedSignatures = newReplayCache(); + } + + void validateConfiguration() { + getKeyRing(); + } + + ClusterMessageAuthConfig.Mode mode() { + return config.getMode(); + } + + long handshakeTimeoutMillis() { + return config.getHandshakeTimeout().toMillis(); + } + + ByteString newChannelNonce() { + byte[] nonce = new byte[CHANNEL_NONCE_BYTES]; + secureRandom.nextBytes(nonce); + return ByteString.copyFrom(nonce); + } + + ClusterMsg.Message sign(ClusterMsg.Message message, ByteString channelNonce) { + KeyRing keys = getKeyRing(); + ByteString safeChannelNonce = channelNonce == null ? ByteString.EMPTY : channelNonce; + if (config.getMode() == ClusterMessageAuthConfig.Mode.REQUIRED + && safeChannelNonce.size() != CHANNEL_NONCE_BYTES) { + throw new IllegalStateException( + "Required cluster message authentication needs a server channel challenge"); + } + ClusterMsg.Message unsigned = message.toBuilder() + .setAuthVersion(AUTH_VERSION) + .setAuthKeyId(keys.activeKeyId()) + .setAuthTimestamp(clock.millis()) + .setAuthNonce(nonceSupplier.get()) + .setAuthChannelNonce(safeChannelNonce) + .clearAuthSignature() + .build(); + return unsigned.toBuilder() + .setAuthSignature(ByteString.copyFrom(calculateSignature(unsigned, keys.activeKey()))) + .build(); + } + + VerificationResult verify(ClusterMsg.Message message, ByteString expectedChannelNonce) { + KeyRing keys = getKeyRing(); + if (isLegacyUnsigned(message)) { + return config.getMode() == ClusterMessageAuthConfig.Mode.OPTIONAL + ? VerificationResult.LEGACY_UNSIGNED + : VerificationResult.UNSIGNED; + } + if (message.getAuthVersion() != AUTH_VERSION) { + return VerificationResult.UNSUPPORTED_VERSION; + } + if (message.getAuthTimestamp() <= 0 + || message.getAuthNonce().size() != MESSAGE_NONCE_BYTES + || message.getAuthSignature().size() != SIGNATURE_BYTES) { + return VerificationResult.MALFORMED; + } + ByteString safeExpectedChannelNonce = + expectedChannelNonce == null ? ByteString.EMPTY : expectedChannelNonce; + if (!MessageDigest.isEqual( + safeExpectedChannelNonce.toByteArray(), + message.getAuthChannelNonce().toByteArray())) { + return VerificationResult.CHANNEL_MISMATCH; + } + long maxSkewMillis = config.getMaxClockSkew().toMillis(); + long timestampDelta; + try { + timestampDelta = Math.subtractExact(clock.millis(), message.getAuthTimestamp()); + } catch (ArithmeticException e) { + return VerificationResult.STALE; + } + if (timestampDelta > maxSkewMillis || timestampDelta < -maxSkewMillis) { + return VerificationResult.STALE; + } + byte[] signingKey = keys.keyFor(message.getAuthKeyId()); + if (signingKey == null) { + return VerificationResult.UNKNOWN_KEY; + } + ClusterMsg.Message unsigned = message.toBuilder().clearAuthSignature().build(); + byte[] expected = calculateSignature(unsigned, signingKey); + if (!MessageDigest.isEqual(expected, message.getAuthSignature().toByteArray())) { + return VerificationResult.INVALID_SIGNATURE; + } + if (acceptedSignatures.asMap() + .putIfAbsent(message.getAuthSignature(), Boolean.TRUE) != null) { + return VerificationResult.REPLAY; + } + return VerificationResult.VERIFIED; + } + + private boolean isLegacyUnsigned(ClusterMsg.Message message) { + return message.getAuthVersion() == 0 + && message.getAuthTimestamp() == 0 + && message.getAuthNonce().isEmpty() + && message.getAuthSignature().isEmpty() + && message.getAuthKeyId().isEmpty() + && message.getAuthChannelNonce().isEmpty(); + } + + private KeyRing getKeyRing() { + KeyRing local = keyRing; + if (local == null) { + synchronized (this) { + local = keyRing; + if (local == null) { + ClusterMessageAuthConfig.ResolvedSecrets resolved = + config.resolve(fallbackSecretSupplier); + local = new KeyRing( + resolved.activeKeyId(), + deriveSigningKey(resolved.activeSecret()), + resolved.previousKeyId(), + resolved.previousSecret() == null + ? null + : deriveSigningKey(resolved.previousSecret())); + keyRing = local; + } + } + } + return local; + } + + private byte[] calculateSignature(ClusterMsg.Message message, byte[] signingKey) { + try { + Mac mac = Mac.getInstance(HMAC_ALGORITHM); + mac.init(new SecretKeySpec(signingKey, HMAC_ALGORITHM)); + return mac.doFinal(message.toByteArray()); + } catch (GeneralSecurityException e) { + throw new IllegalStateException("Unable to authenticate cluster message", e); + } + } + + private byte[] deriveSigningKey(String rootSecret) { + try { + Mac extract = Mac.getInstance(HMAC_ALGORITHM); + extract.init(new SecretKeySpec(HKDF_SALT, HMAC_ALGORITHM)); + byte[] pseudoRandomKey = extract.doFinal(rootSecret.getBytes(StandardCharsets.UTF_8)); + + Mac expand = Mac.getInstance(HMAC_ALGORITHM); + expand.init(new SecretKeySpec(pseudoRandomKey, HMAC_ALGORITHM)); + expand.update(HKDF_INFO); + expand.update((byte) 1); + return expand.doFinal(); + } catch (GeneralSecurityException e) { + throw new IllegalStateException("Unable to derive cluster message authentication key", e); + } + } + + private Cache newReplayCache() { + Duration maxClockSkew = config.getMaxClockSkew(); + Duration replayWindow = maxClockSkew == null + || maxClockSkew.isZero() + || maxClockSkew.isNegative() + || maxClockSkew.compareTo(MAX_CONFIGURED_CLOCK_SKEW) > 0 + ? DEFAULT_REPLAY_WINDOW + : maxClockSkew.multipliedBy(2); + return Caffeine.newBuilder() + .maximumSize(MAX_REPLAY_ENTRIES) + .expireAfterWrite(replayWindow) + .build(); + } + + enum VerificationResult { + VERIFIED(true), + LEGACY_UNSIGNED(true), + UNSIGNED(false), + UNSUPPORTED_VERSION(false), + MALFORMED(false), + CHANNEL_MISMATCH(false), + STALE(false), + UNKNOWN_KEY(false), + INVALID_SIGNATURE(false), + REPLAY(false); + + private final boolean accepted; + + VerificationResult(boolean accepted) { + this.accepted = accepted; + } + + boolean accepted() { + return accepted; + } + } + + private record KeyRing( + String activeKeyId, + byte[] activeKey, + String previousKeyId, + byte[] previousKey) { + + byte[] keyFor(String keyId) { + if (activeKeyId.equals(keyId)) { + return activeKey; + } + if (previousKeyId != null && previousKeyId.equals(keyId)) { + return previousKey; + } + return null; + } + } +} diff --git a/hertzbeat-remoting/src/main/java/org/apache/hertzbeat/remoting/netty/NettyRemotingAbstract.java b/hertzbeat-remoting/src/main/java/org/apache/hertzbeat/remoting/netty/NettyRemotingAbstract.java index 3d9390f467d..3c2732885a4 100644 --- a/hertzbeat-remoting/src/main/java/org/apache/hertzbeat/remoting/netty/NettyRemotingAbstract.java +++ b/hertzbeat-remoting/src/main/java/org/apache/hertzbeat/remoting/netty/NettyRemotingAbstract.java @@ -17,14 +17,20 @@ package org.apache.hertzbeat.remoting.netty; +import com.google.protobuf.ByteString; import io.netty.channel.Channel; import io.netty.channel.ChannelHandlerContext; import io.netty.channel.epoll.Epoll; import io.netty.handler.timeout.IdleState; import io.netty.handler.timeout.IdleStateEvent; +import io.netty.util.Attribute; +import io.netty.util.AttributeKey; import java.util.ArrayList; import java.util.List; +import java.util.Objects; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; import lombok.extern.slf4j.Slf4j; import org.apache.commons.collections4.CollectionUtils; import org.apache.hertzbeat.common.entity.message.ClusterMsg; @@ -33,13 +39,20 @@ import org.apache.hertzbeat.remoting.event.NettyEventListener; /** - * Derived from Apache Rocketmq org.apache.rocketmq.remoting.netty.NettyRemotingAbstract + * Derived from Apache Rocketmq org.apache.rocketmq.remoting.netty.NettyRemotingAbstract * netty remote abstract * @see NettyRemotingAbstract */ @Slf4j public abstract class NettyRemotingAbstract implements RemotingService { - protected ConcurrentHashMap processorTable = new ConcurrentHashMap<>(); + + private static final AttributeKey AUTH_CHANNEL_NONCE = + AttributeKey.valueOf(NettyRemotingAbstract.class, "authChannelNonce"); + private static final AttributeKey APPLICATION_ACTIVE_NOTIFIED = + AttributeKey.valueOf(NettyRemotingAbstract.class, "applicationActiveNotified"); + + protected ConcurrentHashMap processorTable = + new ConcurrentHashMap<>(); protected ConcurrentHashMap responseTable = new ConcurrentHashMap<>(); @@ -47,15 +60,59 @@ public abstract class NettyRemotingAbstract implements RemotingService { protected NettyEventListener nettyEventListener; + private final EndpointRole endpointRole; + private final ClusterMessageAuthenticator messageAuthenticator; + protected NettyRemotingAbstract(NettyEventListener nettyEventListener) { + this(nettyEventListener, EndpointRole.DISABLED, null, null); + } + + protected NettyRemotingAbstract( + NettyEventListener nettyEventListener, + EndpointRole endpointRole, + ClusterMessageAuthConfig authConfig, + Supplier fallbackSecretSupplier) { this.nettyEventListener = nettyEventListener; + this.endpointRole = endpointRole; + this.messageAuthenticator = endpointRole == EndpointRole.DISABLED + ? null + : new ClusterMessageAuthenticator( + Objects.requireNonNull( + authConfig, + "Cluster message authentication settings must be configured"), + fallbackSecretSupplier); + } + + protected final void initializeAuthentication() { + if (messageAuthenticator != null) { + messageAuthenticator.validateConfiguration(); + } } - public void registerProcessor(final ClusterMsg.MessageType messageType, final NettyRemotingProcessor processor) { + public void registerProcessor( + final ClusterMsg.MessageType messageType, + final NettyRemotingProcessor processor) { this.processorTable.put(messageType, processor); } protected void processReceiveMsg(ChannelHandlerContext ctx, ClusterMsg.Message message) { + if (message.getType() == ClusterMsg.MessageType.AUTH_CHALLENGE) { + processAuthenticationChallenge(ctx, message); + return; + } + if (messageAuthenticator != null) { + ClusterMessageAuthenticator.VerificationResult result = + messageAuthenticator.verify(message, channelNonce(ctx.channel())); + ClusterMessageAuthMetrics.recordVerification(result, endpointRole); + if (!result.accepted()) { + log.warn( + "Reject cluster message authentication from {}, reason: {}", + ctx.channel().remoteAddress(), + result); + ctx.close(); + return; + } + } if (ClusterMsg.Direction.REQUEST.equals(message.getDirection())) { this.processRequestMsg(ctx, message); } else { @@ -73,7 +130,7 @@ protected void processRequestMsg(ChannelHandlerContext ctx, ClusterMsg.Message r } ClusterMsg.Message response = processor.handle(ctx, request); if (response != null) { - ctx.writeAndFlush(response); + ctx.writeAndFlush(prepareOutbound(ctx.channel(), response)); } } @@ -87,40 +144,48 @@ private void doBeforeRequest(ChannelHandlerContext ctx, ClusterMsg.Message reque } protected void processResponseMsg(ChannelHandlerContext ctx, ClusterMsg.Message response) { - // for sync response if (this.responseTable.containsKey(response.getIdentity())) { ResponseFuture responseFuture = this.responseTable.get(response.getIdentity()); responseFuture.putResponse(response); } else { - // async response NettyRemotingProcessor processor = this.processorTable.get(response.getType()); if (processor != null) { ClusterMsg.Message repMessage = processor.handle(ctx, response); if (repMessage != null) { - ctx.writeAndFlush(repMessage); + ctx.writeAndFlush(prepareOutbound(ctx.channel(), repMessage)); } - } + } } } protected void sendMsgImpl(final Channel channel, final ClusterMsg.Message request) { - channel.writeAndFlush(request).addListener(future -> { + channel.writeAndFlush(prepareOutbound(channel, request)).addListener(future -> { if (!future.isSuccess()) { - log.warn("send request message failed. address: {}, ", channel.remoteAddress(), future.cause()); + log.warn( + "send request message failed. address: {}, ", + channel.remoteAddress(), + future.cause()); } }); } - protected ClusterMsg.Message sendMsgSyncImpl(final Channel channel, final ClusterMsg.Message request, final int timeoutMillis) { + protected ClusterMsg.Message sendMsgSyncImpl( + final Channel channel, + final ClusterMsg.Message request, + final int timeoutMillis) { final String identity = request.getIdentity(); try { ResponseFuture responseFuture = new ResponseFuture(); this.responseTable.put(identity, responseFuture); - channel.writeAndFlush(request).addListener(future -> { + channel.writeAndFlush(prepareOutbound(channel, request)).addListener(future -> { if (!future.isSuccess()) { responseTable.remove(identity); - log.warn("send request message failed. request: {}, address: {}, ", request, channel.remoteAddress(), future.cause()); + log.warn( + "send request message failed. request: {}, address: {}, ", + request, + channel.remoteAddress(), + future.cause()); } }); ClusterMsg.Message response = responseFuture.waitResponse(timeoutMillis); @@ -130,15 +195,121 @@ protected ClusterMsg.Message sendMsgSyncImpl(final Channel channel, final Cluste return response; } catch (InterruptedException e) { log.warn("get response message failed, ", e); + Thread.currentThread().interrupt(); } finally { responseTable.remove(identity); } return null; } - protected void channelActive(ChannelHandlerContext ctx) throws Exception { - if (this.nettyEventListener != null && ctx.channel().isActive()) { - this.nettyEventListener.onChannelActive(ctx.channel()); + private ClusterMsg.Message prepareOutbound(Channel channel, ClusterMsg.Message message) { + return messageAuthenticator == null + ? message + : messageAuthenticator.sign(message, channelNonce(channel)); + } + + protected void channelActive(ChannelHandlerContext ctx) { + if (messageAuthenticator == null || endpointRole == EndpointRole.DISABLED) { + notifyApplicationChannelActive(ctx.channel()); + return; + } + if (endpointRole == EndpointRole.SERVER) { + ByteString channelNonce = messageAuthenticator.newChannelNonce(); + setChannelNonce(ctx.channel(), channelNonce); + ClusterMsg.Message challenge = ClusterMsg.Message.newBuilder() + .setDirection(ClusterMsg.Direction.REQUEST) + .setType(ClusterMsg.MessageType.AUTH_CHALLENGE) + .setAuthVersion(ClusterMessageAuthenticator.AUTH_VERSION) + .setMsg(channelNonce) + .build(); + ctx.writeAndFlush(challenge); + notifyApplicationChannelActive(ctx.channel()); + return; + } + scheduleClientHandshakeTimeout(ctx); + } + + private void processAuthenticationChallenge( + ChannelHandlerContext ctx, + ClusterMsg.Message challenge) { + if (messageAuthenticator == null || endpointRole == EndpointRole.DISABLED) { + log.debug("Ignore authentication capability challenge in legacy compatibility mode"); + return; + } + if (endpointRole != EndpointRole.CLIENT + || challenge.getDirection() != ClusterMsg.Direction.REQUEST + || challenge.getAuthVersion() != ClusterMessageAuthenticator.AUTH_VERSION + || challenge.getMsg().size() != ClusterMessageAuthenticator.CHANNEL_NONCE_BYTES + || challenge.getAuthTimestamp() != 0 + || !challenge.getAuthNonce().isEmpty() + || !challenge.getAuthSignature().isEmpty() + || !challenge.getAuthKeyId().isEmpty() + || !challenge.getAuthChannelNonce().isEmpty()) { + ClusterMessageAuthMetrics.recordVerification( + ClusterMessageAuthenticator.VerificationResult.MALFORMED, + endpointRole); + log.warn("Reject malformed cluster authentication challenge from {}", + ctx.channel().remoteAddress()); + ctx.close(); + return; + } + setChannelNonce(ctx.channel(), challenge.getMsg()); + notifyApplicationChannelActive(ctx.channel()); + } + + private void scheduleClientHandshakeTimeout(ChannelHandlerContext ctx) { + long timeoutMillis = messageAuthenticator == null + ? 0 + : messageAuthenticator.handshakeTimeoutMillis(); + ctx.executor().schedule(() -> { + if (applicationActiveWasNotified(ctx.channel())) { + return; + } + ClusterMessageAuthMetrics.recordHandshakeTimeout( + endpointRole, + messageAuthenticator.mode()); + if (messageAuthenticator.mode() == ClusterMessageAuthConfig.Mode.OPTIONAL) { + log.warn( + "Cluster peer did not advertise authentication; continuing in optional compatibility mode"); + notifyApplicationChannelActive(ctx.channel()); + } else { + log.warn("Cluster peer did not advertise required authentication; closing channel"); + ctx.close(); + } + }, timeoutMillis, TimeUnit.MILLISECONDS); + } + + private void notifyApplicationChannelActive(Channel channel) { + Attribute notified = channel.attr(APPLICATION_ACTIVE_NOTIFIED); + if (notified != null && !notified.compareAndSet(null, Boolean.TRUE)) { + return; + } + if (this.nettyEventListener != null && channel.isActive()) { + try { + this.nettyEventListener.onChannelActive(channel); + } catch (Exception e) { + log.error("Cluster channel activation callback failed", e); + channel.close(); + } + } + } + + private boolean applicationActiveWasNotified(Channel channel) { + Attribute attribute = channel.attr(APPLICATION_ACTIVE_NOTIFIED); + return attribute != null && Boolean.TRUE.equals(attribute.get()); + } + + private ByteString channelNonce(Channel channel) { + Attribute attribute = channel.attr(AUTH_CHANNEL_NONCE); + return attribute == null || attribute.get() == null + ? ByteString.EMPTY + : attribute.get(); + } + + private void setChannelNonce(Channel channel, ByteString channelNonce) { + Attribute attribute = channel.attr(AUTH_CHANNEL_NONCE); + if (attribute != null) { + attribute.set(channelNonce); } } @@ -151,8 +322,22 @@ protected void channelIdle(ChannelHandlerContext ctx, Object evt) throws Excepti } protected boolean useEpoll() { - return NetworkUtil.isLinuxPlatform() - && Epoll.isAvailable(); + return NetworkUtil.isLinuxPlatform() && Epoll.isAvailable(); } + enum EndpointRole { + DISABLED("disabled"), + CLIENT("client"), + SERVER("server"); + + private final String metricTag; + + EndpointRole(String metricTag) { + this.metricTag = metricTag; + } + + String metricTag() { + return metricTag; + } + } } diff --git a/hertzbeat-remoting/src/main/java/org/apache/hertzbeat/remoting/netty/NettyRemotingClient.java b/hertzbeat-remoting/src/main/java/org/apache/hertzbeat/remoting/netty/NettyRemotingClient.java index 2001fd912fd..cba0c68ac70 100644 --- a/hertzbeat-remoting/src/main/java/org/apache/hertzbeat/remoting/netty/NettyRemotingClient.java +++ b/hertzbeat-remoting/src/main/java/org/apache/hertzbeat/remoting/netty/NettyRemotingClient.java @@ -36,6 +36,7 @@ import io.netty.handler.codec.protobuf.ProtobufVarint32FrameDecoder; import io.netty.handler.codec.protobuf.ProtobufVarint32LengthFieldPrepender; import java.util.concurrent.ThreadFactory; +import java.util.function.Supplier; import org.apache.hertzbeat.common.concurrent.BackgroundTaskExecutor; import lombok.extern.slf4j.Slf4j; import org.apache.hertzbeat.common.entity.message.ClusterMsg; @@ -70,8 +71,19 @@ public NettyRemotingClient(final NettyClientConfig nettyClientConfig, this.threadPool = threadPool; } + public NettyRemotingClient(final NettyClientConfig nettyClientConfig, + final NettyEventListener nettyEventListener, + final BackgroundTaskExecutor threadPool, + final ClusterMessageAuthConfig authConfig, + final Supplier fallbackSecretSupplier) { + super(nettyEventListener, EndpointRole.CLIENT, authConfig, fallbackSecretSupplier); + this.nettyClientConfig = nettyClientConfig; + this.threadPool = threadPool; + } + @Override public void start() { + initializeAuthentication(); this.threadPool.executeLongRunning(() -> { ThreadFactory threadFactory = new ThreadFactoryBuilder() .setUncaughtExceptionHandler((thread, throwable) -> { diff --git a/hertzbeat-remoting/src/main/java/org/apache/hertzbeat/remoting/netty/NettyRemotingServer.java b/hertzbeat-remoting/src/main/java/org/apache/hertzbeat/remoting/netty/NettyRemotingServer.java index f6e743bda5e..8a82e3e4fa9 100644 --- a/hertzbeat-remoting/src/main/java/org/apache/hertzbeat/remoting/netty/NettyRemotingServer.java +++ b/hertzbeat-remoting/src/main/java/org/apache/hertzbeat/remoting/netty/NettyRemotingServer.java @@ -43,6 +43,7 @@ import io.netty.handler.timeout.IdleStateHandler; import java.util.List; import java.util.concurrent.ThreadFactory; +import java.util.function.Supplier; import org.apache.hertzbeat.common.concurrent.BackgroundTaskExecutor; import lombok.extern.slf4j.Slf4j; import org.apache.hertzbeat.common.entity.message.ClusterMsg; @@ -75,8 +76,19 @@ public NettyRemotingServer(final NettyServerConfig nettyServerConfig, this.threadPool = threadPool; } + public NettyRemotingServer(final NettyServerConfig nettyServerConfig, + final NettyEventListener nettyEventListener, + final BackgroundTaskExecutor threadPool, + final ClusterMessageAuthConfig authConfig, + final Supplier fallbackSecretSupplier) { + super(nettyEventListener, EndpointRole.SERVER, authConfig, fallbackSecretSupplier); + this.nettyServerConfig = nettyServerConfig; + this.threadPool = threadPool; + } + @Override public void start() { + initializeAuthentication(); this.threadPool.executeLongRunning(() -> { int port = this.nettyServerConfig.getPort(); ThreadFactory bossThreadFactory = new ThreadFactoryBuilder() diff --git a/hertzbeat-remoting/src/test/java/org/apache/hertzbeat/remoting/RemotingServiceTest.java b/hertzbeat-remoting/src/test/java/org/apache/hertzbeat/remoting/RemotingServiceTest.java index 5d98432572e..318209c3b1d 100644 --- a/hertzbeat-remoting/src/test/java/org/apache/hertzbeat/remoting/RemotingServiceTest.java +++ b/hertzbeat-remoting/src/test/java/org/apache/hertzbeat/remoting/RemotingServiceTest.java @@ -23,6 +23,7 @@ import org.apache.hertzbeat.common.entity.message.ClusterMsg; import org.apache.hertzbeat.common.concurrent.BackgroundTaskExecutor; import org.apache.hertzbeat.remoting.netty.NettyClientConfig; +import org.apache.hertzbeat.remoting.netty.ClusterMessageAuthConfig; import org.apache.hertzbeat.remoting.netty.NettyRemotingClient; import org.apache.hertzbeat.remoting.netty.NettyRemotingServer; import org.apache.hertzbeat.remoting.netty.NettyServerConfig; @@ -37,6 +38,8 @@ */ public class RemotingServiceTest { + private static final String SHARED_SECRET = "cluster-authentication-secret-32!!"; + private final BackgroundTaskExecutor threadPool = new BackgroundTaskExecutor() { private final ExecutorService executor = Executors.newCachedThreadPool(); @@ -64,7 +67,12 @@ public RemotingServer createRemotingServer(int port) { NettyServerConfig nettyServerConfig = new NettyServerConfig(); nettyServerConfig.setPort(port); // todo test NettyEventListener - RemotingServer server = new NettyRemotingServer(nettyServerConfig, null, threadPool); + RemotingServer server = new NettyRemotingServer( + nettyServerConfig, + null, + threadPool, + authConfig(), + () -> SHARED_SECRET); server.start(); return server; } @@ -73,7 +81,12 @@ public RemotingClient createRemotingClient(int port) { NettyClientConfig nettyClientConfig = new NettyClientConfig(); nettyClientConfig.setServerHost("localhost"); nettyClientConfig.setServerPort(port); - RemotingClient client = new NettyRemotingClient(nettyClientConfig, null, threadPool); + RemotingClient client = new NettyRemotingClient( + nettyClientConfig, + null, + threadPool, + authConfig(), + () -> SHARED_SECRET); client.start(); return client; } @@ -109,6 +122,7 @@ public void setUp() throws InterruptedException { if (count < 0) { throw new RuntimeException("remoting client start error"); } + Thread.sleep(100); } @AfterEach @@ -175,4 +189,11 @@ public void testNettyHook() { this.remotingClient.sendMsg(request); } + private ClusterMessageAuthConfig authConfig() { + ClusterMessageAuthConfig config = new ClusterMessageAuthConfig(); + config.setMode(ClusterMessageAuthConfig.Mode.REQUIRED); + config.setActiveKeyId("active"); + return config; + } + } diff --git a/hertzbeat-remoting/src/test/java/org/apache/hertzbeat/remoting/netty/ClusterMessageAuthenticatorTest.java b/hertzbeat-remoting/src/test/java/org/apache/hertzbeat/remoting/netty/ClusterMessageAuthenticatorTest.java new file mode 100644 index 00000000000..d760a847477 --- /dev/null +++ b/hertzbeat-remoting/src/test/java/org/apache/hertzbeat/remoting/netty/ClusterMessageAuthenticatorTest.java @@ -0,0 +1,192 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hertzbeat.remoting.netty; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.google.protobuf.ByteString; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.concurrent.atomic.AtomicInteger; +import org.apache.hertzbeat.common.entity.message.ClusterMsg; +import org.junit.jupiter.api.Test; + +class ClusterMessageAuthenticatorTest { + + private static final String ACTIVE_SECRET = "active-cluster-secret-32-bytes!!"; + private static final String PREVIOUS_SECRET = "previous-cluster-secret-32-bytes"; + private static final String LEGACY_AES_SECRET = "legacy-root-1234"; + private static final Instant NOW = Instant.parse("2026-07-30T08:00:00Z"); + private static final ByteString CHANNEL_NONCE = + ByteString.copyFromUtf8("server-channel-nonce-32-bytes!!!"); + + @Test + void shouldRequireAuthenticationByDefault() { + assertEquals( + ClusterMessageAuthConfig.Mode.REQUIRED, + new ClusterMessageAuthConfig().getMode()); + } + + @Test + void shouldSupportOptionalThenRequiredRollout() { + ClusterMsg.Message unsigned = message(); + + assertEquals( + ClusterMessageAuthenticator.VerificationResult.LEGACY_UNSIGNED, + authenticator(config(ClusterMessageAuthConfig.Mode.OPTIONAL), NOW, 1).verify(unsigned, CHANNEL_NONCE)); + assertEquals( + ClusterMessageAuthenticator.VerificationResult.UNSIGNED, + authenticator(config(ClusterMessageAuthConfig.Mode.REQUIRED), NOW, 1).verify(unsigned, CHANNEL_NONCE)); + } + + @Test + void shouldSignVersionedEnvelopeBoundToChannelAndRejectReplay() { + ClusterMessageAuthenticator sender = + authenticator(config(ClusterMessageAuthConfig.Mode.REQUIRED), NOW, 1); + ClusterMessageAuthenticator receiver = + authenticator(config(ClusterMessageAuthConfig.Mode.REQUIRED), NOW, 20); + + ClusterMsg.Message signed = sender.sign(message(), CHANNEL_NONCE); + + assertEquals(1, signed.getAuthVersion()); + assertEquals("active", signed.getAuthKeyId()); + assertEquals(CHANNEL_NONCE, signed.getAuthChannelNonce()); + assertEquals( + ClusterMessageAuthenticator.VerificationResult.VERIFIED, + receiver.verify(signed, CHANNEL_NONCE)); + assertEquals( + ClusterMessageAuthenticator.VerificationResult.REPLAY, + receiver.verify(signed, CHANNEL_NONCE)); + assertEquals( + ClusterMessageAuthenticator.VerificationResult.CHANNEL_MISMATCH, + authenticator(config(ClusterMessageAuthConfig.Mode.REQUIRED), NOW, 30) + .verify(signed, ByteString.copyFromUtf8("different-channel-nonce-32-byte"))); + } + + @Test + void shouldAcceptPreviousKeyWhileAlwaysSigningWithActiveKey() { + ClusterMessageAuthConfig senderConfig = config(ClusterMessageAuthConfig.Mode.REQUIRED); + senderConfig.setActiveKeyId("previous"); + senderConfig.setActiveSecret(PREVIOUS_SECRET); + ClusterMessageAuthConfig receiverConfig = config(ClusterMessageAuthConfig.Mode.REQUIRED); + receiverConfig.setPreviousKeyId("previous"); + receiverConfig.setPreviousSecret(PREVIOUS_SECRET); + ClusterMessageAuthenticator sender = authenticator(senderConfig, NOW, 1); + ClusterMessageAuthenticator receiver = authenticator(receiverConfig, NOW, 20); + + ClusterMsg.Message previousKeyMessage = sender.sign(message(), CHANNEL_NONCE); + ClusterMsg.Message activeKeyMessage = receiver.sign(message(), CHANNEL_NONCE); + + assertEquals( + ClusterMessageAuthenticator.VerificationResult.VERIFIED, + receiver.verify(previousKeyMessage, CHANNEL_NONCE)); + assertEquals("active", activeKeyMessage.getAuthKeyId()); + } + + @Test + void shouldRotateFromExistingSixteenByteRootSecret() { + ClusterMessageAuthConfig legacyConfig = config(ClusterMessageAuthConfig.Mode.REQUIRED); + legacyConfig.setActiveKeyId("legacy"); + legacyConfig.setActiveSecret(null); + ClusterMessageAuthenticator legacySender = new ClusterMessageAuthenticator( + legacyConfig, + () -> LEGACY_AES_SECRET, + Clock.fixed(NOW, ZoneOffset.UTC), + () -> ByteString.copyFromUtf8("0000000000000001")); + ClusterMessageAuthConfig rotatedConfig = config(ClusterMessageAuthConfig.Mode.REQUIRED); + rotatedConfig.setPreviousKeyId("legacy"); + rotatedConfig.setPreviousSecret(LEGACY_AES_SECRET); + + assertEquals( + ClusterMessageAuthenticator.VerificationResult.VERIFIED, + authenticator(rotatedConfig, NOW, 20) + .verify(legacySender.sign(message(), CHANNEL_NONCE), CHANNEL_NONCE)); + } + + @Test + void shouldReportTamperUnknownKeyUnsupportedVersionAndConfiguredClockSkew() { + ClusterMessageAuthConfig config = config(ClusterMessageAuthConfig.Mode.REQUIRED); + config.setMaxClockSkew(Duration.ofSeconds(30)); + ClusterMsg.Message signed = authenticator(config, NOW, 1).sign(message(), CHANNEL_NONCE); + + assertEquals( + ClusterMessageAuthenticator.VerificationResult.INVALID_SIGNATURE, + authenticator(config, NOW, 20) + .verify(signed.toBuilder().setIdentity("collector-2").build(), CHANNEL_NONCE)); + assertEquals( + ClusterMessageAuthenticator.VerificationResult.UNKNOWN_KEY, + authenticator(config, NOW, 20) + .verify(signed.toBuilder().setAuthKeyId("unknown").build(), CHANNEL_NONCE)); + assertEquals( + ClusterMessageAuthenticator.VerificationResult.UNSUPPORTED_VERSION, + authenticator(config, NOW, 20) + .verify(signed.toBuilder().setAuthVersion(2).build(), CHANNEL_NONCE)); + assertEquals( + ClusterMessageAuthenticator.VerificationResult.STALE, + authenticator(config, NOW.plusSeconds(31), 20).verify(signed, CHANNEL_NONCE)); + assertEquals( + ClusterMessageAuthenticator.VerificationResult.STALE, + authenticator(config, Instant.ofEpochMilli(Long.MIN_VALUE), 20) + .verify(signed.toBuilder().setAuthTimestamp(Long.MAX_VALUE).build(), CHANNEL_NONCE)); + } + + @Test + void shouldFailFastForMissingDefaultAndWeakSecrets() { + ClusterMessageAuthConfig missing = config(ClusterMessageAuthConfig.Mode.REQUIRED); + missing.setActiveSecret(null); + assertThrows(IllegalStateException.class, () -> missing.validate(() -> null)); + + ClusterMessageAuthConfig defaultSecret = config(ClusterMessageAuthConfig.Mode.REQUIRED); + defaultSecret.setActiveSecret("tomSun28HaHaHaHa"); + assertThrows(IllegalStateException.class, () -> defaultSecret.validate(() -> null)); + + ClusterMessageAuthConfig weak = config(ClusterMessageAuthConfig.Mode.REQUIRED); + weak.setActiveSecret("too-short"); + assertThrows(IllegalStateException.class, () -> weak.validate(() -> null)); + } + + private ClusterMessageAuthenticator authenticator( + ClusterMessageAuthConfig config, Instant now, int nonceSeed) { + AtomicInteger nonce = new AtomicInteger(nonceSeed); + return new ClusterMessageAuthenticator( + config, + () -> null, + Clock.fixed(now, ZoneOffset.UTC), + () -> ByteString.copyFromUtf8("%016d".formatted(nonce.getAndIncrement()))); + } + + private ClusterMessageAuthConfig config(ClusterMessageAuthConfig.Mode mode) { + ClusterMessageAuthConfig config = new ClusterMessageAuthConfig(); + config.setMode(mode); + config.setActiveKeyId("active"); + config.setActiveSecret(ACTIVE_SECRET); + return config; + } + + private ClusterMsg.Message message() { + return ClusterMsg.Message.newBuilder() + .setIdentity("collector-1") + .setDirection(ClusterMsg.Direction.REQUEST) + .setType(ClusterMsg.MessageType.RESPONSE_CYCLIC_TASK_DATA) + .setMsg(ByteString.copyFromUtf8("metrics")) + .build(); + } +} diff --git a/hertzbeat-remoting/src/test/java/org/apache/hertzbeat/remoting/netty/NettyRemotingAuthenticationTest.java b/hertzbeat-remoting/src/test/java/org/apache/hertzbeat/remoting/netty/NettyRemotingAuthenticationTest.java new file mode 100644 index 00000000000..dba2566f704 --- /dev/null +++ b/hertzbeat-remoting/src/test/java/org/apache/hertzbeat/remoting/netty/NettyRemotingAuthenticationTest.java @@ -0,0 +1,166 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hertzbeat.remoting.netty; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.netty.channel.Channel; +import io.netty.channel.ChannelHandlerContext; +import io.micrometer.core.instrument.Metrics; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import java.net.InetSocketAddress; +import java.util.concurrent.atomic.AtomicBoolean; +import org.apache.hertzbeat.common.entity.message.ClusterMsg; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +class NettyRemotingAuthenticationTest { + + private static final String SHARED_SECRET = "cluster-authentication-secret-32!!"; + + @Test + void shouldRejectMissingAuthenticationSettingsForClusterEndpoint() { + assertThrows( + NullPointerException.class, + () -> new TestRemoting(null)); + } + + @Test + void shouldCloseChannelBeforeDispatchingUnsignedMessage() { + TestRemoting remoting = new TestRemoting(ClusterMessageAuthConfig.Mode.REQUIRED); + AtomicBoolean processed = new AtomicBoolean(); + remoting.registerProcessor(ClusterMsg.MessageType.HEARTBEAT, (ctx, message) -> { + processed.set(true); + return null; + }); + ChannelHandlerContext context = context(); + SimpleMeterRegistry meterRegistry = new SimpleMeterRegistry(); + Metrics.addRegistry(meterRegistry); + + try { + remoting.receive(context, request()); + + verify(context).close(); + verify(context, never()).writeAndFlush(any()); + assertFalse(processed.get()); + assertEquals( + 1.0, + meterRegistry.get("hertzbeat.cluster.message.authentication.rejected") + .tags("endpoint", "server", "reason", "unsigned") + .counter() + .count()); + } finally { + Metrics.removeRegistry(meterRegistry); + meterRegistry.close(); + } + } + + @Test + void shouldDispatchUnsignedMessageInOptionalRolloutMode() { + TestRemoting remoting = new TestRemoting(ClusterMessageAuthConfig.Mode.OPTIONAL); + AtomicBoolean processed = new AtomicBoolean(); + remoting.registerProcessor(ClusterMsg.MessageType.HEARTBEAT, (ctx, message) -> { + processed.set(true); + return null; + }); + ChannelHandlerContext context = context(); + + remoting.receive(context, request()); + + assertTrue(processed.get()); + verify(context, never()).close(); + } + + @Test + void shouldVerifyInboundAndSignProcessorResponse() { + TestRemoting remoting = new TestRemoting(ClusterMessageAuthConfig.Mode.OPTIONAL); + remoting.registerProcessor(ClusterMsg.MessageType.HEARTBEAT, (ctx, message) -> + ClusterMsg.Message.newBuilder() + .setDirection(ClusterMsg.Direction.RESPONSE) + .setType(ClusterMsg.MessageType.HEARTBEAT) + .build()); + ClusterMessageAuthenticator sender = + new ClusterMessageAuthenticator(config(ClusterMessageAuthConfig.Mode.OPTIONAL), () -> null); + ClusterMessageAuthenticator receiver = + new ClusterMessageAuthenticator(config(ClusterMessageAuthConfig.Mode.OPTIONAL), () -> null); + ChannelHandlerContext context = context(); + + remoting.receive(context, sender.sign(request(), com.google.protobuf.ByteString.EMPTY)); + + ArgumentCaptor response = ArgumentCaptor.forClass(ClusterMsg.Message.class); + verify(context).writeAndFlush(response.capture()); + assertTrue(receiver.verify(response.getValue(), com.google.protobuf.ByteString.EMPTY).accepted()); + verify(context, never()).close(); + } + + private ChannelHandlerContext context() { + Channel channel = mock(Channel.class); + when(channel.remoteAddress()).thenReturn(new InetSocketAddress("127.0.0.1", 1158)); + ChannelHandlerContext context = mock(ChannelHandlerContext.class); + when(context.channel()).thenReturn(channel); + return context; + } + + private ClusterMsg.Message request() { + return ClusterMsg.Message.newBuilder() + .setIdentity("collector-1") + .setDirection(ClusterMsg.Direction.REQUEST) + .setType(ClusterMsg.MessageType.HEARTBEAT) + .build(); + } + + private static class TestRemoting extends NettyRemotingAbstract { + + TestRemoting(ClusterMessageAuthConfig.Mode mode) { + super(null, EndpointRole.SERVER, mode == null ? null : config(mode), () -> null); + } + + void receive(ChannelHandlerContext context, ClusterMsg.Message message) { + processReceiveMsg(context, message); + } + + @Override + public void start() { + } + + @Override + public void shutdown() { + } + + @Override + public boolean isStart() { + return true; + } + } + + private static ClusterMessageAuthConfig config(ClusterMessageAuthConfig.Mode mode) { + ClusterMessageAuthConfig config = new ClusterMessageAuthConfig(); + config.setMode(mode); + config.setActiveKeyId("active"); + config.setActiveSecret(SHARED_SECRET); + return config; + } +} diff --git a/hertzbeat-remoting/src/test/java/org/apache/hertzbeat/remoting/netty/NettyRemotingCompatibilityTest.java b/hertzbeat-remoting/src/test/java/org/apache/hertzbeat/remoting/netty/NettyRemotingCompatibilityTest.java new file mode 100644 index 00000000000..8003bd670ea --- /dev/null +++ b/hertzbeat-remoting/src/test/java/org/apache/hertzbeat/remoting/netty/NettyRemotingCompatibilityTest.java @@ -0,0 +1,241 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hertzbeat.remoting.netty; + +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.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.params.provider.Arguments.arguments; + +import com.google.protobuf.ByteString; +import java.io.IOException; +import java.net.ServerSocket; +import java.time.Duration; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.stream.Stream; +import org.apache.hertzbeat.common.concurrent.BackgroundTaskExecutor; +import org.apache.hertzbeat.common.entity.message.ClusterMsg; +import org.apache.hertzbeat.remoting.RemotingClient; +import org.apache.hertzbeat.remoting.RemotingServer; +import org.apache.hertzbeat.remoting.event.NettyEventListener; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +class NettyRemotingCompatibilityTest { + + private static final String SHARED_SECRET = "cluster-authentication-secret-32!!"; + + @ParameterizedTest(name = "{0}") + @MethodSource("compatibleVersionPairs") + void shouldExchangeMessagesAcrossRollingUpgradePairs( + String description, + ClusterMessageAuthConfig.Mode serverMode, + ClusterMessageAuthConfig.Mode clientMode) throws Exception { + int port = availablePort(); + BackgroundTaskExecutor serverExecutor = executor(); + BackgroundTaskExecutor clientExecutor = executor(); + CountDownLatch clientReady = new CountDownLatch(1); + RemotingServer server = server(port, serverMode, serverExecutor); + RemotingClient client = client(port, clientMode, clientExecutor, clientReady); + server.registerProcessor(ClusterMsg.MessageType.HEARTBEAT, (ctx, message) -> + ClusterMsg.Message.newBuilder() + .setIdentity(message.getIdentity()) + .setDirection(ClusterMsg.Direction.RESPONSE) + .setType(ClusterMsg.MessageType.HEARTBEAT) + .setMsg(ByteString.copyFromUtf8("response")) + .build()); + + try { + server.start(); + assertTrue(awaitStarted(server)); + client.start(); + assertTrue(awaitStarted(client)); + assertTrue(clientReady.await(3, TimeUnit.SECONDS)); + + ClusterMsg.Message response = client.sendMsgSync( + ClusterMsg.Message.newBuilder() + .setIdentity("collector-1") + .setDirection(ClusterMsg.Direction.REQUEST) + .setType(ClusterMsg.MessageType.HEARTBEAT) + .setMsg(ByteString.copyFromUtf8("request")) + .build(), + 3000); + + assertNotNull(response); + assertEquals("response", response.getMsg().toStringUtf8()); + } finally { + client.shutdown(); + server.shutdown(); + } + } + + @ParameterizedTest(name = "{0}") + @MethodSource("requiredModeLegacyPairs") + void shouldRejectLegacyPeerAfterRequiredModeCutover( + String description, + ClusterMessageAuthConfig.Mode serverMode, + ClusterMessageAuthConfig.Mode clientMode) throws Exception { + int port = availablePort(); + BackgroundTaskExecutor serverExecutor = executor(); + BackgroundTaskExecutor clientExecutor = executor(); + CountDownLatch clientReady = new CountDownLatch(1); + RemotingServer server = server(port, serverMode, serverExecutor); + RemotingClient client = client(port, clientMode, clientExecutor, clientReady); + server.registerProcessor(ClusterMsg.MessageType.HEARTBEAT, (ctx, message) -> + ClusterMsg.Message.newBuilder() + .setIdentity(message.getIdentity()) + .setDirection(ClusterMsg.Direction.RESPONSE) + .setType(ClusterMsg.MessageType.HEARTBEAT) + .build()); + + try { + server.start(); + assertTrue(awaitStarted(server)); + client.start(); + assertTrue(awaitStarted(client)); + if (clientMode == ClusterMessageAuthConfig.Mode.REQUIRED) { + assertFalse(clientReady.await(1, TimeUnit.SECONDS)); + return; + } + assertTrue(clientReady.await(1, TimeUnit.SECONDS)); + + ClusterMsg.Message response = client.sendMsgSync( + ClusterMsg.Message.newBuilder() + .setIdentity("collector-1") + .setDirection(ClusterMsg.Direction.REQUEST) + .setType(ClusterMsg.MessageType.HEARTBEAT) + .build(), + 1000); + + assertNull(response); + } finally { + client.shutdown(); + server.shutdown(); + } + } + + private static Stream compatibleVersionPairs() { + return Stream.of( + arguments("old manager and old collector", null, null), + arguments("old manager and new optional collector", null, + ClusterMessageAuthConfig.Mode.OPTIONAL), + arguments("new optional manager and old collector", + ClusterMessageAuthConfig.Mode.OPTIONAL, null), + arguments("new optional manager and new optional collector", + ClusterMessageAuthConfig.Mode.OPTIONAL, + ClusterMessageAuthConfig.Mode.OPTIONAL), + arguments("new required manager and new required collector", + ClusterMessageAuthConfig.Mode.REQUIRED, + ClusterMessageAuthConfig.Mode.REQUIRED)); + } + + private static Stream requiredModeLegacyPairs() { + return Stream.of( + arguments("old manager and new required collector", null, + ClusterMessageAuthConfig.Mode.REQUIRED), + arguments("new required manager and old collector", + ClusterMessageAuthConfig.Mode.REQUIRED, null)); + } + + private RemotingServer server( + int port, + ClusterMessageAuthConfig.Mode mode, + BackgroundTaskExecutor executor) { + NettyServerConfig config = new NettyServerConfig(); + config.setPort(port); + return mode == null + ? new NettyRemotingServer(config, null, executor) + : new NettyRemotingServer(config, null, executor, authConfig(mode), () -> SHARED_SECRET); + } + + private RemotingClient client( + int port, + ClusterMessageAuthConfig.Mode mode, + BackgroundTaskExecutor executor, + CountDownLatch ready) { + NettyClientConfig config = new NettyClientConfig(); + config.setServerHost("127.0.0.1"); + config.setServerPort(port); + NettyEventListener listener = new NettyEventListener() { + @Override + public void onChannelActive(io.netty.channel.Channel channel) { + ready.countDown(); + } + }; + return mode == null + ? new NettyRemotingClient(config, listener, executor) + : new NettyRemotingClient( + config, + listener, + executor, + authConfig(mode), + () -> SHARED_SECRET); + } + + private ClusterMessageAuthConfig authConfig(ClusterMessageAuthConfig.Mode mode) { + ClusterMessageAuthConfig config = new ClusterMessageAuthConfig(); + config.setMode(mode); + config.setActiveKeyId("active"); + config.setHandshakeTimeout(Duration.ofMillis(200)); + return config; + } + + private BackgroundTaskExecutor executor() { + return new BackgroundTaskExecutor() { + private final ExecutorService delegate = Executors.newCachedThreadPool(); + + @Override + public void execute(Runnable runnable) { + delegate.execute(runnable); + } + + @Override + public void executeLongRunning(Runnable runnable) { + delegate.execute(runnable); + } + + @Override + public void destroy() { + delegate.shutdownNow(); + } + }; + } + + private boolean awaitStarted(org.apache.hertzbeat.remoting.RemotingService service) + throws InterruptedException { + for (int attempt = 0; attempt < 50; attempt++) { + if (service.isStart()) { + return true; + } + Thread.sleep(50); + } + return false; + } + + private int availablePort() throws IOException { + try (ServerSocket socket = new ServerSocket(0)) { + return socket.getLocalPort(); + } + } +} diff --git a/hertzbeat-startup/src/main/resources/application.yml b/hertzbeat-startup/src/main/resources/application.yml index 90c0ea4bb8e..857b189054f 100644 --- a/hertzbeat-startup/src/main/resources/application.yml +++ b/hertzbeat-startup/src/main/resources/application.yml @@ -327,6 +327,16 @@ scheduler: server: enabled: true port: 1158 + authentication: + # Required by default. Set optional explicitly only during a mixed-version rolling upgrade. + mode: ${CLUSTER_AUTH_MODE:required} + active-key-id: ${CLUSTER_AUTH_ACTIVE_KEY_ID:primary} + # Leave empty to derive a domain-separated key from common.secret. + active-secret: ${CLUSTER_AUTH_ACTIVE_SECRET:} + previous-key-id: ${CLUSTER_AUTH_PREVIOUS_KEY_ID:} + previous-secret: ${CLUSTER_AUTH_PREVIOUS_SECRET:} + max-clock-skew: ${CLUSTER_AUTH_MAX_CLOCK_SKEW:5m} + handshake-timeout: ${CLUSTER_AUTH_HANDSHAKE_TIMEOUT:3s} grafana: enabled: false diff --git a/home/docs/help/collector.md b/home/docs/help/collector.md index 2f27b10a78e..48bc3bf6701 100644 --- a/home/docs/help/collector.md +++ b/home/docs/help/collector.md @@ -88,6 +88,97 @@ In private mode, collectors operate in isolated networks while still reporting t | `manager-host` | IP address of the HertzBeat server | IP | | `manager-port` | Port of the HertzBeat server | 1158 | +## Cluster Message Authentication + +Manager and standalone Collector authenticate Netty cluster messages with a +versioned, connection-bound HMAC envelope. A standalone Collector also needs +the same AES `COMMON_SECRET` used by Manager to process encrypted collection +data. Configure both values on both sides and keep them independent. + +```yaml +common: + secret: ${COMMON_SECRET:} + +authentication: + mode: required + active-key-id: primary + active-secret: ${CLUSTER_AUTH_ACTIVE_SECRET:} + previous-key-id: ${CLUSTER_AUTH_PREVIOUS_KEY_ID:} + previous-secret: ${CLUSTER_AUTH_PREVIOUS_SECRET:} + max-clock-skew: 5m + handshake-timeout: 3s +``` + +Generate both values once and store them in the deployment secret manager: + +```shell +export COMMON_SECRET="$(openssl rand -hex 16)" +export CLUSTER_AUTH_ACTIVE_SECRET="$(openssl rand -hex 32)" +``` + +`openssl rand -hex 16` produces 32 ASCII bytes, which is a valid AES-256 +`COMMON_SECRET`; the accepted AES lengths are exactly 16, 24, or 32 bytes. +`openssl rand -hex 32` produces a separate 64-character authentication secret +and must not be reused directly as `COMMON_SECRET`. Provision the same two +values on Manager and every standalone Collector, and preserve both across +restarts and upgrades. Blank, known-default, short authentication secrets and +invalid-length AES secrets are rejected during startup. Never put either value +in an image, command history, or public configuration file. + +### Rolling Upgrade + +Use this order to avoid interrupting collection: + +1. Before changing binaries, preserve or establish the same valid-length + `COMMON_SECRET` and configure the same independent + `CLUSTER_AUTH_ACTIVE_SECRET` on Manager and every Collector. Explicitly set + `CLUSTER_AUTH_MODE=optional` on every node. Older versions ignore the new + authentication properties. Optional mode is only a mixed-version rollout + setting; it is not the shipped steady-state default. +2. Upgrade Collectors first. A new optional Collector signs outbound traffic, + while an old Manager ignores the added protobuf fields; the new Collector + temporarily accepts the old Manager's unsigned responses. +3. Upgrade Manager instances. Optional Manager instances accept remaining + unsigned Collectors and advertise a channel-binding challenge to new ones. +4. Wait until + `hertzbeat.cluster.message.authentication.legacy.accepted` remains at zero + and investigate every + `hertzbeat.cluster.message.authentication.rejected` reason. +5. Remove the `CLUSTER_AUTH_MODE=optional` override (or explicitly set + `mode: required`) on both sides. Required mode rejects unsigned peers and + is the shipped default. + +Do not upgrade Manager before its old Collectors have the new secret and +optional mode configured. The preceding local-key change intentionally stops +Manager from sending encryption key material over unauthenticated Netty. + +### Key Rotation + +The active key signs new traffic. The previous key is accepted only for +verification, which provides a dual-key transition: + +1. Deploy the next key as `previous-key-id` and `previous-secret` everywhere. +2. Gradually make that key active, and retain the old key as previous. +3. Confirm that all instances use the new active key, then remove the old key. + +An existing 16-byte `common.secret` may be supplied temporarily as the previous +secret while rotating to an independent 32-byte authentication secret. Key IDs +must be different and do not contain secret material. + +### Replay and Clock Model + +Manager sends a fresh random challenge for every Netty connection. The +challenge is covered by every subsequent signature, so a message captured from +one connection cannot be replayed through another Manager instance or after a +reconnect. A bounded local cache rejects duplicate signatures on the active +connection. This connection-level binding supports multi-Manager deployments +without a shared replay database. + +Authentication timestamps use the configured `max-clock-skew`. Keep Manager +and Collector clocks synchronized. Stale, future, replayed, unknown-key, +malformed, channel-mismatched, and invalid-signature messages are counted by +low-cardinality rejection-reason metrics. + ## Collector Management You can manage collectors through the HertzBeat web interface: diff --git a/home/docs/start/docker-compose-deploy.md b/home/docs/start/docker-compose-deploy.md index 38e1319032e..0b803c5a274 100644 --- a/home/docs/start/docker-compose-deploy.md +++ b/home/docs/start/docker-compose-deploy.md @@ -39,12 +39,19 @@ Run the `docker compose version` command to check if you have a Docker Compose e cd hertzbeat-postgresql-victoria-metrics ``` - - One-click start + - Create and preserve two independent private secrets, then start. + `COMMON_SECRET` is the AES key shared by Manager and every standalone + Collector; `CLUSTER_AUTH_ACTIVE_SECRET` authenticates cluster messages. + Do not commit `.env`, reuse either value for the other purpose, or change + either value during an upgrade. > Run script in `hertzbeat-postgresql-victoria-metrics` directory ```shell - docker-compose up -d + umask 077 + printf 'COMMON_SECRET=%s\n' "$(openssl rand -hex 16)" > .env + printf 'CLUSTER_AUTH_ACTIVE_SECRET=%s\n' "$(openssl rand -hex 32)" >> .env + docker compose up -d ``` - View service status diff --git a/home/docs/start/docker-deploy.md b/home/docs/start/docker-deploy.md index bdeebc70e04..730ba3d1b7f 100644 --- a/home/docs/start/docker-deploy.md +++ b/home/docs/start/docker-deploy.md @@ -15,10 +15,17 @@ It is necessary to have Docker environment in your environment. If not installed ## Deploy HertzBeat Server -1. Execute the following command +1. Generate two independent install-specific secrets, preserve the private + `.env` across upgrades, and execute the following command. The 32-character + `COMMON_SECRET` produced by `rand -hex 16` is a valid 32-byte AES key; + `CLUSTER_AUTH_ACTIVE_SECRET` is separate and must not be reused as that AES + key. ```shell - $ docker run -d -p 1157:1157 -p 1158:1158 \ + $ umask 077 + $ printf 'COMMON_SECRET=%s\n' "$(openssl rand -hex 16)" > .env + $ printf 'CLUSTER_AUTH_ACTIVE_SECRET=%s\n' "$(openssl rand -hex 32)" >> .env + $ docker run -d --env-file .env -p 1157:1157 -p 1158:1158 \ -e HERTZBEAT_COLLECTOR_MYSQL_QUERY_ENGINE=auto \ -v $(pwd)/data:/opt/hertzbeat/data \ -v $(pwd)/logs:/opt/hertzbeat/logs \ @@ -31,6 +38,7 @@ It is necessary to have Docker environment in your environment. If not installed > Command parameter explanation - `docker run -d` : Run a container in the background via Docker + - `--env-file .env` : Supply the private AES and cluster authentication secrets. Use the same file for Manager and standalone Collectors, and do not commit it. - `-p 1157:1157 -p 1158:1158` : Mapping container ports to the host, 1157 is web-ui port, 1158 is cluster port. - `-v $(pwd)/data:/opt/hertzbeat/data` : (optional, data persistence) Important, Mount the H2 database file to the local host, to ensure that the data is not lost due to creating or deleting container. - `-v $(pwd)/logs:/opt/hertzbeat/logs` : (optional) Mount the log file to the local host to facilitate viewing. @@ -68,7 +76,7 @@ By deploying multiple HertzBeat Collectors, high availability, load balancing, a 1. Execute the following command ```shell - $ docker run -d \ + $ docker run -d --env-file .env \ -e IDENTITY=custom-collector-name \ -e MODE=public \ -e MANAGER_HOST=127.0.0.1 \ @@ -80,6 +88,7 @@ By deploying multiple HertzBeat Collectors, high availability, load balancing, a > Command parameter explanation - `docker run -d` : Run a container in the background via Docker + - `--env-file .env` : Supply the same valid-length `COMMON_SECRET` and the same independent `CLUSTER_AUTH_ACTIVE_SECRET` used by Manager. - `-e IDENTITY=custom-collector-name` : (optional) Set the collector unique identity name. Attention the clusters collector name must unique. - `-e MODE=public` : set the running mode(public or private), public cluster or private - `-e MANAGER_HOST=127.0.0.1` : Important, Set the main hertzbeat server ip host, must use the server host instead of 127.0.0.1. diff --git a/home/docs/start/native-collector.md b/home/docs/start/native-collector.md index 36527ce068f..037cabc80fd 100644 --- a/home/docs/start/native-collector.md +++ b/home/docs/start/native-collector.md @@ -65,6 +65,10 @@ The native collector package uses the same `config/application.yml` layout as th That means: - Collector connection settings are edited in the same place +- The same 16/24/32-byte `COMMON_SECRET` used by Manager must be provisioned + before startup +- The same independent `CLUSTER_AUTH_ACTIVE_SECRET` used by Manager must also + be provisioned; do not reuse its value as `COMMON_SECRET` - Virtual-thread related configuration is edited in the same place - Native-only boot adjustments are applied by code at runtime instead of maintaining a second `application.yml` diff --git a/home/docs/start/package-deploy.md b/home/docs/start/package-deploy.md index 4453269c6b0..01781409dcf 100644 --- a/home/docs/start/package-deploy.md +++ b/home/docs/start/package-deploy.md @@ -45,7 +45,22 @@ The current branch uses `Java 25`, and the standard installation package no long If you need modify account or password, configure `config/sureness.yml`. For detail steps, please refer to [Configure Account Password](account-modify) -4. Start the service +4. Generate two independent private secrets and store both in your deployment + secret manager. `COMMON_SECRET` must be exactly 16, 24, or 32 bytes and + identical on Manager and every standalone Collector. The first command + below produces 32 ASCII bytes. `CLUSTER_AUTH_ACTIVE_SECRET` is a separate + message authentication secret; do not reuse its 64-character output as + `COMMON_SECRET`. + + ```shell + export COMMON_SECRET="$(openssl rand -hex 16)" + export CLUSTER_AUTH_ACTIVE_SECRET="$(openssl rand -hex 32)" + ``` + + Run Manager and every standalone Collector with those same two environment + values, and preserve both across upgrades. + +5. Start the service Execute the startup script in the installation directory `bin/`, or `startup.bat` in windows. @@ -53,7 +68,7 @@ The current branch uses `Java 25`, and the standard installation package no long ./startup.sh ``` -5. Begin to explore HertzBeat +6. Begin to explore HertzBeat Access [http://ip:1157/](http://ip:1157/) using browser. You can explore HertzBeat with default account `admin/hertzbeat` now! @@ -95,6 +110,9 @@ Before choosing it, review the trade-offs in [Native Collector Guide](native-col Configure the collector configuration yml file `config/application.yml`: unique `identity` name, running `mode` (public or private), hertzbeat `manager-host`, hertzbeat `manager-port` ```yaml + common: + secret: ${COMMON_SECRET:} + collector: dispatch: entrance: @@ -104,6 +122,10 @@ Before choosing it, review the trade-offs in [Native Collector Guide](native-col mode: ${MODE:public} manager-host: ${MANAGER_HOST:127.0.0.1} manager-port: ${MANAGER_PORT:1158} + authentication: + mode: ${CLUSTER_AUTH_MODE:required} + active-key-id: ${CLUSTER_AUTH_ACTIVE_KEY_ID:primary} + active-secret: ${CLUSTER_AUTH_ACTIVE_SECRET:} ``` > Parameters detailed explanation @@ -113,7 +135,8 @@ Before choosing it, review the trade-offs in [Native Collector Guide](native-col - `manager-host` : Important, configure the address of the connected HertzBeat Server, - `manager-port` : (optional) Configure the port of the connected HertzBeat Server, default 1158. -3. Start the service +3. Confirm that both `COMMON_SECRET` and `CLUSTER_AUTH_ACTIVE_SECRET` are the + same values used by Manager, then start the service. Run `$ ./bin/startup.sh` or `bin/startup.bat` for the JVM collector package. Run `$ ./bin/startup.sh` for Linux or macOS native collector packages, and `bin\\startup.bat` for the Windows native collector package. diff --git a/home/docs/start/quickstart.md b/home/docs/start/quickstart.md index d68cf330ded..5a9b333fcbf 100644 --- a/home/docs/start/quickstart.md +++ b/home/docs/start/quickstart.md @@ -9,7 +9,14 @@ description: Install Apache HertzBeat monitoring system in minutes using Docker, Install Apache HertzBeat™ in under 5 minutes using Docker with a single command. HertzBeat supports Docker, binary packages, and source code installation on X86/ARM64 architectures. -**Quick Install Command:** `docker run -d -p 1157:1157 -p 1158:1158 --name hertzbeat apache/hertzbeat` +**Quick Install Commands:** + +```shell +umask 077 +printf 'COMMON_SECRET=%s\n' "$(openssl rand -hex 16)" > .env +printf 'CLUSTER_AUTH_ACTIVE_SECRET=%s\n' "$(openssl rand -hex 32)" >> .env +docker run -d --env-file .env -p 1157:1157 -p 1158:1158 --name hertzbeat apache/hertzbeat +``` ## Installation Methods @@ -33,16 +40,24 @@ HertzBeat provides multiple installation options: ### 1:Install quickly via docker -1. Just one command to get started: +1. Create and preserve two independent private secrets, then start HertzBeat. + `COMMON_SECRET` is a 32-byte AES key shared by Manager and standalone + Collectors. `CLUSTER_AUTH_ACTIVE_SECRET` is a separate message + authentication secret; do not reuse one value for both: - ```docker run -d -p 1157:1157 -p 1158:1158 --name hertzbeat apache/hertzbeat``` + ```shell + umask 077 + printf 'COMMON_SECRET=%s\n' "$(openssl rand -hex 16)" > .env + printf 'CLUSTER_AUTH_ACTIVE_SECRET=%s\n' "$(openssl rand -hex 32)" >> .env + docker run -d --env-file .env -p 1157:1157 -p 1158:1158 --name hertzbeat apache/hertzbeat + ``` 2. Access `http://localhost:1157` to start, default account: `admin/hertzbeat` 3. Deploy collector clusters(Optional) ```shell - docker run -d -e IDENTITY=custom-collector-name -e MANAGER_HOST=127.0.0.1 -e MANAGER_PORT=1158 --name hertzbeat-collector apache/hertzbeat-collector + docker run -d --env-file .env -e IDENTITY=custom-collector-name -e MANAGER_HOST=127.0.0.1 -e MANAGER_PORT=1158 --name hertzbeat-collector apache/hertzbeat-collector ``` - `-e IDENTITY=custom-collector-name` : set the collector unique identity name. @@ -55,15 +70,21 @@ Detailed config refer to [Install HertzBeat via Docker](https://hertzbeat.apache #### 2:Install via package 1. Download the release package `apache-hertzbeat-xx-bin.tar.gz` [Download Page](https://hertzbeat.apache.org/docs/download) -2. Configure the HertzBeat configuration yml file `hertzbeat/config/application.yml` (optional) -3. Run command `$ ./bin/startup.sh` or `bin/startup.bat` -4. Access `http://localhost:1157` to start, default account: `admin/hertzbeat` -5. Deploy collector clusters(Optional) +2. Generate and securely store the same two independent secrets shown above. + Provide the same 16/24/32-byte `COMMON_SECRET` and the same separate + `CLUSTER_AUTH_ACTIVE_SECRET` to Manager and every standalone Collector. +3. Configure the HertzBeat configuration yml file `hertzbeat/config/application.yml` (optional) +4. Run command `$ ./bin/startup.sh` or `bin/startup.bat` +5. Access `http://localhost:1157` to start, default account: `admin/hertzbeat` +6. Deploy collector clusters(Optional) - If you do not need external JDBC drivers from `ext-lib`, prefer the native collector package for faster startup and lower memory usage. MySQL, MariaDB, and OceanBase can use the built-in query engine directly when `mysql-connector-j` is not provided. TiDB follows the same rule for its SQL query metric set. See [Native Collector Guide](native-collector). - Download the release package `apache-hertzbeat-collector-xx-bin.tar.gz` (JVM collector) or the native collector package for your target platform, such as `apache-hertzbeat-collector-native-xx-linux-amd64-bin.tar.gz` or `apache-hertzbeat-collector-native-xx-windows-amd64-bin.zip`, to the new machine [Download Page](https://hertzbeat.apache.org/docs/download) - Configure the collector configuration yml file `hertzbeat-collector/config/application.yml`: unique `identity` name, running `mode` (public or private), hertzbeat `manager-host`, hertzbeat `manager-port` ```yaml + common: + secret: ${COMMON_SECRET:} + collector: dispatch: entrance: @@ -73,6 +94,10 @@ Detailed config refer to [Install HertzBeat via Docker](https://hertzbeat.apache mode: ${MODE:public} manager-host: ${MANAGER_HOST:127.0.0.1} manager-port: ${MANAGER_PORT:1158} + authentication: + mode: ${CLUSTER_AUTH_MODE:required} + active-key-id: ${CLUSTER_AUTH_ACTIVE_KEY_ID:primary} + active-secret: ${CLUSTER_AUTH_ACTIVE_SECRET:} ``` - Native collector trade-offs: platform-specific packages, no runtime `ext-lib` JDBC loading, and less suitable for JVM-style runtime classpath extension. See [Native Collector Guide](native-collector). @@ -139,9 +164,13 @@ Yes. After first login, navigate to Settings → Account Management to change th docker stop hertzbeat docker rm hertzbeat docker pull apache/hertzbeat:latest -docker run -d -p 1157:1157 -p 1158:1158 --name hertzbeat apache/hertzbeat +docker run -d --env-file .env -p 1157:1157 -p 1158:1158 --name hertzbeat apache/hertzbeat ``` +Preserve the private `.env` and reuse both the same valid-length +`COMMON_SECRET` and the same independent `CLUSTER_AUTH_ACTIVE_SECRET` across +upgrades. + ### What database does HertzBeat use? HertzBeat uses H2 embedded database by default. For production, configure external databases: diff --git a/home/docs/start/upgrade.md b/home/docs/start/upgrade.md index e7363fc4245..cf72714fec1 100644 --- a/home/docs/start/upgrade.md +++ b/home/docs/start/upgrade.md @@ -43,4 +43,43 @@ Apache HertzBeat's metadata information is stored in H2 or Mysql, PostgreSQL rel - `bin/shutdown.sh` stops the HertzBeat process and downloads the new installation package - Refer to [Installation package to install HertzBeat](./package-deploy) to start with the new installation package and configure the database connection in `application.yml` +## Cluster Authentication Upgrade + +Before upgrading to a version that requires authenticated Manager and +Collector messages: + +1. Back up the metadata database and your existing secret configuration. +2. Establish the AES `COMMON_SECRET` shared by Manager and every standalone + Collector: + - For a fresh installation, generate a valid 32-byte value with + `openssl rand -hex 16`. + - For an existing installation, preserve its current valid + `common.secret` exactly. If Manager previously bootstrapped the AES value + into the database, recover that existing `aesSecret` through restricted + administrator access and move it into the deployment secret manager. + Do not replace it with a newly generated value during an ordinary upgrade; + changing it can make encrypted data unreadable. +3. Generate a separate cluster authentication secret. Its 64-character output + is deliberately independent and is not a valid replacement for + `COMMON_SECRET`. + + ```shell + openssl rand -hex 32 + ``` + +4. Provision the same `COMMON_SECRET` and the same independent + `CLUSTER_AUTH_ACTIVE_SECRET` on Manager and every standalone Collector. + Preserve both across all future restarts and upgrades. +5. Set `CLUSTER_AUTH_MODE=optional` on every node before replacing binaries. +6. Upgrade Collectors first, then Managers. +7. After all nodes are upgraded and legacy-acceptance metrics remain at zero, + remove the optional override. The shipped `required` mode then rejects + unsigned messages. + +Fresh Docker Compose installations must create the documented `.env` before +`docker compose up`; Compose fails interpolation with an actionable message +when either secret is absent. Existing Compose installations must preserve the +current AES key, add both variables to the private `.env`, and back it up before +pulling the new image. + **HAVE FUN** diff --git a/script/application.yml b/script/application.yml index 90c0ea4bb8e..857b189054f 100644 --- a/script/application.yml +++ b/script/application.yml @@ -327,6 +327,16 @@ scheduler: server: enabled: true port: 1158 + authentication: + # Required by default. Set optional explicitly only during a mixed-version rolling upgrade. + mode: ${CLUSTER_AUTH_MODE:required} + active-key-id: ${CLUSTER_AUTH_ACTIVE_KEY_ID:primary} + # Leave empty to derive a domain-separated key from common.secret. + active-secret: ${CLUSTER_AUTH_ACTIVE_SECRET:} + previous-key-id: ${CLUSTER_AUTH_PREVIOUS_KEY_ID:} + previous-secret: ${CLUSTER_AUTH_PREVIOUS_SECRET:} + max-clock-skew: ${CLUSTER_AUTH_MAX_CLOCK_SKEW:5m} + handshake-timeout: ${CLUSTER_AUTH_HANDSHAKE_TIMEOUT:3s} grafana: enabled: false diff --git a/script/docker-compose/README.md b/script/docker-compose/README.md index 62b6daf2e4f..73a3b371478 100644 --- a/script/docker-compose/README.md +++ b/script/docker-compose/README.md @@ -4,6 +4,11 @@ Suggest the [HertzBeat + GreptimeDB + Postgresql Solution](hertzbeat-postgresql- Notes: +- Before `docker compose up`, create the install-specific `.env` described in + the selected solution README. Every Compose definition requires an AES + `COMMON_SECRET` shared by Manager and standalone Collectors, plus an + independent `CLUSTER_AUTH_ACTIVE_SECRET`. Keep that file private and + preserve both values across upgrades. - MySQL, MariaDB, OceanBase, and TiDB SQL query metrics can use the built-in MySQL-compatible query engine without `mysql-connector-j`. - If you place `mysql-connector-j` in `ext-lib`, HertzBeat prefers JDBC after restart. - Oracle and DB2 still require external JDBC driver jars in `ext-lib`. diff --git a/script/docker-compose/hertzbeat-mysql-iotdb/README.md b/script/docker-compose/hertzbeat-mysql-iotdb/README.md index 59ed05e9c1b..0700097f484 100644 --- a/script/docker-compose/hertzbeat-mysql-iotdb/README.md +++ b/script/docker-compose/hertzbeat-mysql-iotdb/README.md @@ -23,7 +23,19 @@ If you want HertzBeat to prefer JDBC after restart, place `mysql-connector-j` in `ext-lib`. Oracle and DB2 still require external JDBC jars in `ext-lib`. -3. Enter the deployment script docker-compose directory, execute +3. Create two independent, private install-specific secrets. `COMMON_SECRET` + is a 32-byte AES key that must be identical on Manager and every standalone + Collector. `CLUSTER_AUTH_ACTIVE_SECRET` is a separate message + authentication secret. Preserve both across upgrades and do not commit + `.env`. + + ```shell + umask 077 + printf 'COMMON_SECRET=%s\n' "$(openssl rand -hex 16)" > .env + printf 'CLUSTER_AUTH_ACTIVE_SECRET=%s\n' "$(openssl rand -hex 32)" >> .env + ``` + +4. Enter the deployment script docker-compose directory, execute `docker compose up -d` diff --git a/script/docker-compose/hertzbeat-mysql-iotdb/README_CN.md b/script/docker-compose/hertzbeat-mysql-iotdb/README_CN.md index 3bfe5b8f758..76ea1eed891 100644 --- a/script/docker-compose/hertzbeat-mysql-iotdb/README_CN.md +++ b/script/docker-compose/hertzbeat-mysql-iotdb/README_CN.md @@ -24,7 +24,18 @@ 如果你希望 HertzBeat 在重启后优先走 JDBC,可以把 `mysql-connector-j` 放到 `ext-lib`。 Oracle、DB2 这类场景仍然需要把外部 JDBC 驱动放到 `ext-lib`。 -3. 进入部署脚本 docker-compose 目录, 执行 +3. 创建两份相互独立且仅供本次安装使用的密钥。`COMMON_SECRET` 是 Manager + 与所有独立 Collector 必须完全相同的 32 字节 AES 密钥; + `CLUSTER_AUTH_ACTIVE_SECRET` 是另一份消息认证密钥。升级时必须保留两者, + 并且不要将 `.env` 提交到版本库。 + + ```shell + umask 077 + printf 'COMMON_SECRET=%s\n' "$(openssl rand -hex 16)" > .env + printf 'CLUSTER_AUTH_ACTIVE_SECRET=%s\n' "$(openssl rand -hex 32)" >> .env + ``` + +4. 进入部署脚本 docker-compose 目录, 执行 `docker compose up -d` diff --git a/script/docker-compose/hertzbeat-mysql-iotdb/conf/application.yml b/script/docker-compose/hertzbeat-mysql-iotdb/conf/application.yml index 33b0537468e..b9390c244a0 100644 --- a/script/docker-compose/hertzbeat-mysql-iotdb/conf/application.yml +++ b/script/docker-compose/hertzbeat-mysql-iotdb/conf/application.yml @@ -135,6 +135,7 @@ spring: enable: true common: + secret: ${COMMON_SECRET:} queue: # memory or kafka type: memory @@ -228,6 +229,14 @@ scheduler: server: enabled: true port: 1158 + authentication: + mode: ${CLUSTER_AUTH_MODE:required} + active-key-id: ${CLUSTER_AUTH_ACTIVE_KEY_ID:primary} + active-secret: ${CLUSTER_AUTH_ACTIVE_SECRET:} + previous-key-id: ${CLUSTER_AUTH_PREVIOUS_KEY_ID:} + previous-secret: ${CLUSTER_AUTH_PREVIOUS_SECRET:} + max-clock-skew: ${CLUSTER_AUTH_MAX_CLOCK_SKEW:5m} + handshake-timeout: ${CLUSTER_AUTH_HANDSHAKE_TIMEOUT:3s} grafana: enabled: false diff --git a/script/docker-compose/hertzbeat-mysql-iotdb/docker-compose.yaml b/script/docker-compose/hertzbeat-mysql-iotdb/docker-compose.yaml index 1beb96ca8d4..bf20d91fa2d 100644 --- a/script/docker-compose/hertzbeat-mysql-iotdb/docker-compose.yaml +++ b/script/docker-compose/hertzbeat-mysql-iotdb/docker-compose.yaml @@ -69,6 +69,10 @@ services: restart: always environment: HERTZBEAT_COLLECTOR_MYSQL_QUERY_ENGINE: auto + COMMON_SECRET: ${COMMON_SECRET:?Set COMMON_SECRET in .env before startup} + CLUSTER_AUTH_MODE: ${CLUSTER_AUTH_MODE:-required} + CLUSTER_AUTH_ACTIVE_KEY_ID: ${CLUSTER_AUTH_ACTIVE_KEY_ID:-primary} + CLUSTER_AUTH_ACTIVE_SECRET: ${CLUSTER_AUTH_ACTIVE_SECRET:?Set CLUSTER_AUTH_ACTIVE_SECRET in .env before startup} TZ: Asia/Shanghai LANG: zh_CN.UTF-8 depends_on: diff --git a/script/docker-compose/hertzbeat-mysql-tdengine/README.md b/script/docker-compose/hertzbeat-mysql-tdengine/README.md index 51278996514..f016331cefa 100644 --- a/script/docker-compose/hertzbeat-mysql-tdengine/README.md +++ b/script/docker-compose/hertzbeat-mysql-tdengine/README.md @@ -23,11 +23,23 @@ If you want HertzBeat to prefer JDBC after restart, place `mysql-connector-j` in `ext-lib`. Oracle and DB2 still require external JDBC jars in `ext-lib`. -3. Enter the deployment script docker-compose directory, execute +3. Create two independent, private install-specific secrets. `COMMON_SECRET` + is a 32-byte AES key that must be identical on Manager and every standalone + Collector. `CLUSTER_AUTH_ACTIVE_SECRET` is a separate message + authentication secret. Preserve both across upgrades and do not commit + `.env`. + + ```shell + umask 077 + printf 'COMMON_SECRET=%s\n' "$(openssl rand -hex 16)" > .env + printf 'CLUSTER_AUTH_ACTIVE_SECRET=%s\n' "$(openssl rand -hex 32)" >> .env + ``` + +4. Enter the deployment script docker-compose directory, execute `docker compose up -d` -4. Enter tdengine to create hertzbeat database +5. Enter tdengine to create hertzbeat database ```shell $ docker exec -it tdengine /bin/bash diff --git a/script/docker-compose/hertzbeat-mysql-tdengine/README_CN.md b/script/docker-compose/hertzbeat-mysql-tdengine/README_CN.md index ccc8c0f6a85..961225959cf 100644 --- a/script/docker-compose/hertzbeat-mysql-tdengine/README_CN.md +++ b/script/docker-compose/hertzbeat-mysql-tdengine/README_CN.md @@ -24,11 +24,22 @@ 如果你希望 HertzBeat 在重启后优先走 JDBC,可以把 `mysql-connector-j` 放到 `ext-lib`。 Oracle、DB2 这类场景仍然需要把外部 JDBC 驱动放到 `ext-lib`。 -3. 进入部署脚本 docker-compose 目录, 执行 +3. 创建两份相互独立且仅供本次安装使用的密钥。`COMMON_SECRET` 是 Manager + 与所有独立 Collector 必须完全相同的 32 字节 AES 密钥; + `CLUSTER_AUTH_ACTIVE_SECRET` 是另一份消息认证密钥。升级时必须保留两者, + 并且不要将 `.env` 提交到版本库。 + + ```shell + umask 077 + printf 'COMMON_SECRET=%s\n' "$(openssl rand -hex 16)" > .env + printf 'CLUSTER_AUTH_ACTIVE_SECRET=%s\n' "$(openssl rand -hex 32)" >> .env + ``` + +4. 进入部署脚本 docker-compose 目录, 执行 `docker compose up -d` -4. 进入tdengine创建hertzbeat数据库 +5. 进入tdengine创建hertzbeat数据库 ```bash $ docker exec -it tdengine /bin/bash diff --git a/script/docker-compose/hertzbeat-mysql-tdengine/conf/application.yml b/script/docker-compose/hertzbeat-mysql-tdengine/conf/application.yml index 8d795ed6434..6c9cde945d9 100644 --- a/script/docker-compose/hertzbeat-mysql-tdengine/conf/application.yml +++ b/script/docker-compose/hertzbeat-mysql-tdengine/conf/application.yml @@ -135,6 +135,7 @@ spring: enable: true common: + secret: ${COMMON_SECRET:} queue: # memory or kafka type: memory @@ -225,6 +226,14 @@ scheduler: server: enabled: true port: 1158 + authentication: + mode: ${CLUSTER_AUTH_MODE:required} + active-key-id: ${CLUSTER_AUTH_ACTIVE_KEY_ID:primary} + active-secret: ${CLUSTER_AUTH_ACTIVE_SECRET:} + previous-key-id: ${CLUSTER_AUTH_PREVIOUS_KEY_ID:} + previous-secret: ${CLUSTER_AUTH_PREVIOUS_SECRET:} + max-clock-skew: ${CLUSTER_AUTH_MAX_CLOCK_SKEW:5m} + handshake-timeout: ${CLUSTER_AUTH_HANDSHAKE_TIMEOUT:3s} grafana: enabled: false diff --git a/script/docker-compose/hertzbeat-mysql-tdengine/docker-compose.yaml b/script/docker-compose/hertzbeat-mysql-tdengine/docker-compose.yaml index fb474162c4c..3c9e5fa2ae5 100644 --- a/script/docker-compose/hertzbeat-mysql-tdengine/docker-compose.yaml +++ b/script/docker-compose/hertzbeat-mysql-tdengine/docker-compose.yaml @@ -68,6 +68,10 @@ services: restart: always environment: HERTZBEAT_COLLECTOR_MYSQL_QUERY_ENGINE: auto + COMMON_SECRET: ${COMMON_SECRET:?Set COMMON_SECRET in .env before startup} + CLUSTER_AUTH_MODE: ${CLUSTER_AUTH_MODE:-required} + CLUSTER_AUTH_ACTIVE_KEY_ID: ${CLUSTER_AUTH_ACTIVE_KEY_ID:-primary} + CLUSTER_AUTH_ACTIVE_SECRET: ${CLUSTER_AUTH_ACTIVE_SECRET:?Set CLUSTER_AUTH_ACTIVE_SECRET in .env before startup} TZ: Asia/Shanghai LANG: zh_CN.UTF-8 depends_on: diff --git a/script/docker-compose/hertzbeat-mysql-victoria-metrics/README.md b/script/docker-compose/hertzbeat-mysql-victoria-metrics/README.md index 0b05f4750b5..536af24e905 100644 --- a/script/docker-compose/hertzbeat-mysql-victoria-metrics/README.md +++ b/script/docker-compose/hertzbeat-mysql-victoria-metrics/README.md @@ -23,7 +23,19 @@ If you want HertzBeat to prefer JDBC after restart, place `mysql-connector-j` in `ext-lib`. Oracle and DB2 still require external JDBC jars in `ext-lib`. -3. Enter the deployment script docker-compose directory, execute +3. Create two independent, private install-specific secrets. `COMMON_SECRET` + is a 32-byte AES key that must be identical on Manager and every standalone + Collector. `CLUSTER_AUTH_ACTIVE_SECRET` is a separate message + authentication secret. Preserve both across upgrades and do not commit + `.env`. + + ```shell + umask 077 + printf 'COMMON_SECRET=%s\n' "$(openssl rand -hex 16)" > .env + printf 'CLUSTER_AUTH_ACTIVE_SECRET=%s\n' "$(openssl rand -hex 32)" >> .env + ``` + +4. Enter the deployment script docker-compose directory, execute `docker compose up -d` diff --git a/script/docker-compose/hertzbeat-mysql-victoria-metrics/README_CN.md b/script/docker-compose/hertzbeat-mysql-victoria-metrics/README_CN.md index 3ee6c4942af..a5878ca3365 100644 --- a/script/docker-compose/hertzbeat-mysql-victoria-metrics/README_CN.md +++ b/script/docker-compose/hertzbeat-mysql-victoria-metrics/README_CN.md @@ -24,7 +24,18 @@ 如果你希望 HertzBeat 在重启后优先走 JDBC,可以把 `mysql-connector-j` 放到 `ext-lib`。 Oracle、DB2 这类场景仍然需要把外部 JDBC 驱动放到 `ext-lib`。 -3. 进入部署脚本 docker-compose 目录, 执行 +3. 创建两份相互独立且仅供本次安装使用的密钥。`COMMON_SECRET` 是 Manager + 与所有独立 Collector 必须完全相同的 32 字节 AES 密钥; + `CLUSTER_AUTH_ACTIVE_SECRET` 是另一份消息认证密钥。升级时必须保留两者, + 并且不要将 `.env` 提交到版本库。 + + ```shell + umask 077 + printf 'COMMON_SECRET=%s\n' "$(openssl rand -hex 16)" > .env + printf 'CLUSTER_AUTH_ACTIVE_SECRET=%s\n' "$(openssl rand -hex 32)" >> .env + ``` + +4. 进入部署脚本 docker-compose 目录, 执行 `docker compose up -d` diff --git a/script/docker-compose/hertzbeat-mysql-victoria-metrics/conf/application.yml b/script/docker-compose/hertzbeat-mysql-victoria-metrics/conf/application.yml index f92abb9f989..01d4cd8620f 100644 --- a/script/docker-compose/hertzbeat-mysql-victoria-metrics/conf/application.yml +++ b/script/docker-compose/hertzbeat-mysql-victoria-metrics/conf/application.yml @@ -168,6 +168,7 @@ warehouse: db: 0 common: + secret: ${COMMON_SECRET:} queue: # memory or kafka type: memory @@ -228,6 +229,14 @@ scheduler: server: enabled: true port: 1158 + authentication: + mode: ${CLUSTER_AUTH_MODE:required} + active-key-id: ${CLUSTER_AUTH_ACTIVE_KEY_ID:primary} + active-secret: ${CLUSTER_AUTH_ACTIVE_SECRET:} + previous-key-id: ${CLUSTER_AUTH_PREVIOUS_KEY_ID:} + previous-secret: ${CLUSTER_AUTH_PREVIOUS_SECRET:} + max-clock-skew: ${CLUSTER_AUTH_MAX_CLOCK_SKEW:5m} + handshake-timeout: ${CLUSTER_AUTH_HANDSHAKE_TIMEOUT:3s} grafana: enabled: false diff --git a/script/docker-compose/hertzbeat-mysql-victoria-metrics/docker-compose.yaml b/script/docker-compose/hertzbeat-mysql-victoria-metrics/docker-compose.yaml index f127a4198cb..bf879dc051a 100644 --- a/script/docker-compose/hertzbeat-mysql-victoria-metrics/docker-compose.yaml +++ b/script/docker-compose/hertzbeat-mysql-victoria-metrics/docker-compose.yaml @@ -68,6 +68,10 @@ services: restart: always environment: HERTZBEAT_COLLECTOR_MYSQL_QUERY_ENGINE: auto + COMMON_SECRET: ${COMMON_SECRET:?Set COMMON_SECRET in .env before startup} + CLUSTER_AUTH_MODE: ${CLUSTER_AUTH_MODE:-required} + CLUSTER_AUTH_ACTIVE_KEY_ID: ${CLUSTER_AUTH_ACTIVE_KEY_ID:-primary} + CLUSTER_AUTH_ACTIVE_SECRET: ${CLUSTER_AUTH_ACTIVE_SECRET:?Set CLUSTER_AUTH_ACTIVE_SECRET in .env before startup} TZ: Asia/Shanghai LANG: zh_CN.UTF-8 depends_on: diff --git a/script/docker-compose/hertzbeat-postgresql-greptimedb/README.md b/script/docker-compose/hertzbeat-postgresql-greptimedb/README.md index 82e8291811f..68f65e728a8 100644 --- a/script/docker-compose/hertzbeat-postgresql-greptimedb/README.md +++ b/script/docker-compose/hertzbeat-postgresql-greptimedb/README.md @@ -24,7 +24,19 @@ If you want HertzBeat to prefer JDBC after restart, place `mysql-connector-j` in `ext-lib`. Oracle and DB2 still require external JDBC jars in `ext-lib`. -3. Enter the deployment script docker-compose directory, execute +3. Create two independent, private install-specific secrets. `COMMON_SECRET` + is a 32-byte AES key that must be identical on Manager and every standalone + Collector. `CLUSTER_AUTH_ACTIVE_SECRET` is a separate message + authentication secret. Preserve both across upgrades and do not commit + `.env`. + + ```shell + umask 077 + printf 'COMMON_SECRET=%s\n' "$(openssl rand -hex 16)" > .env + printf 'CLUSTER_AUTH_ACTIVE_SECRET=%s\n' "$(openssl rand -hex 32)" >> .env + ``` + +4. Enter the deployment script docker-compose directory, execute `docker compose up -d` diff --git a/script/docker-compose/hertzbeat-postgresql-greptimedb/README_CN.md b/script/docker-compose/hertzbeat-postgresql-greptimedb/README_CN.md index 60347ce8732..d9199bc1d22 100644 --- a/script/docker-compose/hertzbeat-postgresql-greptimedb/README_CN.md +++ b/script/docker-compose/hertzbeat-postgresql-greptimedb/README_CN.md @@ -25,7 +25,18 @@ 如果你希望 HertzBeat 在重启后优先走 JDBC,可以把 `mysql-connector-j` 放到 `ext-lib`。 Oracle、DB2 这类场景仍然需要把外部 JDBC 驱动放到 `ext-lib`。 -3. 进入部署脚本 docker-compose 目录, 执行 +3. 创建两份相互独立且仅供本次安装使用的密钥。`COMMON_SECRET` 是 Manager + 与所有独立 Collector 必须完全相同的 32 字节 AES 密钥; + `CLUSTER_AUTH_ACTIVE_SECRET` 是另一份消息认证密钥。升级时必须保留两者, + 并且不要将 `.env` 提交到版本库。 + + ```shell + umask 077 + printf 'COMMON_SECRET=%s\n' "$(openssl rand -hex 16)" > .env + printf 'CLUSTER_AUTH_ACTIVE_SECRET=%s\n' "$(openssl rand -hex 32)" >> .env + ``` + +4. 进入部署脚本 docker-compose 目录, 执行 `docker compose up -d` diff --git a/script/docker-compose/hertzbeat-postgresql-greptimedb/conf/application.yml b/script/docker-compose/hertzbeat-postgresql-greptimedb/conf/application.yml index 6cc14900303..895bc28627f 100644 --- a/script/docker-compose/hertzbeat-postgresql-greptimedb/conf/application.yml +++ b/script/docker-compose/hertzbeat-postgresql-greptimedb/conf/application.yml @@ -165,6 +165,7 @@ warehouse: db: 0 common: + secret: ${COMMON_SECRET:} queue: # memory or kafka type: memory @@ -225,6 +226,14 @@ scheduler: server: enabled: true port: 1158 + authentication: + mode: ${CLUSTER_AUTH_MODE:required} + active-key-id: ${CLUSTER_AUTH_ACTIVE_KEY_ID:primary} + active-secret: ${CLUSTER_AUTH_ACTIVE_SECRET:} + previous-key-id: ${CLUSTER_AUTH_PREVIOUS_KEY_ID:} + previous-secret: ${CLUSTER_AUTH_PREVIOUS_SECRET:} + max-clock-skew: ${CLUSTER_AUTH_MAX_CLOCK_SKEW:5m} + handshake-timeout: ${CLUSTER_AUTH_HANDSHAKE_TIMEOUT:3s} grafana: enabled: false diff --git a/script/docker-compose/hertzbeat-postgresql-greptimedb/docker-compose.yaml b/script/docker-compose/hertzbeat-postgresql-greptimedb/docker-compose.yaml index 3ad6fc796f3..e9cd60577a7 100644 --- a/script/docker-compose/hertzbeat-postgresql-greptimedb/docker-compose.yaml +++ b/script/docker-compose/hertzbeat-postgresql-greptimedb/docker-compose.yaml @@ -88,6 +88,10 @@ services: restart: always environment: HERTZBEAT_COLLECTOR_MYSQL_QUERY_ENGINE: auto + COMMON_SECRET: ${COMMON_SECRET:?Set COMMON_SECRET in .env before startup} + CLUSTER_AUTH_MODE: ${CLUSTER_AUTH_MODE:-required} + CLUSTER_AUTH_ACTIVE_KEY_ID: ${CLUSTER_AUTH_ACTIVE_KEY_ID:-primary} + CLUSTER_AUTH_ACTIVE_SECRET: ${CLUSTER_AUTH_ACTIVE_SECRET:?Set CLUSTER_AUTH_ACTIVE_SECRET in .env before startup} TZ: Asia/Shanghai LANG: zh_CN.UTF-8 depends_on: diff --git a/script/docker-compose/hertzbeat-postgresql-victoria-metrics/README.md b/script/docker-compose/hertzbeat-postgresql-victoria-metrics/README.md index a999460c94a..4760092c1ae 100644 --- a/script/docker-compose/hertzbeat-postgresql-victoria-metrics/README.md +++ b/script/docker-compose/hertzbeat-postgresql-victoria-metrics/README.md @@ -24,7 +24,19 @@ If you want HertzBeat to prefer JDBC after restart, place `mysql-connector-j` in `ext-lib`. Oracle and DB2 still require external JDBC jars in `ext-lib`. -3. Enter the deployment script docker-compose directory, execute +3. Create two independent, private install-specific secrets. `COMMON_SECRET` + is a 32-byte AES key that must be identical on Manager and every standalone + Collector. `CLUSTER_AUTH_ACTIVE_SECRET` is a separate message + authentication secret. Preserve both across upgrades and do not commit + `.env`. + + ```shell + umask 077 + printf 'COMMON_SECRET=%s\n' "$(openssl rand -hex 16)" > .env + printf 'CLUSTER_AUTH_ACTIVE_SECRET=%s\n' "$(openssl rand -hex 32)" >> .env + ``` + +4. Enter the deployment script docker-compose directory, execute `docker compose up -d` diff --git a/script/docker-compose/hertzbeat-postgresql-victoria-metrics/README_CN.md b/script/docker-compose/hertzbeat-postgresql-victoria-metrics/README_CN.md index dc9c386147c..429cd2e016d 100644 --- a/script/docker-compose/hertzbeat-postgresql-victoria-metrics/README_CN.md +++ b/script/docker-compose/hertzbeat-postgresql-victoria-metrics/README_CN.md @@ -25,7 +25,18 @@ 如果你希望 HertzBeat 在重启后优先走 JDBC,可以把 `mysql-connector-j` 放到 `ext-lib`。 Oracle、DB2 这类场景仍然需要把外部 JDBC 驱动放到 `ext-lib`。 -3. 进入部署脚本 docker-compose 目录, 执行 +3. 创建两份相互独立且仅供本次安装使用的密钥。`COMMON_SECRET` 是 Manager + 与所有独立 Collector 必须完全相同的 32 字节 AES 密钥; + `CLUSTER_AUTH_ACTIVE_SECRET` 是另一份消息认证密钥。升级时必须保留两者, + 并且不要将 `.env` 提交到版本库。 + + ```shell + umask 077 + printf 'COMMON_SECRET=%s\n' "$(openssl rand -hex 16)" > .env + printf 'CLUSTER_AUTH_ACTIVE_SECRET=%s\n' "$(openssl rand -hex 32)" >> .env + ``` + +4. 进入部署脚本 docker-compose 目录, 执行 `docker compose up -d` diff --git a/script/docker-compose/hertzbeat-postgresql-victoria-metrics/conf/application.yml b/script/docker-compose/hertzbeat-postgresql-victoria-metrics/conf/application.yml index 3108f5b9944..b5de81b7420 100644 --- a/script/docker-compose/hertzbeat-postgresql-victoria-metrics/conf/application.yml +++ b/script/docker-compose/hertzbeat-postgresql-victoria-metrics/conf/application.yml @@ -167,6 +167,7 @@ warehouse: db: 0 common: + secret: ${COMMON_SECRET:} queue: # memory or kafka type: memory @@ -227,6 +228,14 @@ scheduler: server: enabled: true port: 1158 + authentication: + mode: ${CLUSTER_AUTH_MODE:required} + active-key-id: ${CLUSTER_AUTH_ACTIVE_KEY_ID:primary} + active-secret: ${CLUSTER_AUTH_ACTIVE_SECRET:} + previous-key-id: ${CLUSTER_AUTH_PREVIOUS_KEY_ID:} + previous-secret: ${CLUSTER_AUTH_PREVIOUS_SECRET:} + max-clock-skew: ${CLUSTER_AUTH_MAX_CLOCK_SKEW:5m} + handshake-timeout: ${CLUSTER_AUTH_HANDSHAKE_TIMEOUT:3s} grafana: enabled: false diff --git a/script/docker-compose/hertzbeat-postgresql-victoria-metrics/docker-compose.yaml b/script/docker-compose/hertzbeat-postgresql-victoria-metrics/docker-compose.yaml index 4a581e1afc9..750c1aef071 100644 --- a/script/docker-compose/hertzbeat-postgresql-victoria-metrics/docker-compose.yaml +++ b/script/docker-compose/hertzbeat-postgresql-victoria-metrics/docker-compose.yaml @@ -71,6 +71,10 @@ services: restart: always environment: HERTZBEAT_COLLECTOR_MYSQL_QUERY_ENGINE: auto + COMMON_SECRET: ${COMMON_SECRET:?Set COMMON_SECRET in .env before startup} + CLUSTER_AUTH_MODE: ${CLUSTER_AUTH_MODE:-required} + CLUSTER_AUTH_ACTIVE_KEY_ID: ${CLUSTER_AUTH_ACTIVE_KEY_ID:-primary} + CLUSTER_AUTH_ACTIVE_SECRET: ${CLUSTER_AUTH_ACTIVE_SECRET:?Set CLUSTER_AUTH_ACTIVE_SECRET in .env before startup} TZ: Asia/Shanghai LANG: zh_CN.UTF-8 depends_on: From 5428958ef92f11d91cb51cbaf2950df7667870ea Mon Sep 17 00:00:00 2001 From: Logic Date: Tue, 4 Aug 2026 21:09:02 +0800 Subject: [PATCH 2/2] Harden cluster authentication rollout boundaries --- .../processor/GoOnlineProcessorTest.java | 35 ++++++++++++++++ .../remoting/netty/NettyRemotingAbstract.java | 23 ++++++++--- .../NettyRemotingAuthenticationTest.java | 40 ++++++++++++++++++- .../netty/NettyRemotingCompatibilityTest.java | 4 +- home/docs/help/collector.md | 12 +++++- home/docs/start/upgrade.md | 6 +++ 6 files changed, 109 insertions(+), 11 deletions(-) diff --git a/hertzbeat-collector/hertzbeat-collector-common/src/test/java/org/apache/hertzbeat/collector/dispatch/entrance/processor/GoOnlineProcessorTest.java b/hertzbeat-collector/hertzbeat-collector-common/src/test/java/org/apache/hertzbeat/collector/dispatch/entrance/processor/GoOnlineProcessorTest.java index 69fb3914ea6..850986989ad 100644 --- a/hertzbeat-collector/hertzbeat-collector-common/src/test/java/org/apache/hertzbeat/collector/dispatch/entrance/processor/GoOnlineProcessorTest.java +++ b/hertzbeat-collector/hertzbeat-collector-common/src/test/java/org/apache/hertzbeat/collector/dispatch/entrance/processor/GoOnlineProcessorTest.java @@ -19,7 +19,10 @@ import com.google.common.collect.Lists; import com.google.protobuf.ByteString; +import org.apache.hertzbeat.collector.timer.WheelTimerTask; import org.apache.hertzbeat.collector.timer.TimerDispatcher; +import org.apache.hertzbeat.common.constants.CommonConstants; +import org.apache.hertzbeat.common.entity.job.Configmap; import org.apache.hertzbeat.common.entity.job.Job; import org.apache.hertzbeat.common.entity.job.Metrics; import org.apache.hertzbeat.common.entity.message.ClusterMsg; @@ -33,6 +36,7 @@ import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; /** * Test for GoOnlineProcessor @@ -106,4 +110,35 @@ void shouldIgnoreAesSecretFromNetworkResponse() { AesUtil.setDefaultSecretKey(AesUtil.DEFAULT_ENCODE_RULES); } } + + @Test + void legacyCollectorCannotUseCredentialJobAfterManagerFirstUpgrade() { + String managerSecret = "manager-key-1234"; + String plaintext = "database-password"; + String ciphertext = AesUtil.aesEncode(plaintext, managerSecret); + AesUtil.setDefaultSecretKey(AesUtil.DEFAULT_ENCODE_RULES); + try { + ClusterMsg.Message response = ClusterMsg.Message.newBuilder() + .setType(ClusterMsg.MessageType.GO_ONLINE) + .setDirection(ClusterMsg.Direction.RESPONSE) + .build(); + goOnlineProcessor.handle(null, response); + Job credentialedJob = Job.builder() + .app("test") + .configmap(Lists.newArrayList(Configmap.builder() + .key("password") + .type(CommonConstants.PARAM_TYPE_PASSWORD) + .value(ciphertext) + .build())) + .metrics(Lists.newArrayList(Metrics.builder().interval(60L).build())) + .build(); + + new WheelTimerTask(credentialedJob, timeout -> { }); + + assertEquals(ciphertext, credentialedJob.getConfigmap().get(0).getValue()); + assertNotEquals(plaintext, credentialedJob.getConfigmap().get(0).getValue()); + } finally { + AesUtil.setDefaultSecretKey(AesUtil.DEFAULT_ENCODE_RULES); + } + } } diff --git a/hertzbeat-remoting/src/main/java/org/apache/hertzbeat/remoting/netty/NettyRemotingAbstract.java b/hertzbeat-remoting/src/main/java/org/apache/hertzbeat/remoting/netty/NettyRemotingAbstract.java index 3c2732885a4..a82844d465e 100644 --- a/hertzbeat-remoting/src/main/java/org/apache/hertzbeat/remoting/netty/NettyRemotingAbstract.java +++ b/hertzbeat-remoting/src/main/java/org/apache/hertzbeat/remoting/netty/NettyRemotingAbstract.java @@ -215,7 +215,12 @@ protected void channelActive(ChannelHandlerContext ctx) { } if (endpointRole == EndpointRole.SERVER) { ByteString channelNonce = messageAuthenticator.newChannelNonce(); - setChannelNonce(ctx.channel(), channelNonce); + if (!establishChannelNonce(ctx.channel(), channelNonce)) { + log.warn("Cluster channel nonce was already established for {}", + ctx.channel().remoteAddress()); + ctx.close(); + return; + } ClusterMsg.Message challenge = ClusterMsg.Message.newBuilder() .setDirection(ClusterMsg.Direction.REQUEST) .setType(ClusterMsg.MessageType.AUTH_CHALLENGE) @@ -253,7 +258,15 @@ private void processAuthenticationChallenge( ctx.close(); return; } - setChannelNonce(ctx.channel(), challenge.getMsg()); + if (!establishChannelNonce(ctx.channel(), challenge.getMsg())) { + ClusterMessageAuthMetrics.recordVerification( + ClusterMessageAuthenticator.VerificationResult.REPLAY, + endpointRole); + log.warn("Reject repeated cluster authentication challenge from {}", + ctx.channel().remoteAddress()); + ctx.close(); + return; + } notifyApplicationChannelActive(ctx.channel()); } @@ -306,11 +319,9 @@ private ByteString channelNonce(Channel channel) { : attribute.get(); } - private void setChannelNonce(Channel channel, ByteString channelNonce) { + private boolean establishChannelNonce(Channel channel, ByteString channelNonce) { Attribute attribute = channel.attr(AUTH_CHANNEL_NONCE); - if (attribute != null) { - attribute.set(channelNonce); - } + return attribute != null && attribute.compareAndSet(null, channelNonce); } protected void channelIdle(ChannelHandlerContext ctx, Object evt) throws Exception { diff --git a/hertzbeat-remoting/src/test/java/org/apache/hertzbeat/remoting/netty/NettyRemotingAuthenticationTest.java b/hertzbeat-remoting/src/test/java/org/apache/hertzbeat/remoting/netty/NettyRemotingAuthenticationTest.java index dba2566f704..bdbb492956d 100644 --- a/hertzbeat-remoting/src/test/java/org/apache/hertzbeat/remoting/netty/NettyRemotingAuthenticationTest.java +++ b/hertzbeat-remoting/src/test/java/org/apache/hertzbeat/remoting/netty/NettyRemotingAuthenticationTest.java @@ -27,8 +27,11 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import com.google.protobuf.ByteString; import io.netty.channel.Channel; import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.embedded.EmbeddedChannel; +import io.netty.util.AttributeKey; import io.micrometer.core.instrument.Metrics; import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import java.net.InetSocketAddress; @@ -116,6 +119,28 @@ void shouldVerifyInboundAndSignProcessorResponse() { verify(context, never()).close(); } + @Test + void shouldRejectRepeatedChallengeAndKeepChannelNonce() { + TestRemoting remoting = new TestRemoting( + NettyRemotingAbstract.EndpointRole.CLIENT, + ClusterMessageAuthConfig.Mode.REQUIRED); + EmbeddedChannel channel = new EmbeddedChannel(); + ChannelHandlerContext context = mock(ChannelHandlerContext.class); + when(context.channel()).thenReturn(channel); + ByteString firstNonce = ByteString.copyFrom(new byte[32]); + byte[] replacementBytes = new byte[32]; + java.util.Arrays.fill(replacementBytes, (byte) 1); + ByteString replacementNonce = ByteString.copyFrom(replacementBytes); + + remoting.receive(context, challenge(firstNonce)); + remoting.receive(context, challenge(replacementNonce)); + + AttributeKey nonceKey = + AttributeKey.valueOf(NettyRemotingAbstract.class, "authChannelNonce"); + assertEquals(firstNonce, channel.attr(nonceKey).get()); + verify(context).close(); + } + private ChannelHandlerContext context() { Channel channel = mock(Channel.class); when(channel.remoteAddress()).thenReturn(new InetSocketAddress("127.0.0.1", 1158)); @@ -132,10 +157,23 @@ private ClusterMsg.Message request() { .build(); } + private ClusterMsg.Message challenge(ByteString nonce) { + return ClusterMsg.Message.newBuilder() + .setDirection(ClusterMsg.Direction.REQUEST) + .setType(ClusterMsg.MessageType.AUTH_CHALLENGE) + .setAuthVersion(ClusterMessageAuthenticator.AUTH_VERSION) + .setMsg(nonce) + .build(); + } + private static class TestRemoting extends NettyRemotingAbstract { TestRemoting(ClusterMessageAuthConfig.Mode mode) { - super(null, EndpointRole.SERVER, mode == null ? null : config(mode), () -> null); + this(EndpointRole.SERVER, mode); + } + + TestRemoting(EndpointRole endpointRole, ClusterMessageAuthConfig.Mode mode) { + super(null, endpointRole, mode == null ? null : config(mode), () -> null); } void receive(ChannelHandlerContext context, ClusterMsg.Message message) { diff --git a/hertzbeat-remoting/src/test/java/org/apache/hertzbeat/remoting/netty/NettyRemotingCompatibilityTest.java b/hertzbeat-remoting/src/test/java/org/apache/hertzbeat/remoting/netty/NettyRemotingCompatibilityTest.java index 8003bd670ea..0d167850804 100644 --- a/hertzbeat-remoting/src/test/java/org/apache/hertzbeat/remoting/netty/NettyRemotingCompatibilityTest.java +++ b/hertzbeat-remoting/src/test/java/org/apache/hertzbeat/remoting/netty/NettyRemotingCompatibilityTest.java @@ -48,7 +48,7 @@ class NettyRemotingCompatibilityTest { @ParameterizedTest(name = "{0}") @MethodSource("compatibleVersionPairs") - void shouldExchangeMessagesAcrossRollingUpgradePairs( + void shouldExchangeTransportMessagesAcrossRollingUpgradePairs( String description, ClusterMessageAuthConfig.Mode serverMode, ClusterMessageAuthConfig.Mode clientMode) throws Exception { @@ -140,7 +140,7 @@ private static Stream compatibleVersionPairs() { arguments("old manager and old collector", null, null), arguments("old manager and new optional collector", null, ClusterMessageAuthConfig.Mode.OPTIONAL), - arguments("new optional manager and old collector", + arguments("new optional manager and old collector (transport only)", ClusterMessageAuthConfig.Mode.OPTIONAL, null), arguments("new optional manager and new optional collector", ClusterMessageAuthConfig.Mode.OPTIONAL, diff --git a/home/docs/help/collector.md b/home/docs/help/collector.md index 48bc3bf6701..4c075a44c75 100644 --- a/home/docs/help/collector.md +++ b/home/docs/help/collector.md @@ -151,6 +151,10 @@ Use this order to avoid interrupting collection: Do not upgrade Manager before its old Collectors have the new secret and optional mode configured. The preceding local-key change intentionally stops Manager from sending encryption key material over unauthenticated Netty. +Although an old Collector can complete a heartbeat exchange with a new Manager +in optional mode, that pair is not functionally compatible: the old Collector +cannot obtain the AES key and therefore cannot decrypt credentialed jobs. This +is why Collector-first order is mandatory rather than a recommendation. ### Key Rotation @@ -171,8 +175,12 @@ Manager sends a fresh random challenge for every Netty connection. The challenge is covered by every subsequent signature, so a message captured from one connection cannot be replayed through another Manager instance or after a reconnect. A bounded local cache rejects duplicate signatures on the active -connection. This connection-level binding supports multi-Manager deployments -without a shared replay database. +connection. The cache holds at most 65,536 signatures. If one process accepts +more unique messages than that within the configured replay window, capacity +eviction can allow an older duplicate to pass until its timestamp leaves the +clock-skew window. The per-connection challenge still prevents that message +from being replayed on another connection. This connection-level binding +supports multi-Manager deployments without a shared replay database. Authentication timestamps use the configured `max-clock-skew`. Keep Manager and Collector clocks synchronized. Stale, future, replayed, unknown-key, diff --git a/home/docs/start/upgrade.md b/home/docs/start/upgrade.md index cf72714fec1..8465394075e 100644 --- a/home/docs/start/upgrade.md +++ b/home/docs/start/upgrade.md @@ -76,6 +76,12 @@ Collector messages: remove the optional override. The shipped `required` mode then rejects unsigned messages. +The order in step 6 is mandatory. Optional mode lets a new Manager and old +Collector exchange heartbeat messages, but the old Collector no longer +receives the AES key from the Manager and cannot decrypt credentialed jobs. +Do not start any Manager upgrade until every Collector runs the new binary with +the shared `COMMON_SECRET` already provisioned. + Fresh Docker Compose installations must create the documented `.env` before `docker compose up`; Compose fails interpolation with an actionable message when either secret is absent. Existing Compose installations must preserve the