You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@rocketmq.apache.org by li...@apache.org on 2026/09/11 03:43:55 UTC

(rocketmq) branch develop updated: [ISSUE #10825] feat(proxy): Implement the Proxy Admin gRPC management interface (#10826)

This is an automated email from the ASF dual-hosted git repository.

lizhimins pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/rocketmq.git


The following commit(s) were added to refs/heads/develop by this push:
     new d38f81e787 [ISSUE #10825] feat(proxy): Implement the Proxy Admin gRPC management interface (#10826)
d38f81e787 is described below

commit d38f81e787c2bb3d05a601192a8014e771b829a4
Author: zhaohai <33...@users.noreply.github.com>
AuthorDate: Fri Sep 11 11:43:49 2026 +0800

    [ISSUE #10825] feat(proxy): Implement the Proxy Admin gRPC management interface (#10826)
    
    Implement the control-plane admin capability as a gRPC service
    served from the proxy process (NOT connecting to broker remoting/grpc
    directly), per the rocketmq-apis admin.proto contract.
    ---------
    Co-authored-by: lizhimins <70...@qq.com>
---
 .bazelignore                                       |   18 +
 .github/workflows/bazel.yml                        |    2 +
 .github/workflows/codeql_analysis.yml              |   18 +-
 .github/workflows/coverage.yml                     |    2 +
 .github/workflows/integration-test.yml             |    2 +
 .github/workflows/maven.yaml                       |    2 +
 .gitmodules                                        |   21 +
 WORKSPACE                                          |   88 +-
 auth/BUILD.bazel                                   |    4 +-
 bazel/rocketmq_apis.BUILD                          |   33 +
 .../rocketmq/common/constant/GrpcConstants.java    |    8 +
 docs/proxy-admin-zh.md                             |  240 +++
 docs/proxy-admin.md                                |  370 +++++
 pom.xml                                            |    6 +-
 proxy/BUILD.bazel                                  |    4 +-
 .../org/apache/rocketmq/proxy/ProxyStartup.java    |   48 +-
 .../apache/rocketmq/proxy/config/ProxyConfig.java  |   59 +
 .../rocketmq/proxy/grpc/GrpcServerBuilder.java     |   43 +-
 .../proxy/grpc/admin/AdminModelConverter.java      |  372 +++++
 .../grpc/admin/ProxyAdminAuthInterceptor.java      |  277 ++++
 .../proxy/grpc/admin/ProxyAdminForwarder.java      |  356 ++++
 .../proxy/grpc/admin/ProxyAdminGrpcService.java    | 1735 ++++++++++++++++++++
 .../grpc/v2/DefaultGrpcMessagingActivity.java      |    8 +
 .../proxy/grpc/v2/GrpcMessagingApplication.java    |    4 +
 .../proxy/grpc/v2/channel/GrpcChannelManager.java  |    5 +
 .../proxy/grpc/v2/channel/GrpcClientChannel.java   |   16 +
 .../proxy/grpc/v2/common/GrpcConverter.java        |   13 +-
 .../proxy/processor/DefaultMessagingProcessor.java |    4 +
 .../proxy/service/ClusterServiceManager.java       |    1 +
 .../proxy/service/LocalServiceManager.java         |    1 +
 .../rocketmq/proxy/service/admin/AdminService.java |   90 +-
 .../proxy/service/admin/DefaultAdminService.java   |  229 ++-
 .../service/relay/ClusterProxyRelayService.java    |   47 +-
 .../proxy/service/relay/ProxyRelayRequest.java     |  118 ++
 .../proxy/grpc/GrpcServerInterceptorOrderTest.java |  102 ++
 .../proxy/grpc/admin/AdminModelConverterTest.java  |  533 ++++++
 .../grpc/admin/ProxyAdminAuthInterceptorTest.java  |  269 +++
 .../grpc/admin/ProxyAdminGrpcServiceTest.java      | 1495 +++++++++++++++++
 .../grpc/v2/channel/GrpcClientChannelTest.java     |   73 +
 .../service/admin/DefaultAdminServiceTest.java     |  386 ++++-
 rocketmq-apis                                      |    1 +
 rocketmq-proto/BUILD.bazel                         |   92 ++
 rocketmq-proto/pom.xml                             |  143 ++
 test/BUILD.bazel                                   |    2 +-
 44 files changed, 7238 insertions(+), 102 deletions(-)

diff --git a/.bazelignore b/.bazelignore
new file mode 100644
index 0000000000..17dc28aa5b
--- /dev/null
+++ b/.bazelignore
@@ -0,0 +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
+#
+#   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.
+
+rocketmq-apis
diff --git a/.github/workflows/bazel.yml b/.github/workflows/bazel.yml
index a19bcfc7d5..b93d21b647 100644
--- a/.github/workflows/bazel.yml
+++ b/.github/workflows/bazel.yml
@@ -21,6 +21,8 @@ jobs:
         os: [ubuntu-latest]
     steps:
       - uses: actions/checkout@v4
+        with:
+          submodules: true
       - name: Cache Bazel repository
         uses: actions/cache@v4
         with:
diff --git a/.github/workflows/codeql_analysis.yml b/.github/workflows/codeql_analysis.yml
index fb6ea62a0f..d310d187ac 100644
--- a/.github/workflows/codeql_analysis.yml
+++ b/.github/workflows/codeql_analysis.yml
@@ -18,6 +18,8 @@ jobs:
     steps:
       - name: Checkout repository
         uses: actions/checkout@v3
+        with:
+          submodules: true
       - name: Cache Maven packages
         uses: actions/cache@v3
         with:
@@ -26,11 +28,19 @@ jobs:
           restore-keys: |
             ${{ runner.os }}-maven-
       # Initializes the CodeQL tools for scanning.
+      - name: Set up JDK 8
+        uses: actions/setup-java@v4
+        with:
+          java-version: 8
+          distribution: "corretto"
       - name: Initialize CodeQL
-        uses: github/codeql-action/init@v2
+        uses: github/codeql-action/init@v3
         with:
           languages: java
-      - name: Autobuild
-        uses: github/codeql-action/autobuild@v2
+      # Explicit build instead of autobuild: rocketmq-proto is generated from
+      # the rocketmq-apis submodule at compile time, which autobuild cannot
+      # handle reliably.
+      - name: Build with Maven
+        run: mvn -B compile -DskipTests -Dmaven.javadoc.skip=true
       - name: Perform CodeQL Analysis
-        uses: github/codeql-action/analyze@v2
+        uses: github/codeql-action/analyze@v3
diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml
index 28d874a354..517a1b3e87 100644
--- a/.github/workflows/coverage.yml
+++ b/.github/workflows/coverage.yml
@@ -14,6 +14,8 @@ jobs:
     runs-on: ubuntu-latest
     steps:
       - uses: actions/checkout@master
+        with:
+          submodules: true
       - name: Set up JDK 8
         uses: actions/setup-java@v4
         with:
diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml
index 6a28111d0b..ad78075e03 100644
--- a/.github/workflows/integration-test.yml
+++ b/.github/workflows/integration-test.yml
@@ -28,6 +28,8 @@ jobs:
 
       - name: Checkout
         uses: actions/checkout@v2
+        with:
+          submodules: true
 
       - name: Set up JDK ${{ matrix.jdk }}
         uses: actions/setup-java@v4
diff --git a/.github/workflows/maven.yaml b/.github/workflows/maven.yaml
index 15584e2cd1..629b4a245b 100644
--- a/.github/workflows/maven.yaml
+++ b/.github/workflows/maven.yaml
@@ -22,6 +22,8 @@ jobs:
     steps:
       - name: Checkout
         uses: actions/checkout@v2
+        with:
+          submodules: true
       - name: Set up JDK ${{ matrix.jdk }}
         uses: actions/setup-java@v4
         with:
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000000..9d5374f16e
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,21 @@
+# 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.
+
+[submodule "rocketmq-apis"]
+	path = rocketmq-apis
+	url = https://github.com/apache/rocketmq-apis.git
+	branch = main
diff --git a/WORKSPACE b/WORKSPACE
index 4287bddded..96b1560709 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -14,7 +14,7 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 #
-load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")
+load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive", "http_file")
 
 RULES_JVM_EXTERNAL_TAG = "4.2"
 
@@ -71,9 +71,9 @@ maven_install(
         "org.bouncycastle:bcpkix-jdk18on:1.83",
         "com.google.code.gson:gson:2.9.0",
         "com.googlecode.concurrentlinkedhashmap:concurrentlinkedhashmap-lru:1.4.2",
-        "org.apache.rocketmq:rocketmq-proto:2.1.2",
-        "com.google.protobuf:protobuf-java:3.20.1",
-        "com.google.protobuf:protobuf-java-util:3.20.1",
+        "com.google.protobuf:protobuf-java:3.24.4",
+        "com.google.protobuf:protobuf-java-util:3.24.4",
+        "javax.annotation:javax.annotation-api:1.3.2",
         "com.conversantmedia:disruptor:1.2.10",
         "org.apache.tomcat:annotations-api:6.0.53",
         "com.google.code.findbugs:jsr305:3.0.2",
@@ -82,13 +82,13 @@ maven_install(
         "org.openjdk.jmh:jmh-core:1.19",
         "org.openjdk.jmh:jmh-generator-annprocess:1.19",
         "com.github.ben-manes.caffeine:caffeine:2.9.3",
-        "io.grpc:grpc-services:1.47.0",
-        "io.grpc:grpc-netty-shaded:1.47.0",
-        "io.grpc:grpc-context:1.47.0",
-        "io.grpc:grpc-stub:1.47.0",
-        "io.grpc:grpc-api:1.47.0",
-        "io.grpc:grpc-protobuf:1.47.0",
-        "io.grpc:grpc-testing:1.47.0",
+        "io.grpc:grpc-services:1.50.0",
+        "io.grpc:grpc-netty-shaded:1.50.0",
+        "io.grpc:grpc-context:1.50.0",
+        "io.grpc:grpc-stub:1.50.0",
+        "io.grpc:grpc-api:1.50.0",
+        "io.grpc:grpc-protobuf:1.50.0",
+        "io.grpc:grpc-testing:1.50.0",
         "org.springframework:spring-core:5.3.27",
         "io.opentelemetry:opentelemetry-exporter-otlp:1.47.0",
         "io.opentelemetry:opentelemetry-exporter-prometheus:1.47.0-alpha",
@@ -125,6 +125,72 @@ maven_install(
     ],
 )
 
+# Well-known type protos (google/protobuf/{timestamp,duration}.proto). The
+# standalone protoc executable does not bundle them, so they have to be placed on
+# the include path explicitly.
+http_archive(
+    name = "com_google_protobuf_wkt",
+    build_file_content = """
+filegroup(
+    name = "wkt_protos",
+    srcs = glob(["*.proto"]),
+    visibility = ["//visibility:public"],
+)
+""",
+    sha256 = "616bb3536ac1fff3fb1a141450fa28b875e985712170ea7f1bfe5e5fc41e2cd8",
+    strip_prefix = "protobuf-24.4/src/google/protobuf",
+    urls = ["https://github.com/protocolbuffers/protobuf/archive/refs/tags/v24.4.tar.gz"],
+)
+
+# protoc / grpc codegen binaries. rules_jvm_external can only resolve jar
+# artifacts, so the prebuilt executables are fetched directly. Using prebuilt
+# binaries avoids the grpc-java Bazel toolchain, which would build protoc and
+# the grpc plugin from C++ sources.
+http_file(
+    name = "com_google_protobuf_protoc_linux_x86_64",
+    downloaded_file_path = "protoc",
+    executable = True,
+    sha256 = "59a70515db36977cf29ab09323b469dca51dd6572a5e08a731d741f22eef2b6c",
+    urls = ["https://repo1.maven.org/maven2/com/google/protobuf/protoc/3.24.4/protoc-3.24.4-linux-x86_64.exe"],
+)
+
+http_file(
+    name = "com_google_protobuf_protoc_osx_x86_64",
+    downloaded_file_path = "protoc",
+    executable = True,
+    sha256 = "8689519587d41b7af9e3b3f2e8c3d1335a23315857d157f61c26c10a1c61a9fa",
+    urls = ["https://repo1.maven.org/maven2/com/google/protobuf/protoc/3.24.4/protoc-3.24.4-osx-x86_64.exe"],
+)
+
+http_file(
+    name = "io_grpc_protoc_gen_grpc_java_linux_x86_64",
+    downloaded_file_path = "protoc-gen-grpc-java",
+    executable = True,
+    sha256 = "52dcbe738d3c920d7744780c67417309fe4ed990b380e9d1cf073c0654656191",
+    urls = ["https://repo1.maven.org/maven2/io/grpc/protoc-gen-grpc-java/1.53.0/protoc-gen-grpc-java-1.53.0-linux-x86_64.exe"],
+)
+
+http_file(
+    name = "io_grpc_protoc_gen_grpc_java_osx_x86_64",
+    downloaded_file_path = "protoc-gen-grpc-java",
+    executable = True,
+    sha256 = "97da9c1a408fb23391853273272a9c04f1b8ba6a2ce3c18ff95eab9cc91f0388",
+    urls = ["https://repo1.maven.org/maven2/io/grpc/protoc-gen-grpc-java/1.53.0/protoc-gen-grpc-java-1.53.0-osx-x86_64.exe"],
+)
+
+# The rocketmq-apis git submodule (apache main), which supplies
+# apache/rocketmq/v2/*.proto including the admin.proto.
+#
+# The submodule ships its own BUILD files, but they pull in toolchains this
+# workspace does not declare (graknlabs_bazel_distribution, googleapis), so the
+# directory is listed in .bazelignore (keeps `bazel build //...` from trying to
+# build it) and is surfaced here through a minimal build file instead.
+new_local_repository(
+    name = "rocketmq_apis",
+    build_file = "//bazel:rocketmq_apis.BUILD",
+    path = "rocketmq-apis",
+)
+
 http_archive(
     name = "io_buildbuddy_buildbuddy_toolchain",
     sha256 = "b12273608db627eb14051eb75f8a2134590172cd69392086d392e25f3954ea6e",
diff --git a/auth/BUILD.bazel b/auth/BUILD.bazel
index da4962e452..ba3d8ad39f 100644
--- a/auth/BUILD.bazel
+++ b/auth/BUILD.bazel
@@ -28,7 +28,7 @@ java_library(
         "@maven//:org_apache_commons_commons_lang3",
         "@maven//:commons_collections_commons_collections",
         "@maven//:com_alibaba_fastjson2_fastjson2",
-        "@maven//:org_apache_rocketmq_rocketmq_proto",
+        "//rocketmq-proto:rocketmq-proto",
         "@maven//:org_slf4j_slf4j_api",
         "@maven//:com_github_ben_manes_caffeine_caffeine",
         "@maven//:io_grpc_grpc_api",
@@ -55,7 +55,7 @@ java_library(
         "@maven//:org_apache_commons_commons_lang3",
         "@maven//:commons_collections_commons_collections",
         "@maven//:com_alibaba_fastjson2_fastjson2",
-        "@maven//:org_apache_rocketmq_rocketmq_proto",
+        "//rocketmq-proto:rocketmq-proto",
         "@maven//:org_slf4j_slf4j_api",
         "@maven//:com_github_ben_manes_caffeine_caffeine",
         "@maven//:io_grpc_grpc_api",
diff --git a/bazel/rocketmq_apis.BUILD b/bazel/rocketmq_apis.BUILD
new file mode 100644
index 0000000000..ce0c27b9df
--- /dev/null
+++ b/bazel/rocketmq_apis.BUILD
@@ -0,0 +1,33 @@
+# 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.
+
+# Build file for the rocketmq-apis git submodule.
+#
+# The submodule ships its own BUILD files (root + java/ + cpp/), but those pull
+# in toolchains this workspace does not declare (graknlabs_bazel_distribution,
+# googleapis). Rather than dragging all of that in, the directory is listed in
+# .bazelignore (so `bazel build //...` never tries to build it) and is exposed to
+# this workspace as the external repository @rocketmq_apis through a minimal
+# build file instead.
+#
+# The proto set is a glob over the whole v2 directory, so admin.proto is
+# included alongside definition.proto and service.proto.
+filegroup(
+    name = "v2_protos",
+    srcs = glob(["apache/rocketmq/v2/*.proto"]),
+    visibility = ["//visibility:public"],
+)
diff --git a/common/src/main/java/org/apache/rocketmq/common/constant/GrpcConstants.java b/common/src/main/java/org/apache/rocketmq/common/constant/GrpcConstants.java
index 96293e72ec..9fc4bcf8c6 100644
--- a/common/src/main/java/org/apache/rocketmq/common/constant/GrpcConstants.java
+++ b/common/src/main/java/org/apache/rocketmq/common/constant/GrpcConstants.java
@@ -73,4 +73,12 @@ public class GrpcConstants {
 
     public static final Metadata.Key<String> CHANNEL_ID
         = Metadata.Key.of("x-mq-channel-id", Metadata.ASCII_STRING_MARSHALLER);
+
+    /**
+     * Loop-prevention marker for proxy admin RPCs: set to {@code "true"} by a proxy that forwards
+     * an admin call to the peer proxy owning the target client, so the peer never forwards it
+     * again.
+     */
+    public static final Metadata.Key<String> ADMIN_FORWARDED
+        = Metadata.Key.of("x-mq-admin-forwarded", Metadata.ASCII_STRING_MARSHALLER);
 }
diff --git a/docs/proxy-admin-zh.md b/docs/proxy-admin-zh.md
new file mode 100644
index 0000000000..8656fb40cc
--- /dev/null
+++ b/docs/proxy-admin-zh.md
@@ -0,0 +1,240 @@
+# Proxy Admin 管理接口
+
+> 英文版见 `docs/proxy-admin.md`。
+
+## 1. 动机
+
+RocketMQ 5.0 把客户端访问放到了无状态的 Proxy 后面,但运维侧仍然通过 broker 侧的结构(broker 上的 `ConsumerManager`、Remoting 时代的 admin 命令)来观测客户端。连到 Proxy 的 gRPC 客户端对这些工具不可见:控制面无法回答"哪些 SDK 客户端在线、它们订阅了什么、是否健康",只能依赖间接的指标推断。
+
+控制面 dashboard 需要一个标准的服务端接口来读取完整的 gRPC 客户端数据。本次改动在 Proxy 上实现这个接口。
+
+## 2. 契约选择:上游 `Admin` service
+
+gRPC 契约是**上游 `Admin` service**,定义在 `apache/rocketmq-apis` 仓库(main 分支,作为 git submodule 引入,见 §6)的 `apache/rocketmq/v2/admin.proto` 中。本实现刻意**不**在协议层引入自定义的 `ProxyAdminService`:控制面 RPC 接口位于 dashboard 和多语言 SDK 已经在消费的、经过社区评审的版本化 proto 中,并与上游演进保持增量兼容。
+
+proxy 实现 `AdminGrpc.AdminImplBase`(全部 16 个 RPC),并把它绑定在一个独立的 admin gRPC server 上。
+
+## 3. 目标
+
+1. 在 Proxy 上提供一个独立、专用的 gRPC Admin server,与数据面 `MessagingService` 隔离。
+2. 来自 rocketmq-apis main 的稳定、向后兼容契约(`Admin` service,`apache/rocketmq/v2/admin.proto`)。
+3. 通过专用的 `proxy.admin.*` ACL 2.0 资源实现一等公民的鉴权,区分只读与高权限动作。
+4. 集群级语义但不把拓扑泄漏进协议:每个 proxy 都暴露连接/订阅数据的集群可见视图,并把客户端定向 RPC 转发到持有该客户端的 proxy。
+
+## 4. 设计决策
+
+### D1 — 服务放置
+
+上游 `Admin` service 绑定在自己的 gRPC server/端口(`grpcAdminServerPort`,默认 8088)上,有自己的拦截器链,与数据面(8081)分离。整个接口由全局 kill switch `grpcAdminServerEnable` 门控,**默认为 false**:admin server 是 opt-in 的,除非显式启用否则不会启动。
+
+理由:控制面流量绝不能与数据面竞争,且 admin 端口可以只对运维网络开放防火墙。admin server 刻意不暴露 channelz 或 proto reflection。它复用数据面的 `GrpcChannelManager` / `GrpcClientSettingsManager`,使在线客户端对 admin 查询可见。本特性引入的运行时线程池均归实例所有并接入 Proxy 生命周期:admin timeout scheduler 随 `ProxyAdminGrpcService` 关闭,blocking gateway executor 随 `AdminService` 关闭。
+
+### D2 — 鉴权:专用 `proxy.admin.*` 资源
+
+凭据通过标准 gRPC `Authorization` metadata 传入(与数据面相同的方案,ACL 2.0 签名)。认证以拦截器方式执行,顺序排在标准链路的 `HeaderInterceptor` **之后**(因 gRPC 按注册的逆序调用拦截器,故注册在其之前)。这个顺序是必需的:`HeaderInterceptor` 会把入站的 `x-mq-channel-id` 替换为从传输层派生的 channel id,而认证结果按 channel id 缓存,若在客户端自带的值上做认证,一条连接的请求就能复用另一条连接的缓存结果、跳过签名校验。
+
+每个 RPC 映射到一个资源 + 一个动作:
+
+| 资源 | RPC | 动作 |
+|---|---|---|
+| `proxy.admin.client` | ListSubscription, ListConsumerConnection (List); DescribeSubscription, DescribeGroupAccumulation, GetConsumerRunningInfo, QueryTimeSpan (Get) | List / Get |
+| `proxy.admin.config` | ChangeLogLevel | Update |
+| `proxy.admin.connection` | PrintThreadStackTrace, VerifyMessage | Update(高权限)|
+| `proxy.admin.route` | GetTopicRoute | Get |
+| `proxy.admin.ops` | GetProxyRuntimeStats, DescribeTopicStatus, QueryMessage (Get); ResetGroupOffset (Update); DeleteSubscription (Delete); AdminSendMessage (Pub) | Get / Update / Delete / Pub |
+
+建模说明:ACL 2.0 的资源类型是 cluster/namespace/topic/group。admin 资源建模为 CLUSTER 类型的字面量,使用保留名称(资源 key `cluster:proxy.admin.<module>`),从而在不与真实集群名冲突、不改动鉴权核心的前提下实现精确的最小权限匹配。
+
+模式:
+- 集群鉴权关闭、`grpcAdminServerAuthEnable=false` → 开放接口(与数据面语义相同);
+- 集群鉴权开启 → 针对每方法 `proxy.admin.*` 资源的标准 authenticate + authorize 流程;
+- `grpcAdminServerAuthEnable=true` → fail-closed。admin 接口必须既能认证调用方**又能**强制每方法 ACL,因此该模式要求集群级的认证**和**授权开关都开启。任一关闭时所有请求都被拒绝(认证关 → `UNAUTHENTICATED`; 授权关 → `FAILED_PRECONDITION`),而不是在没有真正权限校验的情况下放行。这是刻意为之:ACL 2.0 评估器本身受集群授权开关门控,若在授权关闭时服务请求,会静默放行每个调用,包括破坏性的那些。
+
+审计:每个被服务的 RPC 都会向鉴权审计 logger 写入 `[PROXY-ADMIN-AUDIT] subject/method/resource/action/sourceIp`(与 ACL 2.0 引擎自身的审计日志一起,满足 Console 用户 + AK + 资源 + 操作的审计四元组要求)。
+
+### D3 — 多 proxy 语义:集群可见,协议不含拓扑
+
+一个 gRPC 客户端同一时刻只连到一个 proxy,但 dashboard 仍需从任意单个 admin 端点看到整个集群。两个机制在不给协议加 scope/peering 字段的前提下做到这点:
+
+- **连接 / 订阅数据是集群可见的。** 列表类 RPC(`ListConsumerConnection`、`ListSubscription`、`DescribeSubscription`)把 broker 侧的 `ConsumerConnection` 视图(覆盖 remoting 客户端,以及被 `HeartbeatSyncer` 在 proxy 间同步的 gRPC 客户端)与本 proxy 自己的 `ConsumerManager`(覆盖本地接入的 gRPC v2 客户端)合并。结果按 group + topic 去重,因此应答不随在线消费者数量膨胀,也不取决于查询的是哪个 proxy。
+- **客户端定向 RPC 转发到持有该客户端的 proxy。** `PrintThreadStackTrace`、`VerifyMessage`、`GetConsumerRunningInfo` 需要只有持有方 proxy 才有的实时 telemetry 流。`ProxyAdminForwarder` 识别出属于 peer 的客户端(其 `ConsumerManager` 条目是 `RemoteChannel`,`getRemoteProxyIp()` 为 peer 的 `localServeAddr`),把整个 RPC——连同原始 metadata——转发到 peer 的 admin 端口,并用 `x-mq-admin-forwarded` 头防止转发成环。peer 的 admin 端口按集群统一假设(心跳同步载荷不携带它); 异构 admin 端口的集群需要先把它加入心跳记录。
+
+`GetProxyRuntimeStats` 刻意报告**本进程**的状态(自身的连接/生产者/消费者计数),因此想要按节点统计的 dashboard 直接查询每个 proxy。
+
+### D4 — 每个 RPC 的数据源
+
+| 数据源 | RPC | 机制 |
+|---|---|---|
+| 集群可见连接 | ListConsumerConnection, ListSubscription, DescribeSubscription | broker 侧 `ConsumerConnection`(remoting + proxy 间同步的客户端)与本 proxy 的 `ConsumerManager`(本地 gRPC v2 客户端)合并 |
+| 客户端定向 telemetry relay | PrintThreadStackTrace, VerifyMessage, GetConsumerRunningInfo | relay 到持有方 `GrpcClientChannel` 的 telemetry command; 客户端在 peer 上时转发到 peer proxy(D3)|
+| Broker 网关(异步、多 broker fan-out)| DescribeTopicStatus, DescribeGroupAccumulation, ResetGroupOffset, QueryMessage, QueryTimeSpan, GetTopicRoute, DeleteSubscription | proxy 自己的异步 `AdminService` remoting 网关; 每个 broker hop 有独立 deadline,慢/无响应的 broker 不会挂起整个 RPC |
+| Proxy producer 路径 | AdminSendMessage | `MessagingProcessor.sendMessage`(定时 / FIFO 消息通过 system properties 生效)|
+| Proxy 运行时 | ChangeLogLevel | root logger 上重定位的 logback API |
+
+所有 broker 侧调用都走异步 `AdminService` 网关并并发扇出到所有相关 broker,因此 gRPC 执行线程不会被阻塞。
+
+### D5 — 协议覆盖
+
+连接/订阅列表是集群可见的,同时包含 remoting 客户端(通过 broker 侧 `ConsumerConnection`)和 gRPC v2 客户端(通过 proxy 的 `ConsumerManager`)。客户端定向 telemetry RPC(`PrintThreadStackTrace`、`VerifyMessage`、`GetConsumerRunningInfo`)作用于持有 proxy telemetry 流的 gRPC v2 客户端,并支持跨 proxy 转发; 它们的完整 running-info 载荷受 telemetry 协议能承载的内容限制(见 §8 诚实边界)。
+
+## 5. 能力映射
+
+| 需求 | 交付为 | 说明 |
+|---|---|---|
+| ListClients(按 group/topic/clientId 前缀过滤,分页)| `ListConsumerConnection(group[, topic])` | clientId 前缀过滤和分页在上游契约里无法表达; 这是本次部分交付的已记录边界 |
+| DescribeClient(SDK 版本、订阅、心跳、鉴权、Pop 进度)| `DescribeSubscription`(每客户端订阅)+ `GetConsumerRunningInfo`(订阅 + 运行信息)| 心跳/鉴权状态/Pop 进度字段在上游契约里不存在 |
+| ListClientsByGroup / ListClientsByTopic | `ListConsumerConnection(group[, topic])` | 完全覆盖 |
+| 多 proxy 集群聚合 | 集群可见连接数据 + 客户端定向 RPC 转发 | 见 D3 |
+
+M2+ 项(配置热更新、配额、连接控制、Pop/batch 诊断、路由观测流)不属于上游 `Admin` 契约,不在本次交付范围内。
+
+## 6. 构建 proto 源码
+
+`apache/rocketmq-apis` 仓库(main 分支,包含 `apache/rocketmq/v2/admin.proto`)作为 **git submodule** 引入(`rocketmq-apis/`,见 `.gitmodules`); CI workflow 用 `submodules: true` 检出。
+
+- **Maven**:`rocketmq-proto` 模块在构建时通过 `protobuf-maven-plugin` 为 `apache/rocketmq/v2/{definition,service,admin}.proto` 生成 Java + gRPC stub; 它的版本跟随 reactor(`${revision}`),因此构建本仓库不需要已发布的 proto 制品。
+- **Bazel**:`//rocketmq-proto:rocketmq-proto` 通过对 submodule 的 `genrule`(作为 `@rocketmq_apis` 外部仓库呈现)构建相同的类,使用 pin 的 `protoc` / `protoc-gen-grpc-java` 二进制和 well-known-type proto。
+- 其他消费者(dashboard、SDK)继续使用 rocketmq-apis 发布的制品; 只有本仓库从源码构建 proto。
+
+## 7. 配置参考
+
+| Key | 默认值 | 含义 |
+|---|---|---|
+| `grpcAdminServerEnable` | false | kill switch; admin 接口是 opt-in 的。设为 `true` 启动 admin gRPC server; `false`(默认)= 不启动 |
+| `grpcAdminServerPort` | 8088 | 专用 admin gRPC 端口(<=0 禁用)|
+| `grpcAdminServerAuthEnable` | false | fail-closed 模式; 要求集群认证**和**授权都已开启(见 D2)|
+| `grpcAdminServerRequestTimeoutMillis` | 3000 | admin RPC 扇出的每个 broker hop 的 deadline |
+
+## 8. 诚实边界
+
+admin 接口绝不填写自己无法如实提供的字段; 下列情况以明确 status 回应或留空,而不是伪造:
+
+- **DeleteSubscription 在开源里事实上是 no-op。** 开源代码没有任何地方写入 `SubscriptionGroupConfig.subscriptionDataSet`(broker 只读它),因此没有持久化的 per-topic 订阅可删。该 RPC 返回 `NOT_FOUND` 并说明原因,**消费组及其位点保持不动**。需要该 RPC 的发行版用外部订阅存储填充同一字段。
+- **QueryMessage** 支持按 `message_id`(unique-key 索引)和 `message_key` 查询。`subscription` / `lite_topic` / 纯时间范围扫描三种变体需要开源 broker 不提供的分页游标,返回 `BAD_REQUEST`。
+- **GetProxyRuntimeStats** 的 `in_tps` / `out_tps` 留空:开源 proxy 没有进程级吞吐计数器,填 0 会与空闲 proxy 无法区分。
+- **GetConsumerRunningInfo** 对 gRPC v2 客户端只返回 `subscriptions`:v2 telemetry 协议没有承载 `properties`、`message_queue_table`、`consume_status_table` 的回包。该 RPC 返回 `OK` 并附说明性 status message,而不是空壳。
+- **DescribeTopicStatus** 的 `create_timestamp` / `tags` 留空,因为 broker 不记录 topic 创建时间。
+
+## 9. 里程碑
+
+- M1(本次交付):基于上游 `Admin` service 的在线客户端查询 — ListConsumerConnection / DescribeSubscription / GetConsumerRunningInfo / DescribeGroupAccumulation,以集群可见方式并支持跨 proxy 转发提供,外加完整的 16-RPC `Admin` 接口用于 dashboard 集成。
+- 未来(需要上游 proto 演进):客户端列表的 clientId 前缀过滤 / 分页、心跳和鉴权状态字段,以及 M2+ 接口(配置/配额/连接/路由观测)。
+
+## 10. 验收标准映射
+
+| 标准 | 状态 |
+|---|---|
+| 设计文档 + 稳定向后兼容的 proto 契约 | 本文档 + 上游 `admin.proto`(rocketmq-apis main)|
+| 客户端查询 RPC 合入服务端仓库 | `ProxyAdminGrpcService` 在 proxy 上实现全部 16 个 `Admin` RPC |
+| 独立 ACL 控制、只读/高危分离、最小权限文档 | D2 资源/动作 + 下方 §11(最小权限配置指南)|
+| 与 dashboard 的 E2E | 契约 = 上游 `Admin` service,为 dashboard 集成冻结(跨仓库)|
+
+## 11. 最小权限配置指南
+
+admin 接口对每个 RPC 按专用的 `proxy.admin.*` ACL 2.0 资源鉴权(见上方 §4 决策 D2)。本节给出每个运维角色的最小权限策略。
+
+### 11.1 资源与动作模型
+
+资源(ACL 2.0 key; 建模为带保留名称的 cluster 类型字面量):
+
+| 资源 key | 保护 |
+|---|---|
+| `cluster:proxy.admin.client` | 在线客户端查询与客户端诊断 |
+| `cluster:proxy.admin.config` | proxy 运行时日志级别变更 |
+| `cluster:proxy.admin.connection` | 客户端定向 telemetry 命令:线程栈、verify message(高危)|
+| `cluster:proxy.admin.route` | topic 路由视图 |
+| `cluster:proxy.admin.ops` | broker 侧运维:stats/topic status/message query(读)和 reset offset / delete subscription / admin send(高危)|
+
+动作分类:
+
+- 只读:`Get`, `List`
+- 高权限(变更 / 破坏性):`Update`, `Delete`, `Pub`
+
+服务端把每个 RPC 映射到恰好一个(资源, 动作)对; 授予只读动作绝不会授权高权限 RPC。
+
+### 11.2 角色模板
+
+所有命令针对集群的任意 broker/namesrv 运行(ACL 2.0 存储)。先创建用户:
+
+```bash
+sh mqadmin createUser -n <namesrv-addr> -u <username> -p <password>
+```
+
+#### 角色 A — 只读观察者(dashboard 服务账号)
+
+在线客户端、订阅、堆积、诊断、配置/路由视图。
+
+```bash
+sh mqadmin updateAcl -n <namesrv-addr> \
+  -s user:rip2-ro \
+  -r cluster:proxy.admin.client,cluster:proxy.admin.config,cluster:proxy.admin.route,cluster:proxy.admin.ops \
+  -a Get,List \
+  -d Allow
+```
+
+说明:`proxy.admin.ops` 上的 `Get,List` 覆盖只读的 broker 侧 RPC; 变更类 ops RPC 需要 `Update`/`Delete`/`Pub`,仍被拒绝。
+
+#### 角色 B — on-call 运维(观察者 + 客户端诊断命令)
+
+角色 A 加上向已连接客户端下发 telemetry 命令(线程栈 / verify message)的能力。
+
+```bash
+sh mqadmin updateAcl -n <namesrv-addr> \
+  -s user:rip2-oncall \
+  -r cluster:proxy.admin.connection \
+  -a Update \
+  -d Allow
+# 外加上面的角色 A 授权
+```
+
+#### 角色 C — Admin(完全控制,break-glass)
+
+offset 重置、订阅删除、admin send、客户端诊断命令。
+
+```bash
+sh mqadmin updateAcl -n <namesrv-addr> \
+  -s user:rip2-admin \
+  -r cluster:proxy.admin.client,cluster:proxy.admin.config,cluster:proxy.admin.connection,cluster:proxy.admin.route,cluster:proxy.admin.ops \
+  -a Get,List,Update,Delete,Pub \
+  -d Allow
+```
+
+(把角色 C 账号控制在最小范围; 每次使用都会以 `[PROXY-ADMIN-AUDIT]` 前缀记录在鉴权审计日志里。)
+
+#### 环境限制(推荐)
+
+通过 `-i` sourceIp 选项把 admin 访问限制到运维网络:
+
+```bash
+sh mqadmin updateAcl -n <namesrv-addr> \
+  -s user:rip2-ro \
+  -r cluster:proxy.admin.client \
+  -a Get,List \
+  -d Allow \
+  -i 10.0.0.0/8
+```
+
+### 11.3 Fail-Closed 模式
+
+默认情况下 admin server 遵循集群级认证/授权开关(与数据面行为相同)。fail-closed 模式让 admin 接口拒绝服务任何它无法完全授权的请求:
+
+```properties
+# proxy.json / -D grpcAdminServerAuthEnable=true
+grpcAdminServerAuthEnable: true
+```
+
+`grpcAdminServerAuthEnable=true` 时,admin 接口要求集群认证**和**授权开关都开启。认证关闭时请求以 `UNAUTHENTICATED` 拒绝; 授权关闭时以 `FAILED_PRECONDITION` 拒绝。这保证 `proxy.admin.*` ACL 被真正强制执行——当 admin 端口无法做网络隔离时使用。
+
+### 11.4 启用 / 禁用接口
+
+接口**默认关闭**(`grpcAdminServerEnable=false`)。要启用:
+
+```properties
+grpcAdminServerEnable: true    # 在 grpcAdminServerPort 上启动 admin gRPC server
+```
+
+要保持禁用(默认),保留 `grpcAdminServerEnable: false`,或把 `grpcAdminServerPort` 设为 0 / 负数,此时 admin gRPC server 完全不启动。
+
+### 11.5 审计
+
+每个被服务的 admin RPC 都会把 subject(Console 登录用户 / AK)、method、resource、action 和 source IP 记录到鉴权审计 logger; 被拒绝的请求由 ACL 2.0 引擎自身记录。这满足审计四元组要求(Console 用户 + AK + 资源 + 操作)。
diff --git a/docs/proxy-admin.md b/docs/proxy-admin.md
new file mode 100644
index 0000000000..7380b6f966
--- /dev/null
+++ b/docs/proxy-admin.md
@@ -0,0 +1,370 @@
+# Proxy Admin Management Interface
+
+> 中文版见 `docs/proxy-admin-zh.md`。
+
+## 1. Motivation
+
+RocketMQ 5.0 moved client access behind the stateless Proxy, but operations still
+observe clients through broker-side structures (`ConsumerManager` on the broker,
+Remoting-era admin commands). gRPC clients attached to a Proxy are invisible to
+those tools: the control plane cannot answer "which SDK clients are online, what
+do they subscribe to, are they healthy" without indirect metrics heuristics.
+
+A control-plane dashboard needs a standard server-side interface to read complete
+gRPC client data. This change implements that interface on the Proxy itself.
+
+## 2. Contract choice: upstream `Admin` service
+
+The gRPC contract is the **upstream `Admin` service** defined in
+`apache/rocketmq/v2/admin.proto` of the `apache/rocketmq-apis` repository (main
+branch, consumed as a git submodule — see §6). This implementation deliberately
+does NOT introduce a bespoke `ProxyAdminService` in the protocol layer: the
+control-plane RPC surface lives in the same versioned, community-reviewed proto
+that the dashboard and multi-language SDKs already consume, and stays
+additive-compatible with upstream evolution.
+
+The proxy implements `AdminGrpc.AdminImplBase` (all 16 RPCs) and binds it on a
+dedicated admin gRPC server.
+
+## 3. Goals
+
+1. A dedicated, independent gRPC Admin server on the Proxy, isolated from the
+   data-plane `MessagingService`.
+2. Stable, backward-compatible contract from rocketmq-apis main (`Admin`
+   service, `apache/rocketmq/v2/admin.proto`).
+3. First-class authorization under dedicated `proxy.admin.*` ACL 2.0 resources
+   with read-only / high-privilege action separation.
+4. Cluster-wide semantics without leaking topology into the protocol: every
+   proxy exposes a cluster-visible view of connection/subscription data and
+   forwards client-directed RPCs to the proxy that owns the client.
+
+## 4. Design Decisions
+
+### D1 — Service placement
+
+The upstream `Admin` service is bound on its own gRPC server/port
+(`grpcAdminServerPort`, default 8088) with its own interceptor chain, separate
+from the data plane (8081). The whole surface is gated by a global kill switch
+`grpcAdminServerEnable`, which **defaults to false**: the admin server is opt-in
+and is not started unless explicitly enabled.
+
+Rationale: control-plane traffic must never contend with the data plane, and the
+admin port can be firewalled to the operations network only. The admin server
+intentionally does NOT expose channelz or proto reflection. It reuses the data
+plane's `GrpcChannelManager` / `GrpcClientSettingsManager` so online clients are
+visible to admin queries. Runtime executors introduced by this feature are
+instance-owned and registered in the Proxy lifecycle: the admin timeout scheduler
+stops with `ProxyAdminGrpcService`, and the blocking gateway executor stops with
+`AdminService`.
+
+### D2 — Authorization: dedicated `proxy.admin.*` resources
+
+Credentials arrive in the standard gRPC `Authorization` metadata (same scheme as
+the data plane, ACL 2.0 signature). Authentication runs as an interceptor, ordered
+**after** the standard pipeline's `HeaderInterceptor` (registered before it, since
+gRPC invokes interceptors in reverse registration order). That ordering is
+load-bearing: `HeaderInterceptor` replaces the inbound `x-mq-channel-id` with the
+channel id derived from the transport, and authentication results are cached per
+channel id, so authenticating on the client-supplied value would let a request on
+one connection reuse another connection's cached success and skip signature
+verification.
+
+Every RPC maps to one resource + one action:
+
+| Resource | RPCs | Actions |
+|---|---|---|
+| `proxy.admin.client` | ListSubscription, ListConsumerConnection (List); DescribeSubscription, DescribeGroupAccumulation, GetConsumerRunningInfo, QueryTimeSpan (Get) | List / Get |
+| `proxy.admin.config` | ChangeLogLevel | Update |
+| `proxy.admin.connection` | PrintThreadStackTrace, VerifyMessage | Update (high privilege) |
+| `proxy.admin.route` | GetTopicRoute | Get |
+| `proxy.admin.ops` | GetProxyRuntimeStats, DescribeTopicStatus, QueryMessage (Get); ResetGroupOffset (Update); DeleteSubscription (Delete); AdminSendMessage (Pub) | Get / Update / Delete / Pub |
+
+Modeling note: ACL 2.0 resource types are cluster/namespace/topic/group. The
+admin resources are modeled as CLUSTER-typed literals with reserved names
+(resource keys `cluster:proxy.admin.<module>`), which yields exact
+least-privilege matching without colliding with real cluster names and without
+changing the auth core.
+
+Modes:
+- cluster auth disabled, `grpcAdminServerAuthEnable=false` → open surface (same
+  semantics as the data plane);
+- cluster auth enabled → standard authenticate + authorize pipeline against the
+  per-method `proxy.admin.*` resource;
+- `grpcAdminServerAuthEnable=true` → fail-closed. The admin surface must be able
+  to both authenticate the caller **and** enforce the per-method ACL, so this
+  mode requires the cluster-wide authentication **and** authorization switches to
+  be on. If either is off every request is refused (authentication off →
+  `UNAUTHENTICATED`; authorization off → `FAILED_PRECONDITION`) rather than served
+  without a real permission check. This is deliberate: the ACL 2.0 evaluator is
+  itself gated by the cluster authorization switch, so serving requests with
+  authorization off would silently pass every call, including the destructive
+  ones.
+
+Audit: every served RPC writes `[PROXY-ADMIN-AUDIT] subject/method/resource/
+action/sourceIp` to the auth audit logger (satisfies the Console-user + AK +
+resource + operation audit tuple requirement together with the ACL 2.0 engine's
+own audit log).
+
+### D3 — Multi-proxy semantics: cluster-visible, no topology in the protocol
+
+A gRPC client is attached to exactly one proxy at a time, but a dashboard must
+still see the whole cluster from any single admin endpoint. Two mechanisms make
+that work without adding scope/peering fields to the protocol:
+
+- **Connection / subscription data is cluster-visible.** The listing RPCs
+  (`ListConsumerConnection`, `ListSubscription`, `DescribeSubscription`) merge
+  the broker-side `ConsumerConnection` view (which covers remoting clients, and
+  gRPC clients replicated between proxies by `HeartbeatSyncer`) with this proxy's
+  own `ConsumerManager` (which covers the gRPC v2 clients attached locally). The
+  result is deduplicated by group + topic, so the answer does not scale with the
+  number of online consumers and does not depend on which proxy is queried.
+- **Client-directed RPCs are forwarded to the owning proxy.**
+  `PrintThreadStackTrace`, `VerifyMessage` and `GetConsumerRunningInfo` need the
+  live telemetry stream that only the owning proxy holds. `ProxyAdminForwarder`
+  detects a client owned by a peer (its `ConsumerManager` entry is a
+  `RemoteChannel` whose `getRemoteProxyIp()` is the peer's `localServeAddr`) and
+  forwards the whole RPC — original metadata included — to the peer's admin port,
+  guarding against loops with the `x-mq-admin-forwarded` header. The peer admin
+  port is assumed uniform across the cluster (the heartbeat sync payload does not
+  carry it); a cluster running heterogeneous admin ports would need that added to
+  the heartbeat record first.
+
+`GetProxyRuntimeStats` intentionally reports the **local** process (its own
+connection/producer/consumer counts), so a dashboard that wants per-node stats
+queries each proxy directly.
+
+### D4 — Data sources per RPC
+
+| Source | RPCs | Mechanism |
+|---|---|---|
+| Cluster-visible connections | ListConsumerConnection, ListSubscription, DescribeSubscription | broker-side `ConsumerConnection` (remoting + peer-synced clients) merged with this proxy's `ConsumerManager` (local gRPC v2 clients) |
+| Client-directed telemetry relay | PrintThreadStackTrace, VerifyMessage, GetConsumerRunningInfo | telemetry command relayed to the owning `GrpcClientChannel`; forwarded to the peer proxy when the client is remote (D3) |
+| Broker gateway (async, multi-broker fan-out) | DescribeTopicStatus, DescribeGroupAccumulation, ResetGroupOffset, QueryMessage, QueryTimeSpan, GetTopicRoute, DeleteSubscription | the proxy's own async `AdminService` remoting gateway; each broker hop is bounded by its own deadline so a slow/unresponsive broker cannot hang the RPC |
+| Proxy producer path | AdminSendMessage | `MessagingProcessor.sendMessage` (system properties honoured for timer / FIFO messages) |
+| Proxy runtime | ChangeLogLevel | relocated logback API on the root logger |
+
+Every broker-facing call goes through the asynchronous `AdminService` gateway and
+is fanned out to all relevant brokers concurrently, so the gRPC executor thread is
+never blocked.
+
+### D5 — Protocol coverage
+
+The connection/subscription listings are cluster-visible and include both
+remoting clients (via the broker-side `ConsumerConnection`) and gRPC v2 clients
+(via the proxy's `ConsumerManager`). The client-directed telemetry RPCs
+(`PrintThreadStackTrace`, `VerifyMessage`, `GetConsumerRunningInfo`) operate on the
+gRPC v2 client that holds a proxy telemetry stream, with cross-proxy forwarding;
+their full running-info payload is bounded by what the telemetry protocol can
+carry (see §8, honest boundaries).
+
+## 5. Capability mapping
+
+| Requirement | Delivered as | Notes |
+|---|---|---|
+| ListClients (filter by group/topic/clientId prefix, paged) | `ListConsumerConnection(group[, topic])` | clientId-prefix filter and pagination are not expressible in the upstream contract; documented boundary of this partial delivery |
+| DescribeClient (SDK version, subscriptions, heartbeat, auth, Pop progress) | `DescribeSubscription` (per-client subscriptions) + `GetConsumerRunningInfo` (subscriptions + running info) | heartbeat/auth-status/Pop-progress fields do not exist in the upstream contract |
+| ListClientsByGroup / ListClientsByTopic | `ListConsumerConnection(group[, topic])` | fully covered |
+| Multi-proxy cluster aggregation | cluster-visible connection data + client-directed RPC forwarding | see D3 |
+
+M2+ items (config hot update, quotas, connection control, Pop/batch diagnostics,
+route observation streaming) are not part of the upstream `Admin` contract and
+are out of scope for this delivery.
+
+## 6. Building the proto sources
+
+The `apache/rocketmq-apis` repository (main branch, containing
+`apache/rocketmq/v2/admin.proto`) is consumed as a **git submodule**
+(`rocketmq-apis/`, see `.gitmodules`); CI workflows check it out with
+`submodules: true`.
+
+- **Maven**: the `rocketmq-proto` module generates the Java + gRPC stubs for
+  `apache/rocketmq/v2/{definition,service,admin}.proto` at build time via
+  `protobuf-maven-plugin`; its version follows the reactor (`${revision}`), so
+  no published proto artifact is required to build this repository.
+- **Bazel**: `//rocketmq-proto:rocketmq-proto` builds the same classes through a
+  `genrule` over the submodule (surfaced as the `@rocketmq_apis` external
+  repository), using pinned `protoc` / `protoc-gen-grpc-java` binaries and the
+  well-known-type protos.
+- Other consumers (dashboard, SDKs) continue to use the artifact published by
+  rocketmq-apis; only this repository builds the proto from source.
+
+## 7. Configuration Reference
+
+| Key | Default | Meaning |
+|---|---|---|
+| `grpcAdminServerEnable` | false | kill switch; the admin surface is opt-in. Set `true` to start the admin gRPC server; `false` (default) = not started |
+| `grpcAdminServerPort` | 8088 | dedicated admin gRPC port (<=0 disables) |
+| `grpcAdminServerAuthEnable` | false | fail-closed mode; requires cluster authentication **and** authorization to be enabled (see D2) |
+| `grpcAdminServerRequestTimeoutMillis` | 3000 | per-broker-hop deadline for the broker calls an admin RPC fans out to |
+
+## 8. Honest boundaries
+
+The admin surface never fills a field it cannot truthfully supply; the following
+are answered with an explicit status or left unset rather than faked:
+
+- **DeleteSubscription is effectively a no-op in open source.** Nothing in the
+  open-source codebase writes `SubscriptionGroupConfig.subscriptionDataSet` (the
+  broker only reads it), so there is no persisted per-topic subscription to
+  remove. The RPC returns `NOT_FOUND` with an explanation and **leaves the
+  consumer group and its offsets untouched**. Distributions that need this RPC
+  back it with an external subscription store that populates the same field.
+- **QueryMessage** supports lookup by `message_id` (unique-key index) and
+  `message_key`. The `subscription` / `lite_topic` / pure time-range scan variants
+  need a paging cursor the open-source broker does not provide and return
+  `BAD_REQUEST`.
+- **GetProxyRuntimeStats** leaves `in_tps` / `out_tps` unset: the open-source
+  proxy keeps no per-process throughput counter, and reporting 0 would be
+  indistinguishable from an idle proxy.
+- **GetConsumerRunningInfo** over a gRPC v2 client returns only `subscriptions`:
+  the v2 telemetry protocol has no reply message carrying `properties`,
+  `message_queue_table` or `consume_status_table`. The RPC returns `OK` with an
+  explanatory status message rather than an empty shell.
+- **DescribeTopicStatus** leaves `create_timestamp` / `tags` unset because the
+  broker does not record a topic creation time.
+
+## 9. Milestones
+
+- M1 (this delivery): online client query over the upstream `Admin` service —
+  ListConsumerConnection / DescribeSubscription / GetConsumerRunningInfo /
+  DescribeGroupAccumulation, served cluster-visibly with cross-proxy forwarding,
+  plus the full 16-RPC `Admin` surface for dashboard integration.
+- Future (needs upstream proto evolution): clientId-prefix filter / pagination
+  for client listings, heartbeat & auth-status fields, and the M2+ surfaces
+  (config/quota/connection/route observation).
+
+## 10. Acceptance Criteria Mapping
+
+| Criterion | Status |
+|---|---|
+| Design document + stable backward-compatible proto contract | this document + upstream `admin.proto` (rocketmq-apis main) |
+| Client query RPCs merged into the server repo | `ProxyAdminGrpcService` implements all 16 `Admin` RPCs on the proxy |
+| Independent ACL control, read-only/high-risk separation, least-privilege doc | D2 resources/actions + §11 (Least-Privilege Configuration Guide) below |
+| E2E with the dashboard | contract = upstream `Admin` service, frozen for dashboard integration (cross-repo) |
+
+## 11. Least-Privilege Configuration Guide
+
+The admin surface authorizes every RPC against dedicated `proxy.admin.*`
+ACL 2.0 resources (see §4 decision D2 above). This section shows the
+minimum-permission policy for each operational role.
+
+### 11.1 Resource & Action Model
+
+Resources (ACL 2.0 keys; modeled as cluster-typed literals with reserved names):
+
+| Resource key | Protects |
+|---|---|
+| `cluster:proxy.admin.client` | online client query & client diagnostics |
+| `cluster:proxy.admin.config` | proxy runtime log-level change |
+| `cluster:proxy.admin.connection` | client-directed telemetry commands: thread stack, verify message (HIGH) |
+| `cluster:proxy.admin.route` | topic route view |
+| `cluster:proxy.admin.ops` | broker-facing ops: stats/topic status/message query (read) and reset offset / delete subscription / admin send (HIGH) |
+
+Action classes:
+
+- Read-only: `Get`, `List`
+- High privilege (mutating / disruptive): `Update`, `Delete`, `Pub`
+
+The server maps every RPC to exactly one (resource, action) pair; granting a
+read-only action can never authorize a high-privilege RPC.
+
+### 11.2 Role Templates
+
+All commands run against any broker/namesrv of the cluster (ACL 2.0 storage).
+Create users first:
+
+```bash
+sh mqadmin createUser -n <namesrv-addr> -u <username> -p <password>
+```
+
+#### Role A — Read-only observer (dashboard service account)
+
+Online clients, subscriptions, accumulation, diagnostics, config/route views.
+
+```bash
+sh mqadmin updateAcl -n <namesrv-addr> \
+  -s user:rip2-ro \
+  -r cluster:proxy.admin.client,cluster:proxy.admin.config,cluster:proxy.admin.route,cluster:proxy.admin.ops \
+  -a Get,List \
+  -d Allow
+```
+
+Note: `Get,List` on `proxy.admin.ops` covers the read-only broker-facing RPCs;
+the mutating ops RPCs require `Update`/`Delete`/`Pub` and stay denied.
+
+#### Role B — On-call operator (observer + client diagnostics commands)
+
+Role A plus the ability to direct telemetry commands (thread stack / verify
+message) at a connected client.
+
+```bash
+sh mqadmin updateAcl -n <namesrv-addr> \
+  -s user:rip2-oncall \
+  -r cluster:proxy.admin.connection \
+  -a Update \
+  -d Allow
+# plus the Role A grant above
+```
+
+#### Role C — Admin (full control, break-glass)
+
+Offset reset, subscription deletion, admin send, client diagnostics commands.
+
+```bash
+sh mqadmin updateAcl -n <namesrv-addr> \
+  -s user:rip2-admin \
+  -r cluster:proxy.admin.client,cluster:proxy.admin.config,cluster:proxy.admin.connection,cluster:proxy.admin.route,cluster:proxy.admin.ops \
+  -a Get,List,Update,Delete,Pub \
+  -d Allow
+```
+
+(Keep Role C accounts to a minimum; every use is recorded in the auth audit
+log with the `[PROXY-ADMIN-AUDIT]` prefix.)
+
+#### Environment restriction (recommended)
+
+Restrict admin access to the operations network via the `-i` sourceIp option:
+
+```bash
+sh mqadmin updateAcl -n <namesrv-addr> \
+  -s user:rip2-ro \
+  -r cluster:proxy.admin.client \
+  -a Get,List \
+  -d Allow \
+  -i 10.0.0.0/8
+```
+
+### 11.3 Fail-Closed Mode
+
+By default the admin server follows the cluster-wide authentication/authorization
+switches (same behavior as the data plane). Fail-closed mode makes the admin
+surface refuse to serve anything it cannot fully authorize:
+
+```properties
+# proxy.json / -D grpcAdminServerAuthEnable=true
+grpcAdminServerAuthEnable: true
+```
+
+With `grpcAdminServerAuthEnable=true`, the admin surface requires the cluster
+authentication **and** authorization switches to be enabled. If authentication is
+off, requests are rejected with `UNAUTHENTICATED`; if authorization is off, they
+are rejected with `FAILED_PRECONDITION`. This guarantees the `proxy.admin.*` ACL
+is genuinely enforced — use it when the admin port cannot be network-isolated.
+
+### 11.4 Enabling / Disabling the Surface
+
+The surface is **off by default** (`grpcAdminServerEnable=false`). To enable it:
+
+```properties
+grpcAdminServerEnable: true    # start the admin gRPC server on grpcAdminServerPort
+```
+
+To keep it disabled (the default), leave `grpcAdminServerEnable: false`, or set
+`grpcAdminServerPort` to 0 / negative — the admin gRPC server is then not started
+at all.
+
+### 11.5 Audit
+
+Every served admin RPC logs subject (Console login user / AK), method, resource,
+action and source IP to the auth audit logger; denied requests are logged by the
+ACL 2.0 engine itself. This satisfies the four-tuple audit requirement
+(Console user + AK + resource + operation).
diff --git a/pom.xml b/pom.xml
index 9cb13dde1f..e2a96df4a6 100644
--- a/pom.xml
+++ b/pom.xml
@@ -127,9 +127,8 @@
         <annotations-api.version>6.0.53</annotations-api.version>
         <extra-enforcer-rules.version>1.0-beta-4</extra-enforcer-rules.version>
         <concurrentlinkedhashmap-lru.version>1.4.2</concurrentlinkedhashmap-lru.version>
-        <rocketmq-proto.version>2.1.2</rocketmq-proto.version>
         <grpc.version>1.53.0</grpc.version>
-        <protobuf.version>3.20.1</protobuf.version>
+        <protobuf.version>3.24.4</protobuf.version>
         <disruptor.version>1.2.10</disruptor.version>
         <org.relection.version>0.9.11</org.relection.version>
         <caffeine.version>2.9.3</caffeine.version>
@@ -199,6 +198,7 @@
         <module>example</module>
         <module>container</module>
         <module>controller</module>
+        <module>rocketmq-proto</module>
         <module>proxy</module>
         <module>tieredstore</module>
     </modules>
@@ -663,7 +663,7 @@
             <dependency>
                 <groupId>${project.groupId}</groupId>
                 <artifactId>rocketmq-proto</artifactId>
-                <version>${rocketmq-proto.version}</version>
+                <version>${project.version}</version>
                 <exclusions>
                     <exclusion>
                         <groupId>*</groupId>
diff --git a/proxy/BUILD.bazel b/proxy/BUILD.bazel
index 8ddaa52382..f5883a2107 100644
--- a/proxy/BUILD.bazel
+++ b/proxy/BUILD.bazel
@@ -57,7 +57,7 @@ java_library(
         "@maven//:io_opentelemetry_opentelemetry_sdk_common",
         "@maven//:io_opentelemetry_opentelemetry_sdk_metrics",
         "@maven//:org_apache_commons_commons_lang3",
-        "@maven//:org_apache_rocketmq_rocketmq_proto",
+        "//rocketmq-proto:rocketmq-proto",
         "@maven//:org_checkerframework_checker_qual",
         "@maven//:at_yawk_lz4_lz4_java",
         "@maven//:org_slf4j_slf4j_api",
@@ -103,7 +103,7 @@ java_library(
         "@maven//:io_opentelemetry_opentelemetry_exporter_otlp",
         "@maven//:io_opentelemetry_opentelemetry_exporter_prometheus",
         "@maven//:io_opentelemetry_opentelemetry_sdk",
-        "@maven//:org_apache_rocketmq_rocketmq_proto",
+        "//rocketmq-proto:rocketmq-proto",
         "@maven//:org_checkerframework_checker_qual",
         "@maven//:org_slf4j_slf4j_api",
         "@maven//:org_springframework_spring_core",
diff --git a/proxy/src/main/java/org/apache/rocketmq/proxy/ProxyStartup.java b/proxy/src/main/java/org/apache/rocketmq/proxy/ProxyStartup.java
index 1b38a19ae6..5c56f0fe55 100644
--- a/proxy/src/main/java/org/apache/rocketmq/proxy/ProxyStartup.java
+++ b/proxy/src/main/java/org/apache/rocketmq/proxy/ProxyStartup.java
@@ -39,7 +39,13 @@ import org.apache.rocketmq.proxy.config.ConfigurationManager;
 import org.apache.rocketmq.proxy.config.ProxyConfig;
 import org.apache.rocketmq.proxy.grpc.GrpcServer;
 import org.apache.rocketmq.proxy.grpc.GrpcServerBuilder;
+import org.apache.rocketmq.proxy.grpc.admin.ProxyAdminAuthInterceptor;
+import org.apache.rocketmq.proxy.grpc.admin.ProxyAdminForwarder;
+import org.apache.rocketmq.proxy.grpc.admin.ProxyAdminGrpcService;
+import org.apache.rocketmq.proxy.grpc.v2.DefaultGrpcMessagingActivity;
 import org.apache.rocketmq.proxy.grpc.v2.GrpcMessagingApplication;
+import org.apache.rocketmq.proxy.grpc.v2.channel.GrpcChannelManager;
+import org.apache.rocketmq.proxy.grpc.v2.common.GrpcClientSettingsManager;
 import org.apache.rocketmq.proxy.metrics.ProxyMetricsManager;
 import org.apache.rocketmq.proxy.processor.DefaultMessagingProcessor;
 import org.apache.rocketmq.proxy.processor.MessagingProcessor;
@@ -81,10 +87,13 @@ public class ProxyStartup {
             TlsCertificateManager tlsCertificateManager = new TlsCertificateManager();
             PROXY_START_AND_SHUTDOWN.appendStartAndShutdown(tlsCertificateManager);
 
-            // create grpcServer
+            // create grpcServer (data plane). Capture the application reference so the
+            // admin server can reuse the SAME GrpcChannelManager / GrpcClientSettingsManager
+            // that the data plane uses to track online clients.
+            GrpcMessagingApplication dataPlaneApplication = createServiceProcessor(messagingProcessor);
             GrpcServer grpcServer = GrpcServerBuilder.newBuilder(executor,
                     ConfigurationManager.getProxyConfig().getGrpcServerPort(), tlsCertificateManager)
-                .addService(createServiceProcessor(messagingProcessor))
+                .addService(dataPlaneApplication)
                 .addService(ChannelzService.newInstance(100))
                 .addService(ProtoReflectionService.newInstance())
                 .configInterceptor()
@@ -92,6 +101,41 @@ public class ProxyStartup {
                 .build();
             PROXY_START_AND_SHUTDOWN.appendStartAndShutdown(grpcServer);
 
+            // Dedicated admin gRPC server (control plane), gated by grpcAdminServerEnable. Reuses
+            // the data plane's GrpcChannelManager so admin queries can see the data-plane clients.
+            Integer adminPort = ConfigurationManager.getProxyConfig().getGrpcAdminServerPort();
+            if (ConfigurationManager.getProxyConfig().isGrpcAdminServerEnable()
+                && adminPort != null && adminPort > 0) {
+                DefaultGrpcMessagingActivity dataPlaneActivity =
+                    (DefaultGrpcMessagingActivity) dataPlaneApplication.getGrpcMessagingActivity();
+                GrpcChannelManager sharedChannelManager = dataPlaneActivity.getGrpcChannelManager();
+                GrpcClientSettingsManager sharedSettingsManager = dataPlaneActivity.getGrpcClientSettingsManager();
+                DefaultMessagingProcessor defaultProcessor = (DefaultMessagingProcessor) messagingProcessor;
+
+                // Forwards client-targeted admin RPCs to the proxy that owns the client channel.
+                ProxyAdminForwarder adminForwarder =
+                    new ProxyAdminForwarder(defaultProcessor.getServiceManager());
+                PROXY_START_AND_SHUTDOWN.appendStartAndShutdown(adminForwarder);
+
+                ProxyAdminGrpcService adminService = new ProxyAdminGrpcService(
+                    defaultProcessor.getServiceManager(),
+                    messagingProcessor,
+                    sharedChannelManager,
+                    sharedSettingsManager,
+                    adminForwarder);
+                PROXY_START_AND_SHUTDOWN.appendStartAndShutdown(adminService);
+                GrpcServer adminGrpcServer = GrpcServerBuilder.newBuilder(executor, adminPort, tlsCertificateManager)
+                    .addService(adminService)
+                    // authentication has to see the channel id HeaderInterceptor derives from the
+                    // transport, so it is ordered after the standard pipeline rather than appended
+                    .configInterceptor(new ProxyAdminAuthInterceptor(
+                        ConfigurationManager.getAuthConfig(), messagingProcessor))
+                    .shutdownTime(ConfigurationManager.getProxyConfig().getGrpcShutdownTimeSeconds(), TimeUnit.SECONDS)
+                    .build();
+                PROXY_START_AND_SHUTDOWN.appendStartAndShutdown(adminGrpcServer);
+                log.info("admin gRPC server will start on port {}", adminPort);
+            }
+
             RemotingProtocolServer remotingServer = new RemotingProtocolServer(messagingProcessor, tlsCertificateManager);
             PROXY_START_AND_SHUTDOWN.appendStartAndShutdown(remotingServer);
 
diff --git a/proxy/src/main/java/org/apache/rocketmq/proxy/config/ProxyConfig.java b/proxy/src/main/java/org/apache/rocketmq/proxy/config/ProxyConfig.java
index 8f6fb7ec1b..6ff495449c 100644
--- a/proxy/src/main/java/org/apache/rocketmq/proxy/config/ProxyConfig.java
+++ b/proxy/src/main/java/org/apache/rocketmq/proxy/config/ProxyConfig.java
@@ -90,6 +90,33 @@ public class ProxyConfig implements ConfigFile {
      */
     private String proxyMode = ProxyMode.CLUSTER.name();
     private Integer grpcServerPort = 8081;
+
+    /**
+     * Dedicated gRPC port for the Proxy Admin service. When &gt; 0 the proxy
+     * starts an independent admin gRPC server (separate ACL scope, isolated
+     * traffic) in addition to the data-plane gRPC server.
+     */
+    private Integer grpcAdminServerPort = 8088;
+    /**
+     * global kill switch for the Proxy Admin surface. When false the
+     * admin gRPC server is not started at all, regardless of {@link #grpcAdminServerPort}.
+     * Defaults to false: the admin surface is opt-in and must be explicitly enabled.
+     */
+    private boolean grpcAdminServerEnable = false;
+    /**
+     * When true the admin server enforces credential checks even if the cluster-wide
+     * authentication switch is off; requests without verifiable credentials are rejected
+     * (fail-closed mode). When false the admin server follows the cluster-wide
+     * authenticationEnabled/authorizationEnabled switches (same behavior as the data plane).
+     */
+    private boolean grpcAdminServerAuthEnable = false;
+    /**
+     * Per-request timeout in milliseconds for the broker calls an admin RPC fans out to.
+     * A single RPC may query several brokers concurrently, so this bounds each hop rather
+     * than the whole call.
+     */
+    private long grpcAdminServerRequestTimeoutMillis = 3000L;
+
     private long grpcShutdownTimeSeconds = 30;
     private int grpcBossLoopNum = 1;
     private int grpcWorkerLoopNum = PROCESSOR_NUMBER * 2;
@@ -492,6 +519,38 @@ public class ProxyConfig implements ConfigFile {
         this.grpcServerPort = grpcServerPort;
     }
 
+    public Integer getGrpcAdminServerPort() {
+        return grpcAdminServerPort;
+    }
+
+    public void setGrpcAdminServerPort(Integer grpcAdminServerPort) {
+        this.grpcAdminServerPort = grpcAdminServerPort;
+    }
+
+    public boolean isGrpcAdminServerEnable() {
+        return grpcAdminServerEnable;
+    }
+
+    public void setGrpcAdminServerEnable(boolean grpcAdminServerEnable) {
+        this.grpcAdminServerEnable = grpcAdminServerEnable;
+    }
+
+    public boolean isGrpcAdminServerAuthEnable() {
+        return grpcAdminServerAuthEnable;
+    }
+
+    public void setGrpcAdminServerAuthEnable(boolean grpcAdminServerAuthEnable) {
+        this.grpcAdminServerAuthEnable = grpcAdminServerAuthEnable;
+    }
+
+    public long getGrpcAdminServerRequestTimeoutMillis() {
+        return grpcAdminServerRequestTimeoutMillis;
+    }
+
+    public void setGrpcAdminServerRequestTimeoutMillis(long grpcAdminServerRequestTimeoutMillis) {
+        this.grpcAdminServerRequestTimeoutMillis = grpcAdminServerRequestTimeoutMillis;
+    }
+
     public long getGrpcShutdownTimeSeconds() {
         return grpcShutdownTimeSeconds;
     }
diff --git a/proxy/src/main/java/org/apache/rocketmq/proxy/grpc/GrpcServerBuilder.java b/proxy/src/main/java/org/apache/rocketmq/proxy/grpc/GrpcServerBuilder.java
index 35d035b993..0619d09ba5 100644
--- a/proxy/src/main/java/org/apache/rocketmq/proxy/grpc/GrpcServerBuilder.java
+++ b/proxy/src/main/java/org/apache/rocketmq/proxy/grpc/GrpcServerBuilder.java
@@ -17,6 +17,7 @@
 package org.apache.rocketmq.proxy.grpc;
 
 import io.grpc.BindableService;
+import io.grpc.ServerBuilder;
 import io.grpc.ServerInterceptor;
 import io.grpc.ServerServiceDefinition;
 import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder;
@@ -141,6 +142,13 @@ public class GrpcServerBuilder {
         return this;
     }
 
+    /**
+     * Registers an interceptor that runs <em>before</em> the pipeline installed by
+     * {@link #configInterceptor(ServerInterceptor...)}, because gRPC invokes server interceptors in
+     * reverse registration order. An interceptor that needs the normalized headers (the
+     * transport-derived channel id, the cleared authorization subject) must NOT be appended here;
+     * pass it to {@link #configInterceptor(ServerInterceptor...)} instead.
+     */
     public GrpcServerBuilder appendInterceptor(ServerInterceptor interceptor) {
         this.serverBuilder.intercept(interceptor);
         return this;
@@ -174,11 +182,36 @@ public class GrpcServerBuilder {
         return enableEpoll ? new EpollEventLoopGroup(threads) : new NioEventLoopGroup(threads);
     }
 
-    public GrpcServerBuilder configInterceptor() {
-        this.serverBuilder
-            .intercept(new GlobalExceptionInterceptor())
-            .intercept(new ContextInterceptor())
-            .intercept(new HeaderInterceptor());
+    /**
+     * Installs the standard pipeline, optionally with interceptors that must observe its normalized
+     * headers.
+     *
+     * @param postHeaderInterceptors interceptors to run after {@link HeaderInterceptor}, typically
+     *                               authentication
+     */
+    public GrpcServerBuilder configInterceptor(ServerInterceptor... postHeaderInterceptors) {
+        configureInterceptors(this.serverBuilder, postHeaderInterceptors);
         return this;
     }
+
+    /**
+     * Orders the standard pipeline and the interceptors that depend on it. gRPC invokes server
+     * interceptors in reverse registration order, so {@code postHeaderInterceptors} are registered
+     * first precisely so that they run last.
+     *
+     * <p>Authentication depends on this order. {@link HeaderInterceptor} replaces the inbound
+     * {@code x-mq-channel-id} with the channel id derived from the transport and clears the
+     * authorization subject. Authentication results are cached per channel id, so authenticating on
+     * the client-supplied value would let a request on one connection reuse the cached success of
+     * another connection and skip signature verification.
+     */
+    static void configureInterceptors(ServerBuilder<?> serverBuilder,
+        ServerInterceptor... postHeaderInterceptors) {
+        for (ServerInterceptor interceptor : postHeaderInterceptors) {
+            serverBuilder.intercept(interceptor);
+        }
+        serverBuilder.intercept(new GlobalExceptionInterceptor());
+        serverBuilder.intercept(new ContextInterceptor());
+        serverBuilder.intercept(new HeaderInterceptor());
+    }
 }
diff --git a/proxy/src/main/java/org/apache/rocketmq/proxy/grpc/admin/AdminModelConverter.java b/proxy/src/main/java/org/apache/rocketmq/proxy/grpc/admin/AdminModelConverter.java
new file mode 100644
index 0000000000..2b7bb918d6
--- /dev/null
+++ b/proxy/src/main/java/org/apache/rocketmq/proxy/grpc/admin/AdminModelConverter.java
@@ -0,0 +1,372 @@
+/*
+ * 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.rocketmq.proxy.grpc.admin;
+
+import apache.rocketmq.v2.Code;
+import apache.rocketmq.v2.DescribeGroupAccumulationResponse.GroupAccumulation;
+import apache.rocketmq.v2.DescribeTopicStatusResponse;
+import apache.rocketmq.v2.GetTopicRouteResponse;
+import apache.rocketmq.v2.MessageQueueItem;
+import apache.rocketmq.v2.MessageType;
+import apache.rocketmq.v2.QueryTimeSpanResponse;
+import apache.rocketmq.v2.Resource;
+import apache.rocketmq.v2.Status;
+import com.alibaba.fastjson2.JSON;
+import com.google.protobuf.Duration;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.apache.rocketmq.common.KeyBuilder;
+import org.apache.rocketmq.common.MixAll;
+import org.apache.rocketmq.common.TopicConfig;
+import org.apache.rocketmq.common.attribute.TopicMessageType;
+import org.apache.rocketmq.common.filter.ExpressionType;
+import org.apache.rocketmq.remoting.protocol.admin.ConsumeStats;
+import org.apache.rocketmq.remoting.protocol.admin.OffsetWrapper;
+import org.apache.rocketmq.remoting.protocol.body.Connection;
+import org.apache.rocketmq.remoting.protocol.body.QueueTimeSpan;
+import org.apache.rocketmq.remoting.protocol.route.TopicRouteData;
+
+/**
+ * Bridge between the broker's internal wire types ({@code org.apache.rocketmq.remoting.*}) and the
+ * gRPC admin contract ({@code apache.rocketmq.v2.*}, generated from rocketmq-apis).
+ *
+ * <p>This is the only class that knows both worlds: {@link ProxyAdminGrpcService} stays
+ * protocol-pure and the admin gateway stays remoting-pure, so neither layer leaks into the other.
+ * Converting a whole message body is deliberately NOT done here — the data plane already has
+ * {@code GrpcConverter.buildMessage(MessageExt)}, which is reused instead of being duplicated.
+ */
+final class AdminModelConverter {
+
+    private AdminModelConverter() {
+    }
+
+    static Status ok() {
+        return Status.newBuilder().setCode(Code.OK).build();
+    }
+
+    // -------------------------------------------------------------------------
+    // topic
+    // -------------------------------------------------------------------------
+
+    static DescribeTopicStatusResponse toTopicStatus(TopicConfig topicConfig, String topic) {
+        DescribeTopicStatusResponse.Builder builder = DescribeTopicStatusResponse.newBuilder()
+            .setStatus(ok())
+            .setTopicMessageType(toMessageType(topicConfig == null ? null : topicConfig.getTopicMessageType()));
+        if (topicConfig != null) {
+            // a topic creation timestamp is not recorded by the broker, so create_timestamp and
+            // tags stay unset rather than being filled with a value that would only look real
+            builder.setDescription("readQueueNums=" + topicConfig.getReadQueueNums()
+                + ", writeQueueNums=" + topicConfig.getWriteQueueNums()
+                + ", perm=" + topicConfig.getPerm());
+        } else {
+            builder.setDescription("topic config not available for " + topic);
+        }
+        return builder.build();
+    }
+
+    static MessageType toMessageType(TopicMessageType topicMessageType) {
+        if (topicMessageType == null) {
+            return MessageType.MESSAGE_TYPE_UNSPECIFIED;
+        }
+        switch (topicMessageType) {
+            case NORMAL:
+                return MessageType.NORMAL;
+            case FIFO:
+                return MessageType.FIFO;
+            case DELAY:
+                return MessageType.DELAY;
+            case TRANSACTION:
+                return MessageType.TRANSACTION;
+            case LITE:
+                return MessageType.LITE;
+            case UNSPECIFIED:
+            case MIXED:
+            case PRIORITY:
+            default:
+                // MessageType.UNRECOGNIZED cannot be set on a builder, so anything this proxy does
+                // not understand degrades to UNSPECIFIED instead of failing the whole RPC
+                return MessageType.MESSAGE_TYPE_UNSPECIFIED;
+        }
+    }
+
+    static GetTopicRouteResponse toTopicRoute(TopicRouteData topicRouteData) {
+        return GetTopicRouteResponse.newBuilder()
+            .setStatus(ok())
+            .setTopicRouteData(topicRouteData == null ? "{}" : JSON.toJSONString(topicRouteData))
+            .build();
+    }
+
+    // -------------------------------------------------------------------------
+    // consume time span
+    // -------------------------------------------------------------------------
+
+    /**
+     * The broker already computes min/max/consume timestamps and the delay per queue
+     * (RequestCode.QUERY_CONSUME_TIME_SPAN), so the response maps 1:1 and no offset table lookup
+     * is needed here.
+     */
+    static QueryTimeSpanResponse toQueryTimeSpan(List<QueueTimeSpan> queueTimeSpans) {
+        QueryTimeSpanResponse.Builder builder = QueryTimeSpanResponse.newBuilder().setStatus(ok());
+        if (queueTimeSpans == null) {
+            return builder.build();
+        }
+        for (QueueTimeSpan span : queueTimeSpans) {
+            if (span == null || span.getMessageQueue() == null) {
+                continue;
+            }
+            long delay = span.getDelayTime();
+            builder.addQueueTimeSpanList(QueryTimeSpanResponse.QueueTimeSpan.newBuilder()
+                .setMessageQueue(toMessageQueue(span.getMessageQueue()))
+                .setMinTimestamp(span.getMinTimeStamp())
+                .setMaxTimestamp(span.getMaxTimeStamp())
+                .setConsumeTimestamp(span.getConsumeTimeStamp())
+                .setDelayTimeMs(Math.max(delay, 0L))
+                .build());
+        }
+        return builder.build();
+    }
+
+    static apache.rocketmq.v2.MessageQueue toMessageQueue(org.apache.rocketmq.common.message.MessageQueue mq) {
+        return apache.rocketmq.v2.MessageQueue.newBuilder()
+            .setTopic(Resource.newBuilder().setName(mq.getTopic()).build())
+            .setBroker(apache.rocketmq.v2.Broker.newBuilder().setName(mq.getBrokerName()).build())
+            .setId(mq.getQueueId())
+            .build();
+    }
+
+    // -------------------------------------------------------------------------
+    // accumulation
+    // -------------------------------------------------------------------------
+
+    /**
+     * Aggregates consume stats collected from every broker of the cluster.
+     *
+     * <p>Per the contract {@code accumulation = inflight + ready}, where inflight counts messages
+     * already delivered but not yet acknowledged ({@code pullOffset - consumerOffset}) and ready
+     * counts messages still waiting ({@code brokerOffset - pullOffset}). Pop retry topics are
+     * folded into their normal topic so a caller asking about a topic sees its whole backlog; the
+     * pull retry topic is counted in the group total but kept out of the per-topic map because it
+     * is not a user-visible topic.
+     *
+     * @param statsByBroker consume stats per broker address; a null topic filter means the broker
+     *                      returned every topic the group has offsets for
+     * @param group         consumer group, needed to recognise its retry topics
+     */
+    static AccumulationResult toAccumulation(Map<String, ConsumeStats> statsByBroker, String group) {
+        long totalDiff = 0L;
+        long totalInflight = 0L;
+        long earliestLastConsume = Long.MAX_VALUE;
+        Map<String, long[]> byTopic = new HashMap<>();
+
+        String pullRetryTopic = MixAll.getRetryTopic(group);
+        for (ConsumeStats stats : statsByBroker.values()) {
+            if (stats == null || stats.getOffsetTable() == null) {
+                continue;
+            }
+            for (Map.Entry<org.apache.rocketmq.common.message.MessageQueue, OffsetWrapper> entry :
+                stats.getOffsetTable().entrySet()) {
+                org.apache.rocketmq.common.message.MessageQueue mq = entry.getKey();
+                OffsetWrapper wrapper = entry.getValue();
+                if (mq == null || wrapper == null) {
+                    continue;
+                }
+                long diff = Math.max(wrapper.getBrokerOffset() - wrapper.getConsumerOffset(), 0L);
+                long inflight = Math.max(wrapper.getPullOffset() - wrapper.getConsumerOffset(), 0L);
+                if (inflight > diff) {
+                    inflight = diff;
+                }
+                totalDiff += diff;
+                totalInflight += inflight;
+                if (diff > 0 && wrapper.getLastTimestamp() > 0) {
+                    earliestLastConsume = Math.min(earliestLastConsume, wrapper.getLastTimestamp());
+                }
+
+                String topic = mq.getTopic();
+                if (pullRetryTopic.equals(topic)) {
+                    continue;
+                }
+                String userTopic = KeyBuilder.parseNormalTopic(topic, group);
+                long[] acc = byTopic.computeIfAbsent(userTopic, k -> new long[3]);
+                acc[0] += diff;
+                acc[1] += inflight;
+                if (diff > 0 && wrapper.getLastTimestamp() > 0 && (acc[2] == 0L || wrapper.getLastTimestamp() < acc[2])) {
+                    acc[2] = wrapper.getLastTimestamp();
+                }
+            }
+        }
+
+        AccumulationResult result = new AccumulationResult(buildAccumulation(totalDiff, totalInflight, earliestLastConsume));
+        for (Map.Entry<String, long[]> entry : byTopic.entrySet()) {
+            long[] acc = entry.getValue();
+            result.byTopic.put(entry.getKey(), buildAccumulation(acc[0], acc[1], acc[2]));
+        }
+        return result;
+    }
+
+    private static GroupAccumulation buildAccumulation(long accumulation, long inflight, long lastConsumeTimestamp) {
+        GroupAccumulation.Builder builder = GroupAccumulation.newBuilder()
+            .setAccumulation(accumulation)
+            .setInflightMessages(inflight)
+            .setReadyMessages(Math.max(accumulation - inflight, 0L));
+        if (lastConsumeTimestamp > 0 && lastConsumeTimestamp != Long.MAX_VALUE) {
+            builder.setLastConsumeTimestamp(lastConsumeTimestamp);
+            if (accumulation > 0) {
+                long delayMillis = Math.max(System.currentTimeMillis() - lastConsumeTimestamp, 0L);
+                builder.setDeliverDelayTime(Duration.newBuilder()
+                    .setSeconds(delayMillis / 1000L)
+                    .setNanos((int) (delayMillis % 1000L) * 1_000_000)
+                    .build());
+            }
+        }
+        return builder.build();
+    }
+
+    static final class AccumulationResult {
+        final GroupAccumulation total;
+        final Map<String, GroupAccumulation> byTopic = new HashMap<>();
+
+        private AccumulationResult(GroupAccumulation total) {
+            this.total = total;
+        }
+    }
+
+    // -------------------------------------------------------------------------
+    // clients
+    // -------------------------------------------------------------------------
+
+    /**
+     * Builds a {@code ClientInfo} from a broker-side connection. The broker sees consumers that
+     * registered through any proxy of the cluster, which is why the admin surface prefers this
+     * over scanning only the local gRPC channels.
+     */
+    static apache.rocketmq.v2.ClientInfo toClientInfo(Connection connection,
+        apache.rocketmq.v2.MessageModel messageModel) {
+        apache.rocketmq.v2.ClientInfo.Builder builder = apache.rocketmq.v2.ClientInfo.newBuilder()
+            .setClientId(connection.getClientId() == null ? "" : connection.getClientId());
+        if (connection.getLanguage() != null) {
+            builder.setLanguage(connection.getLanguage().name());
+        }
+        builder.setVersion(String.valueOf(connection.getVersion()));
+        String clientAddr = connection.getClientAddr();
+        if (clientAddr != null && !clientAddr.isEmpty()) {
+            // clientAddr is "ip:port"; egress_ip is documented as the address observed by the server
+            int idx = clientAddr.lastIndexOf('@');
+            String addr = idx >= 0 ? clientAddr.substring(idx + 1) : clientAddr;
+            int colon = addr.lastIndexOf(':');
+            builder.setEgressIp(colon > 0 ? addr.substring(0, colon) : addr);
+            builder.setHostname(idx >= 0 ? clientAddr.substring(0, idx) : "");
+        }
+        if (messageModel != null) {
+            builder.setMessageModel(messageModel);
+        }
+        return builder.build();
+    }
+
+    static apache.rocketmq.v2.MessageModel toMessageModel(
+        org.apache.rocketmq.remoting.protocol.heartbeat.MessageModel messageModel) {
+        if (messageModel == null) {
+            return apache.rocketmq.v2.MessageModel.MESSAGE_MODEL_UNSPECIFIED;
+        }
+        switch (messageModel) {
+            case BROADCASTING:
+                return apache.rocketmq.v2.MessageModel.BROADCASTING;
+            case CLUSTERING:
+            default:
+                return apache.rocketmq.v2.MessageModel.CLUSTERING;
+        }
+    }
+
+    static apache.rocketmq.v2.FilterExpression toFilterExpression(String expressionType, String expression) {
+        apache.rocketmq.v2.FilterType type = ExpressionType.SQL92.equals(expressionType)
+            ? apache.rocketmq.v2.FilterType.SQL : apache.rocketmq.v2.FilterType.TAG;
+        return apache.rocketmq.v2.FilterExpression.newBuilder()
+            .setType(type)
+            .setExpression(expression == null ? "" : expression)
+            .build();
+    }
+
+    /**
+     * Full mapping of a remoting client's running info. A gRPC v2 client can only report its
+     * thread stack (the telemetry contract has no reply message carrying the other three fields),
+     * so this is reached only for remoting clients.
+     */
+    static apache.rocketmq.v2.ConsumerRunningInfo toConsumerRunningInfo(
+        org.apache.rocketmq.remoting.protocol.body.ConsumerRunningInfo runningInfo) {
+        apache.rocketmq.v2.ConsumerRunningInfo.Builder builder = apache.rocketmq.v2.ConsumerRunningInfo.newBuilder();
+        if (runningInfo == null) {
+            return builder.build();
+        }
+        if (runningInfo.getProperties() != null) {
+            for (String name : runningInfo.getProperties().stringPropertyNames()) {
+                builder.putProperties(name, runningInfo.getProperties().getProperty(name));
+            }
+        }
+        if (runningInfo.getSubscriptionSet() != null) {
+            for (org.apache.rocketmq.remoting.protocol.heartbeat.SubscriptionData data :
+                runningInfo.getSubscriptionSet()) {
+                builder.putSubscriptions(data.getTopic(), toFilterExpression(data.getExpressionType(), data.getSubString()));
+            }
+        }
+        if (runningInfo.getMqTable() != null) {
+            for (Map.Entry<org.apache.rocketmq.common.message.MessageQueue,
+                org.apache.rocketmq.remoting.protocol.body.ProcessQueueInfo> entry : runningInfo.getMqTable().entrySet()) {
+                org.apache.rocketmq.remoting.protocol.body.ProcessQueueInfo info = entry.getValue();
+                if (info == null) {
+                    continue;
+                }
+                builder.addMessageQueueTable(MessageQueueItem.newBuilder()
+                    .setMessageQueue(toMessageQueue(entry.getKey()))
+                    .setProcessQueueInfo(apache.rocketmq.v2.ProcessQueueInfo.newBuilder()
+                        .setCommitOffset(info.getCommitOffset())
+                        .setCachedMsgMinOffset(info.getCachedMsgMinOffset())
+                        .setCachedMsgMaxOffset(info.getCachedMsgMaxOffset())
+                        .setCachedMsgCount(info.getCachedMsgCount())
+                        .setCachedMsgSizeInMib(info.getCachedMsgSizeInMiB())
+                        .setTransactionMsgMinOffset(info.getTransactionMsgMinOffset())
+                        .setTransactionMsgMaxOffset(info.getTransactionMsgMaxOffset())
+                        .setTransactionMsgCount(info.getTransactionMsgCount())
+                        .setLocked(info.isLocked())
+                        .setTryUnlockTimes(info.getTryUnlockTimes())
+                        .setLastLockTimestamp(info.getLastLockTimestamp())
+                        .setDropped(info.isDroped())
+                        .setLastPullTimestamp(info.getLastPullTimestamp())
+                        .setLastConsumeTimestamp(info.getLastConsumeTimestamp())
+                        .build())
+                    .build());
+            }
+        }
+        if (runningInfo.getStatusTable() != null) {
+            for (Map.Entry<String, org.apache.rocketmq.remoting.protocol.body.ConsumeStatus> entry :
+                runningInfo.getStatusTable().entrySet()) {
+                org.apache.rocketmq.remoting.protocol.body.ConsumeStatus status = entry.getValue();
+                if (status == null) {
+                    continue;
+                }
+                builder.putConsumeStatusTable(entry.getKey(), apache.rocketmq.v2.ConsumeStatus.newBuilder()
+                    .setReceiveRt(status.getPullRT())
+                    .setReceiveTps(status.getPullTPS())
+                    .setConsumeRt(status.getConsumeRT())
+                    .setConsumeOkTps(status.getConsumeOKTPS())
+                    .setConsumeFailedTps(status.getConsumeFailedTPS())
+                    .setConsumeFailedMsgs(status.getConsumeFailedMsgs())
+                    .build());
+            }
+        }
+        return builder.build();
+    }
+}
diff --git a/proxy/src/main/java/org/apache/rocketmq/proxy/grpc/admin/ProxyAdminAuthInterceptor.java b/proxy/src/main/java/org/apache/rocketmq/proxy/grpc/admin/ProxyAdminAuthInterceptor.java
new file mode 100644
index 0000000000..9f4d705283
--- /dev/null
+++ b/proxy/src/main/java/org/apache/rocketmq/proxy/grpc/admin/ProxyAdminAuthInterceptor.java
@@ -0,0 +1,277 @@
+/*
+ * 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.rocketmq.proxy.grpc.admin;
+
+import io.grpc.Grpc;
+import io.grpc.Metadata;
+import io.grpc.ServerCall;
+import io.grpc.ServerCallHandler;
+import io.grpc.ServerInterceptor;
+import io.grpc.Status;
+import java.net.InetSocketAddress;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.rocketmq.auth.authentication.context.DefaultAuthenticationContext;
+import org.apache.rocketmq.auth.authentication.exception.AuthenticationException;
+import org.apache.rocketmq.auth.authentication.factory.AuthenticationFactory;
+import org.apache.rocketmq.auth.authentication.model.User;
+import org.apache.rocketmq.auth.authorization.AuthorizationEvaluator;
+import org.apache.rocketmq.auth.authentication.AuthenticationEvaluator;
+import org.apache.rocketmq.auth.authorization.context.DefaultAuthorizationContext;
+import org.apache.rocketmq.auth.authorization.exception.AuthorizationException;
+import org.apache.rocketmq.auth.authorization.factory.AuthorizationFactory;
+import org.apache.rocketmq.auth.authorization.model.Resource;
+import org.apache.rocketmq.auth.config.AuthConfig;
+import org.apache.rocketmq.common.action.Action;
+import org.apache.rocketmq.common.constant.LoggerName;
+import org.apache.rocketmq.common.resource.ResourcePattern;
+import org.apache.rocketmq.common.resource.ResourceType;
+import org.apache.rocketmq.logging.org.slf4j.Logger;
+import org.apache.rocketmq.logging.org.slf4j.LoggerFactory;
+import org.apache.rocketmq.proxy.config.ConfigurationManager;
+import org.apache.rocketmq.proxy.config.ProxyConfig;
+import org.apache.rocketmq.proxy.processor.MessagingProcessor;
+
+/**
+ * authorization interceptor for the dedicated Proxy Admin gRPC server.
+ *
+ * <p>Every admin RPC is bound to a dedicated ACL 2.0 resource under the
+ * {@code proxy.admin.*} namespace with a distinct action, giving true
+ * read-only / high-privilege isolation on top of the standard ACL 2.0 policy
+ * engine:
+ *
+ * <pre>
+ *   proxy.admin.client      ListConsumerConnection / ListSubscription / DescribeSubscription /
+ *                           DescribeGroupAccumulation / GetConsumerRunningInfo / QueryTimeSpan (GET/LIST)
+ *   proxy.admin.config      ChangeLogLevel (UPDATE)
+ *   proxy.admin.connection  PrintThreadStackTrace / VerifyMessage (UPDATE, high privilege)
+ *   proxy.admin.route       GetTopicRoute (GET)
+ *   proxy.admin.ops         broker-facing operations served by the Admin service
+ *                           (GET/LIST for queries; UPDATE/DELETE/PUB for mutations)
+ * </pre>
+ *
+ * <p>Resources are modeled as {@code CLUSTER}-typed literal resources
+ * (resource key {@code cluster:proxy.admin.<module>}) because the ACL 2.0
+ * resource model only defines cluster/namespace/topic/group types; a
+ * cluster-typed literal gives exact least-privilege matching without
+ * colliding with real cluster names.
+ *
+ * <p>Behavior modes:
+ * <ul>
+ *   <li>Cluster auth disabled and {@code grpcAdminServerAuthEnable=false}: the
+ *       admin surface is open (same semantics as the data plane).</li>
+ *   <li>Cluster auth enabled: requests are authenticated from the standard
+ *       {@code Authorization} gRPC metadata and authorized against the
+ *       per-method {@code proxy.admin.*} resource, exactly like the data
+ *       plane does for topic/group resources.</li>
+ *   <li>{@code grpcAdminServerAuthEnable=true}: fail-closed mode. The admin
+ *       surface must be able to both authenticate the caller and enforce the
+ *       per-method {@code proxy.admin.*} ACL, so it requires the cluster-wide
+ *       authentication AND authorization switches to be on. If either is off
+ *       every request is refused rather than served without a real permission
+ *       check: the authorization evaluator is itself gated by the cluster
+ *       authorization switch, so with authorization off it would silently pass
+ *       every call (including the destructive ones).</li>
+ * </ul>
+ */
+public class ProxyAdminAuthInterceptor implements ServerInterceptor {
+
+    private static final Logger log = LoggerFactory.getLogger(LoggerName.PROXY_LOGGER_NAME);
+    private static final Logger logAudit = LoggerFactory.getLogger(LoggerName.ROCKETMQ_AUTH_AUDIT_LOGGER_NAME);
+
+    public static final String RESOURCE_CLIENT = "proxy.admin.client";
+    public static final String RESOURCE_CONFIG = "proxy.admin.config";
+    public static final String RESOURCE_CONNECTION = "proxy.admin.connection";
+    public static final String RESOURCE_ROUTE = "proxy.admin.route";
+    public static final String RESOURCE_OPS = "proxy.admin.ops";
+
+    private static final Map<String, ResourceAction> METHOD_PERMISSIONS = new HashMap<>();
+
+    static {
+        // Admin service (rocketmq-apis v2 admin.proto) served on the dedicated admin server.
+        METHOD_PERMISSIONS.put("GetProxyRuntimeStats", new ResourceAction(RESOURCE_OPS, Action.GET));
+        METHOD_PERMISSIONS.put("GetTopicRoute", new ResourceAction(RESOURCE_ROUTE, Action.GET));
+        METHOD_PERMISSIONS.put("DescribeTopicStatus", new ResourceAction(RESOURCE_OPS, Action.GET));
+        METHOD_PERMISSIONS.put("ListSubscription", new ResourceAction(RESOURCE_CLIENT, Action.LIST));
+        METHOD_PERMISSIONS.put("DescribeSubscription", new ResourceAction(RESOURCE_CLIENT, Action.GET));
+        METHOD_PERMISSIONS.put("ListConsumerConnection", new ResourceAction(RESOURCE_CLIENT, Action.LIST));
+        METHOD_PERMISSIONS.put("DescribeGroupAccumulation", new ResourceAction(RESOURCE_CLIENT, Action.GET));
+        METHOD_PERMISSIONS.put("GetConsumerRunningInfo", new ResourceAction(RESOURCE_CLIENT, Action.GET));
+        METHOD_PERMISSIONS.put("QueryTimeSpan", new ResourceAction(RESOURCE_CLIENT, Action.GET));
+        METHOD_PERMISSIONS.put("QueryMessage", new ResourceAction(RESOURCE_OPS, Action.GET));
+        METHOD_PERMISSIONS.put("ChangeLogLevel", new ResourceAction(RESOURCE_CONFIG, Action.UPDATE));
+        // High-privilege mutations: strictly separated from the read-only actions above.
+        METHOD_PERMISSIONS.put("DeleteSubscription", new ResourceAction(RESOURCE_OPS, Action.DELETE));
+        METHOD_PERMISSIONS.put("ResetGroupOffset", new ResourceAction(RESOURCE_OPS, Action.UPDATE));
+        METHOD_PERMISSIONS.put("AdminSendMessage", new ResourceAction(RESOURCE_OPS, Action.PUB));
+        METHOD_PERMISSIONS.put("PrintThreadStackTrace", new ResourceAction(RESOURCE_CONNECTION, Action.UPDATE));
+        METHOD_PERMISSIONS.put("VerifyMessage", new ResourceAction(RESOURCE_CONNECTION, Action.UPDATE));
+    }
+
+    private final AuthConfig authConfig;
+    private final AuthenticationEvaluator authenticationEvaluator;
+    private final AuthorizationEvaluator authorizationEvaluator;
+
+    public ProxyAdminAuthInterceptor(AuthConfig authConfig, MessagingProcessor messagingProcessor) {
+        this.authConfig = authConfig;
+        this.authenticationEvaluator = AuthenticationFactory.getEvaluator(authConfig,
+            messagingProcessor::getMetadataService);
+        this.authorizationEvaluator = AuthorizationFactory.getEvaluator(authConfig,
+            messagingProcessor::getMetadataService);
+    }
+
+    @Override
+    public <R, W> ServerCall.Listener<R> interceptCall(ServerCall<R, W> call, Metadata headers,
+        ServerCallHandler<R, W> next) {
+        String method = call.getMethodDescriptor().getBareMethodName();
+        try {
+            ProxyConfig proxyConfig = ConfigurationManager.getProxyConfig();
+            boolean requireAuth = proxyConfig != null && proxyConfig.isGrpcAdminServerAuthEnable();
+            boolean authnEnabled = authConfig != null && authConfig.isAuthenticationEnabled();
+            boolean authzEnabled = authConfig != null && authConfig.isAuthorizationEnabled();
+
+            // Open mode: identical semantics to the data plane when cluster auth is off.
+            if (!requireAuth && !authnEnabled && !authzEnabled) {
+                return next.startCall(call, headers);
+            }
+
+            // Fail-closed: credentials are demanded but cannot be verified at all.
+            if (requireAuth && !authnEnabled) {
+                call.close(Status.UNAUTHENTICATED.withDescription(
+                    "grpcAdminServerAuthEnable is on but cluster authenticationEnabled is off; "
+                        + "enable authentication before using the admin surface in fail-closed mode"), new Metadata());
+                return noopListener();
+            }
+
+            // Fail-closed: the admin surface is locked down, but the ACL engine that enforces the
+            // per-method proxy.admin.* permission is gated by the cluster authorization switch.
+            // With that switch off the authorization evaluator returns without checking anything,
+            // so requiring auth here without authorization on would let any authenticated identity
+            // reach every admin RPC, including the destructive ones. Refuse instead of serving
+            // an unauthorized call.
+            if (requireAuth && !authzEnabled) {
+                call.close(Status.FAILED_PRECONDITION.withDescription(
+                    "grpcAdminServerAuthEnable is on but cluster authorizationEnabled is off; "
+                        + "enable authorization so the proxy.admin.* ACL can be enforced before "
+                        + "using the admin surface in fail-closed mode"), new Metadata());
+                return noopListener();
+            }
+
+            String username = null;
+            if (authnEnabled || requireAuth) {
+                DefaultAuthenticationContext authenticationContext = buildAuthenticationContext(call, headers);
+                username = authenticationContext.getUsername();
+                if (StringUtils.isBlank(username)) {
+                    if (requireAuth) {
+                        call.close(Status.UNAUTHENTICATED.withDescription("missing credentials for proxy admin"),
+                            new Metadata());
+                        return noopListener();
+                    }
+                    authenticationEvaluator.evaluate(authenticationContext);
+                } else {
+                    authenticationEvaluator.evaluate(authenticationContext);
+                }
+            }
+
+            ResourceAction resourceAction = METHOD_PERMISSIONS.get(method);
+            // After the fail-closed guards above, requireAuth implies authzEnabled, so the ACL is
+            // enforced exactly when the cluster authorization switch is on; there is no path where
+            // requireAuth alone would ask the (globally gated) evaluator to enforce and be ignored.
+            if (resourceAction != null && authzEnabled) {
+                if (StringUtils.isBlank(username)) {
+                    call.close(Status.UNAUTHENTICATED.withDescription("missing credentials for proxy admin"),
+                        new Metadata());
+                    return noopListener();
+                }
+                DefaultAuthorizationContext authorizationContext = DefaultAuthorizationContext.of(
+                    User.of(username),
+                    Resource.of(ResourceType.CLUSTER, resourceAction.resource, ResourcePattern.LITERAL),
+                    resourceAction.action,
+                    resolveSourceIp(call));
+                authorizationContext.setRpcCode(call.getMethodDescriptor().getFullMethodName());
+                authorizationEvaluator.evaluate(Collections.singletonList(authorizationContext));
+            }
+
+            logAudit.info("[PROXY-ADMIN-AUDIT] subject = {} method = {} resource = {} action = {} sourceIp = {}",
+                StringUtils.isBlank(username) ? "anonymous" : username, method,
+                resourceAction == null ? "unmapped" : resourceAction.resource,
+                resourceAction == null ? "unknown" : resourceAction.action.getName(),
+                resolveSourceIp(call));
+            return next.startCall(call, headers);
+        } catch (AuthenticationException e) {
+            log.warn("admin authentication failed. method:{}, cause:{}", method, e.getMessage());
+            call.close(Status.UNAUTHENTICATED.withDescription(e.getMessage()), new Metadata());
+            return noopListener();
+        } catch (AuthorizationException e) {
+            log.warn("admin authorization denied. method:{}, cause:{}", method, e.getMessage());
+            call.close(Status.PERMISSION_DENIED.withDescription(e.getMessage()), new Metadata());
+            return noopListener();
+        } catch (Throwable t) {
+            log.error("admin auth interceptor error. method:{}", method, t);
+            call.close(Status.INTERNAL.withDescription(t.getMessage()), new Metadata());
+            return noopListener();
+        }
+    }
+
+    private <R, W> DefaultAuthenticationContext buildAuthenticationContext(ServerCall<R, W> call,
+        Metadata headers) {
+        // The builder only uses the message for its descriptor name; pass the shared
+        // Status default instance and overwrite rpcCode with the real admin method.
+        Object context = AuthenticationFactory.newContext(authConfig, headers,
+            apache.rocketmq.v2.Status.getDefaultInstance());
+        if (!(context instanceof DefaultAuthenticationContext)) {
+            throw new AuthenticationException("unsupported authentication context type for proxy admin");
+        }
+        DefaultAuthenticationContext authenticationContext = (DefaultAuthenticationContext) context;
+        authenticationContext.setRpcCode(call.getMethodDescriptor().getFullMethodName());
+        return authenticationContext;
+    }
+
+    private static <R, W> String resolveSourceIp(ServerCall<R, W> call) {
+        try {
+            InetSocketAddress remoteAddress = (InetSocketAddress) call.getAttributes()
+                .get(Grpc.TRANSPORT_ATTR_REMOTE_ADDR);
+            if (remoteAddress != null && remoteAddress.getAddress() != null) {
+                return remoteAddress.getAddress().getHostAddress();
+            }
+        } catch (Throwable ignore) {
+            // best-effort only
+        }
+        return "";
+    }
+
+    private static <R> ServerCall.Listener<R> noopListener() {
+        return new ServerCall.Listener<R>() {
+        };
+    }
+
+    static ResourceAction resolveResourceAction(String method) {
+        return METHOD_PERMISSIONS.get(method);
+    }
+
+    static final class ResourceAction {
+        final String resource;
+        final Action action;
+
+        ResourceAction(String resource, Action action) {
+            this.resource = resource;
+            this.action = action;
+        }
+    }
+}
diff --git a/proxy/src/main/java/org/apache/rocketmq/proxy/grpc/admin/ProxyAdminForwarder.java b/proxy/src/main/java/org/apache/rocketmq/proxy/grpc/admin/ProxyAdminForwarder.java
new file mode 100644
index 0000000000..7b806791a2
--- /dev/null
+++ b/proxy/src/main/java/org/apache/rocketmq/proxy/grpc/admin/ProxyAdminForwarder.java
@@ -0,0 +1,356 @@
+/*
+ * 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.rocketmq.proxy.grpc.admin;
+
+import apache.rocketmq.v2.AdminGrpc;
+import io.grpc.Context;
+import io.grpc.ManagedChannel;
+import io.grpc.Metadata;
+import io.grpc.Status;
+import io.grpc.netty.shaded.io.grpc.netty.GrpcSslContexts;
+import io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder;
+import io.grpc.netty.shaded.io.netty.handler.ssl.SslContext;
+import io.grpc.netty.shaded.io.netty.handler.ssl.util.InsecureTrustManagerFactory;
+import io.grpc.stub.MetadataUtils;
+import io.grpc.stub.StreamObserver;
+import io.netty.channel.Channel;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import java.util.concurrent.TimeUnit;
+import java.util.function.BiConsumer;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.rocketmq.broker.client.ClientChannelInfo;
+import org.apache.rocketmq.broker.client.ConsumerManager;
+import org.apache.rocketmq.common.constant.GrpcConstants;
+import org.apache.rocketmq.common.constant.LoggerName;
+import org.apache.rocketmq.common.utils.StartAndShutdown;
+import org.apache.rocketmq.logging.org.slf4j.Logger;
+import org.apache.rocketmq.logging.org.slf4j.LoggerFactory;
+import org.apache.rocketmq.proxy.common.channel.ChannelHelper;
+import org.apache.rocketmq.proxy.config.ConfigurationManager;
+import org.apache.rocketmq.proxy.config.ProxyConfig;
+import org.apache.rocketmq.proxy.processor.channel.RemoteChannel;
+import org.apache.rocketmq.proxy.service.ServiceManager;
+import org.apache.rocketmq.remoting.common.TlsMode;
+import org.apache.rocketmq.remoting.netty.TlsSystemConfig;
+
+/**
+ * Forwards a Proxy Admin RPC to the peer proxy that actually owns the target client.
+ *
+ * <p>A gRPC client keeps a single telemetry stream, so only the proxy holding that stream can ask
+ * the client for a thread dump, a verify-message run or its running info. {@code
+ * GrpcChannelManager#getChannel(clientId)} is therefore local-only and returns {@code null} for a
+ * client connected elsewhere. {@link ConsumerManager#findChannel(String, String)} on the other hand
+ * also sees clients registered on peer proxies: {@code HeartbeatSyncer} replicates them as
+ * {@link RemoteChannel} instances whose {@link RemoteChannel#getRemoteProxyIp()} is the owning
+ * proxy's {@code ProxyConfig.localServeAddr}. That is exactly the routing hint this class uses.
+ *
+ * <p><b>Peer admin port assumption.</b> The admin port is not published anywhere in the heartbeat
+ * sync payload ({@code HeartbeatSyncerData} carries {@code remotingListenPort} and
+ * {@code grpcServerPort}, not the admin port), so this class assumes the admin port is uniform
+ * across the cluster and dials the peer at {@code <remoteProxyIp>:<this proxy's own
+ * ProxyConfig.getGrpcAdminServerPort()>}. A cluster that runs heterogeneous admin ports would need
+ * the port added to the heartbeat sync record first.
+ *
+ * <p><b>TLS.</b> Every proxy gRPC server installs {@code ProxyAndTlsProtocolNegotiator}. In the
+ * default configuration ({@code tlsTestModeEnable=true}) each JVM generates a fresh
+ * {@code SelfSignedCertificate}, so no peer can possibly validate it; because
+ * {@code TlsSystemConfig.tlsMode} defaults to {@code permissive} the plaintext handshake is
+ * accepted and used. Otherwise an {@link InsecureTrustManagerFactory}-based client SslContext is
+ * used: there is no cluster-wide CA distribution for service-to-service calls, and the peer is
+ * addressed by bare IP, which no sane certificate carries as a SAN.
+ *
+ * <p><b>Auth.</b> {@code ProxyAdminAuthInterceptor} derives the caller identity from the inbound
+ * {@code authorization} + {@code x-mq-date-time} metadata and there is no service-to-service
+ * identity in the open-source proxy. A forwarded call therefore impersonates the original caller:
+ * every inbound metadata key is copied verbatim onto the outbound call. The caller must already be
+ * authorized for the admin resource on the entry proxy, and the peer re-runs the very same
+ * authentication/authorization evaluation against those copied credentials.
+ *
+ * <p><b>Loop prevention.</b> A forwarded call carries {@link GrpcConstants#ADMIN_FORWARDED}. The
+ * receiving proxy detects it with {@link #isInboundForwarded()} and must serve the call locally
+ * instead of forwarding again, which also stops a two-proxy ping-pong when both sides see the
+ * client as remote.
+ */
+public class ProxyAdminForwarder implements StartAndShutdown {
+
+    private static final Logger log = LoggerFactory.getLogger(LoggerName.PROXY_LOGGER_NAME);
+
+    /**
+     * Wire value of {@link GrpcConstants#ADMIN_FORWARDED}.
+     */
+    private static final String FORWARDED_VALUE = "true";
+
+    /**
+     * gRPC reserves the {@code grpc-} prefix; {@link Metadata.Key#of} rejects such names.
+     */
+    private static final String RESERVED_KEY_PREFIX = "grpc-";
+
+    private static final long SHUTDOWN_GRACE_SECONDS = 5L;
+
+    private final ServiceManager serviceManager;
+
+    /**
+     * One channel + stub per remote proxy address ({@code <ip>:<adminPort>}). Channels are created
+     * lazily and are long-lived: a proxy set is small and stable, so re-handshaking per RPC would
+     * only add latency.
+     */
+    private final ConcurrentMap<String, PeerConnection> peerConnections = new ConcurrentHashMap<>();
+
+    public ProxyAdminForwarder(ServiceManager serviceManager) {
+        this.serviceManager = serviceManager;
+    }
+
+    /**
+     * True when this call already arrived forwarded from a peer proxy; such a call must never be
+     * forwarded again.
+     */
+    public boolean isInboundForwarded() {
+        Metadata inbound = GrpcConstants.METADATA.get(Context.current());
+        if (inbound == null) {
+            return false;
+        }
+        return FORWARDED_VALUE.equalsIgnoreCase(inbound.get(GrpcConstants.ADMIN_FORWARDED));
+    }
+
+    /**
+     * If the client identified by (group, clientId) is registered on another proxy, forward the RPC
+     * there and return true — the caller must then return immediately and let the peer own the
+     * observer. Returns false when the client is local, unknown, or forwarding is not possible, so
+     * the caller proceeds with its normal local handling.
+     *
+     * <p>This method never throws. Every "cannot forward" condition degrades to {@code false}; if
+     * the decision to forward was already taken and the outbound call then fails, the failure is
+     * propagated to {@code responseObserver#onError} and {@code true} is returned — see
+     * {@link #invokeOnPeer}.
+     *
+     * @param group            consumer group the client is registered under
+     * @param clientId         client id, as reported by the client itself
+     * @param responseObserver the observer of the inbound admin RPC; ownership moves to the peer
+     *                         when this method returns true
+     * @param invocation       performs the actual unary call on the peer's stub, e.g.
+     *                         {@code (stub, observer) -> stub.printThreadStackTrace(request, observer)}
+     */
+    public <T> boolean forwardIfRemote(String group, String clientId,
+        StreamObserver<T> responseObserver,
+        BiConsumer<AdminGrpc.AdminStub, StreamObserver<T>> invocation) {
+        if (StringUtils.isBlank(clientId)) {
+            return false;
+        }
+
+        // Loop guard first: a forwarded call must be answered locally even if this proxy still
+        // sees the client as remote (stale heartbeat sync), otherwise the two proxies bounce the
+        // call until it dies on a deadline.
+        if (isInboundForwarded()) {
+            log.info("admin call already forwarded by a peer, serving locally. group:{}, clientId:{}",
+                group, clientId);
+            return false;
+        }
+
+        ProxyConfig proxyConfig = ConfigurationManager.getProxyConfig();
+        if (proxyConfig == null) {
+            log.warn("proxy config is not initialized, cannot forward. group:{}, clientId:{}", group, clientId);
+            return false;
+        }
+        Integer adminPort = proxyConfig.getGrpcAdminServerPort();
+        if (adminPort == null || adminPort <= 0 || !proxyConfig.isGrpcAdminServerEnable()) {
+            log.warn("proxy admin server is not enabled on this cluster, cannot forward. "
+                + "group:{}, clientId:{}, adminPort:{}", group, clientId, adminPort);
+            return false;
+        }
+
+        ClientChannelInfo channelInfo = findClientChannel(group, clientId);
+        if (channelInfo == null) {
+            return false;
+        }
+        Channel channel = channelInfo.getChannel();
+        if (!ChannelHelper.isRemote(channel)) {
+            // A locally connected client: the caller can reach it through its own channel manager.
+            return false;
+        }
+        RemoteChannel remoteChannel = (RemoteChannel) channel;
+        String remoteProxyIp = remoteChannel.getRemoteProxyIp();
+        if (StringUtils.isBlank(remoteProxyIp)) {
+            log.warn("remote channel carries no owning proxy address, cannot forward. "
+                + "group:{}, clientId:{}, channel:{}", group, clientId, remoteChannel);
+            return false;
+        }
+        // Self-forward guard: RemoteChannel.remoteProxyIp is the peer's localServeAddr, so a plain
+        // string comparison detects "this proxy" and prevents a forward to ourselves.
+        if (StringUtils.equals(remoteProxyIp, proxyConfig.getLocalServeAddr())) {
+            log.info("client is owned by this proxy, serving locally. group:{}, clientId:{}", group, clientId);
+            return false;
+        }
+
+        return invokeOnPeer(remoteProxyIp, adminPort, group, clientId, responseObserver, invocation);
+    }
+
+    /**
+     * Runs {@code invocation} against the peer stub and hands the inbound observer to the peer's
+     * response stream.
+     *
+     * <p>Error translation: this class only holds the caller's {@code StreamObserver}, not the concrete
+     * response builder, so it cannot synthesize a business-level {@code Status} message inside the
+     * response body. A failed forward is therefore propagated as a gRPC {@code onError} carrying the
+     * peer's status (or {@code Status.INTERNAL} when the call could not even be issued), and
+     * {@code true} is returned so the caller stops and lets that error reach the client. Callers
+     * that want an in-band error instead should pre-check with {@code isInboundForwarded()} /
+     * channel locality themselves.
+     */
+    private <T> boolean invokeOnPeer(String remoteProxyIp, int adminPort, String group, String clientId,
+        StreamObserver<T> responseObserver,
+        BiConsumer<AdminGrpc.AdminStub, StreamObserver<T>> invocation) {
+        String target = remoteProxyIp + ":" + adminPort;
+        try {
+            PeerConnection peer = this.peerConnections.computeIfAbsent(target,
+                key -> createPeerConnection(remoteProxyIp, adminPort));
+            AdminGrpc.AdminStub stub = peer.stub.withInterceptors(
+                MetadataUtils.newAttachHeadersInterceptor(buildOutboundMetadata()));
+            log.info("forwarding admin call to peer proxy. target:{}, group:{}, clientId:{}",
+                target, group, clientId);
+            invocation.accept(stub, responseObserver);
+            return true;
+        } catch (Throwable t) {
+            log.error("forward admin call to peer proxy failed. target:{}, group:{}, clientId:{}",
+                target, group, clientId, t);
+            responseObserver.onError(Status.INTERNAL
+                .withDescription("forward admin call to peer proxy " + target + " failed: " + t.getMessage())
+                .withCause(t)
+                .asRuntimeException());
+            return true;
+        }
+    }
+
+    private ClientChannelInfo findClientChannel(String group, String clientId) {
+        ConsumerManager consumerManager = this.serviceManager == null
+            ? null : this.serviceManager.getConsumerManager();
+        if (consumerManager == null) {
+            return null;
+        }
+        try {
+            return consumerManager.findChannel(group, clientId);
+        } catch (Throwable t) {
+            log.warn("find client channel failed. group:{}, clientId:{}", group, clientId, t);
+            return null;
+        }
+    }
+
+    /**
+     * Builds the metadata for the outbound call: a verbatim copy of the inbound metadata (so the
+     * peer can authenticate/authorize the original caller, see the class javadoc) plus the
+     * loop-prevention marker.
+     */
+    private Metadata buildOutboundMetadata() {
+        Metadata outbound = new Metadata();
+        Metadata inbound = GrpcConstants.METADATA.get(Context.current());
+        if (inbound != null) {
+            for (String name : inbound.keys()) {
+                if (!isCopyable(name)) {
+                    continue;
+                }
+                try {
+                    Metadata.Key<String> key = Metadata.Key.of(name, Metadata.ASCII_STRING_MARSHALLER);
+                    for (String value : inbound.getAll(key)) {
+                        outbound.put(key, value);
+                    }
+                } catch (Throwable t) {
+                    // Defensive: an exotic inbound key must not abort the whole forward.
+                    log.warn("skip inbound metadata key that cannot be forwarded. key:{}", name, t);
+                }
+            }
+        }
+        // discardAll first: Metadata#put appends, and a stale marker from the inbound copy would
+        // otherwise leave the header duplicated.
+        outbound.discardAll(GrpcConstants.ADMIN_FORWARDED);
+        outbound.put(GrpcConstants.ADMIN_FORWARDED, FORWARDED_VALUE);
+        return outbound;
+    }
+
+    private static boolean isCopyable(String name) {
+        if (StringUtils.isBlank(name)) {
+            return false;
+        }
+        // Binary headers cannot be read back through ASCII_STRING_MARSHALLER; grpc-* names are
+        // reserved and rejected by Metadata.Key#of.
+        return !name.endsWith(Metadata.BINARY_HEADER_SUFFIX) && !name.startsWith(RESERVED_KEY_PREFIX);
+    }
+
+    private PeerConnection createPeerConnection(String host, int port) {
+        NettyChannelBuilder builder = NettyChannelBuilder.forAddress(host, port);
+        if (usePlaintext()) {
+            builder.usePlaintext();
+        } else {
+            builder.sslContext(insecureClientSslContext());
+        }
+        ManagedChannel channel = builder.build();
+        log.info("created proxy admin client channel to peer proxy. target:{}:{}", host, port);
+        return new PeerConnection(channel, AdminGrpc.newStub(channel));
+    }
+
+    private static boolean usePlaintext() {
+        // ENFORCING is the only tlsMode in which the peer's negotiator rejects a plaintext
+        // handshake, so it always wins over tlsTestModeEnable.
+        if (TlsMode.ENFORCING.equals(TlsSystemConfig.tlsMode)) {
+            return false;
+        }
+        ProxyConfig proxyConfig = ConfigurationManager.getProxyConfig();
+        // tlsTestModeEnable=true mints a fresh SelfSignedCertificate per JVM; no peer can validate
+        // it, and permissive mode (the default) accepts plaintext, so skip TLS entirely.
+        return proxyConfig == null || proxyConfig.isTlsTestModeEnable();
+    }
+
+    private static SslContext insecureClientSslContext() {
+        try {
+            return GrpcSslContexts.configure(
+                GrpcSslContexts.forClient().trustManager(InsecureTrustManagerFactory.INSTANCE))
+                .build();
+        } catch (Exception e) {
+            throw new IllegalStateException("build proxy admin client SslContext failed", e);
+        }
+    }
+
+    @Override
+    public void start() throws Exception {
+        // Peer connections are created lazily on first forward; nothing to warm up.
+    }
+
+    @Override
+    public void shutdown() throws Exception {
+        for (PeerConnection peer : this.peerConnections.values()) {
+            try {
+                peer.channel.shutdown();
+                if (!peer.channel.awaitTermination(SHUTDOWN_GRACE_SECONDS, TimeUnit.SECONDS)) {
+                    peer.channel.shutdownNow();
+                }
+            } catch (Throwable t) {
+                log.warn("shutdown proxy admin client channel failed. channel:{}", peer.channel, t);
+                peer.channel.shutdownNow();
+            }
+        }
+        this.peerConnections.clear();
+    }
+
+    private static final class PeerConnection {
+        private final ManagedChannel channel;
+        private final AdminGrpc.AdminStub stub;
+
+        private PeerConnection(ManagedChannel channel, AdminGrpc.AdminStub stub) {
+            this.channel = channel;
+            this.stub = stub;
+        }
+    }
+}
diff --git a/proxy/src/main/java/org/apache/rocketmq/proxy/grpc/admin/ProxyAdminGrpcService.java b/proxy/src/main/java/org/apache/rocketmq/proxy/grpc/admin/ProxyAdminGrpcService.java
new file mode 100644
index 0000000000..09b0196f44
--- /dev/null
+++ b/proxy/src/main/java/org/apache/rocketmq/proxy/grpc/admin/ProxyAdminGrpcService.java
@@ -0,0 +1,1735 @@
+/*
+ * 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.rocketmq.proxy.grpc.admin;
+
+import apache.rocketmq.v2.AdminGrpc;
+import apache.rocketmq.v2.AdminSendMessageRequest;
+import apache.rocketmq.v2.AdminSendMessageResponse;
+import apache.rocketmq.v2.ChangeLogLevelRequest;
+import apache.rocketmq.v2.ChangeLogLevelResponse;
+import apache.rocketmq.v2.ClientInfo;
+import apache.rocketmq.v2.Code;
+import apache.rocketmq.v2.ConsumerRunningInfo;
+import apache.rocketmq.v2.DeleteSubscriptionRequest;
+import apache.rocketmq.v2.DeleteSubscriptionResponse;
+import apache.rocketmq.v2.DescribeGroupAccumulationRequest;
+import apache.rocketmq.v2.DescribeGroupAccumulationResponse;
+import apache.rocketmq.v2.DescribeSubscriptionRequest;
+import apache.rocketmq.v2.DescribeSubscriptionResponse;
+import apache.rocketmq.v2.DescribeTopicStatusRequest;
+import apache.rocketmq.v2.DescribeTopicStatusResponse;
+import apache.rocketmq.v2.GetConsumerRunningInfoRequest;
+import apache.rocketmq.v2.GetConsumerRunningInfoResponse;
+import apache.rocketmq.v2.GetProxyRuntimeStatsRequest;
+import apache.rocketmq.v2.GetProxyRuntimeStatsResponse;
+import apache.rocketmq.v2.GetTopicRouteRequest;
+import apache.rocketmq.v2.GetTopicRouteResponse;
+import apache.rocketmq.v2.ListConsumerConnectionRequest;
+import apache.rocketmq.v2.ListConsumerConnectionResponse;
+import apache.rocketmq.v2.ListMessageRequest;
+import apache.rocketmq.v2.ListMessageResponse;
+import apache.rocketmq.v2.ListSubscriptionRequest;
+import apache.rocketmq.v2.ListSubscriptionResponse;
+import apache.rocketmq.v2.PrintThreadStackTraceRequest;
+import apache.rocketmq.v2.PrintThreadStackTraceResponse;
+import apache.rocketmq.v2.QueryTimeSpanRequest;
+import apache.rocketmq.v2.QueryTimeSpanResponse;
+import apache.rocketmq.v2.ResetGroupOffsetRequest;
+import apache.rocketmq.v2.ResetGroupOffsetResponse;
+import apache.rocketmq.v2.Resource;
+import apache.rocketmq.v2.Status;
+import apache.rocketmq.v2.SubscriptionInfo;
+import apache.rocketmq.v2.SystemProperties;
+import apache.rocketmq.v2.VerifyMessageRequest;
+import apache.rocketmq.v2.VerifyMessageResponse;
+import com.google.protobuf.Timestamp;
+import com.google.protobuf.util.Timestamps;
+import io.grpc.stub.StreamObserver;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.ThreadLocalRandom;
+import java.util.concurrent.TimeUnit;
+import java.util.function.BiFunction;
+import org.apache.rocketmq.broker.client.ClientChannelInfo;
+import org.apache.rocketmq.broker.client.ConsumerGroupInfo;
+import org.apache.rocketmq.broker.client.ConsumerManager;
+import org.apache.rocketmq.client.producer.SendResult;
+import org.apache.rocketmq.common.KeyBuilder;
+import org.apache.rocketmq.common.MQVersion;
+import org.apache.rocketmq.common.MixAll;
+import org.apache.rocketmq.common.ThreadFactoryImpl;
+import org.apache.rocketmq.common.TopicConfig;
+import org.apache.rocketmq.common.attribute.TopicMessageType;
+import org.apache.rocketmq.common.message.MessageAccessor;
+import org.apache.rocketmq.common.message.MessageConst;
+import org.apache.rocketmq.common.message.MessageExt;
+import org.apache.rocketmq.common.message.MessageQueue;
+import org.apache.rocketmq.common.utils.StartAndShutdown;
+import org.apache.rocketmq.common.utils.ThreadUtils;
+import org.apache.rocketmq.logging.org.slf4j.Logger;
+import org.apache.rocketmq.logging.org.slf4j.LoggerFactory;
+import org.apache.rocketmq.proxy.common.ProxyContext;
+import org.apache.rocketmq.proxy.config.ConfigurationManager;
+import org.apache.rocketmq.proxy.grpc.v2.channel.GrpcChannelManager;
+import org.apache.rocketmq.proxy.grpc.v2.channel.GrpcClientChannel;
+import org.apache.rocketmq.proxy.grpc.v2.common.GrpcClientSettingsManager;
+import org.apache.rocketmq.proxy.grpc.v2.common.GrpcConverter;
+import org.apache.rocketmq.proxy.grpc.v2.common.GrpcProxyException;
+import org.apache.rocketmq.proxy.grpc.v2.common.ResponseBuilder;
+import org.apache.rocketmq.proxy.processor.MessagingProcessor;
+import org.apache.rocketmq.proxy.service.ServiceManager;
+import org.apache.rocketmq.proxy.service.admin.AdminService;
+import org.apache.rocketmq.proxy.service.relay.ProxyRelayRequest;
+import org.apache.rocketmq.proxy.service.relay.ProxyRelayResult;
+import org.apache.rocketmq.proxy.service.route.AddressableMessageQueue;
+import org.apache.rocketmq.proxy.service.route.MessageQueueView;
+import org.apache.rocketmq.remoting.protocol.RequestCode;
+import org.apache.rocketmq.remoting.protocol.ResponseCode;
+import org.apache.rocketmq.remoting.protocol.admin.ConsumeStats;
+import org.apache.rocketmq.remoting.protocol.body.CMResult;
+import org.apache.rocketmq.remoting.protocol.body.ConsumeMessageDirectlyResult;
+import org.apache.rocketmq.remoting.protocol.body.ConsumerConnection;
+import org.apache.rocketmq.remoting.protocol.body.Connection;
+import org.apache.rocketmq.remoting.protocol.body.GroupList;
+import org.apache.rocketmq.remoting.protocol.body.QueueTimeSpan;
+import org.apache.rocketmq.remoting.protocol.body.TopicList;
+import org.apache.rocketmq.remoting.protocol.header.ConsumeMessageDirectlyResultRequestHeader;
+import org.apache.rocketmq.remoting.protocol.header.GetConsumerRunningInfoRequestHeader;
+import org.apache.rocketmq.remoting.protocol.route.BrokerData;
+import org.apache.rocketmq.remoting.protocol.route.TopicRouteData;
+import org.apache.rocketmq.remoting.protocol.subscription.SimpleSubscriptionData;
+import org.apache.rocketmq.remoting.protocol.subscription.SubscriptionGroupConfig;
+
+/**
+ * Proxy Admin gRPC service (control plane).
+ *
+ * <p>Design rules this class follows:
+ * <ul>
+ *   <li><b>Never block the gRPC executor.</b> Every broker hop goes through the asynchronous
+ *       {@link AdminService} gateway and is fanned out to all relevant brokers concurrently.</li>
+ *   <li><b>Cluster-wide view.</b> Subscription and connection data is read from the brokers (which
+ *       see consumers registered through every proxy) rather than from this proxy's local channel
+ *       table, and consumers owned by a peer proxy are reached by forwarding the whole RPC.</li>
+ *   <li><b>Honest responses.</b> A field the open-source proxy genuinely cannot supply is left
+ *       unset and explained in the status message, instead of being filled with a plausible value.</li>
+ *   <li><b>Graded errors.</b> Failures are mapped through {@link ResponseBuilder#buildStatus(Throwable)}
+ *       so callers can tell "topic not found" from "internal error".</li>
+ * </ul>
+ *
+ * <p>The translation between broker wire types and the v2 contract lives in
+ * {@link AdminModelConverter}; whole-message conversion reuses the data plane's
+ * {@link GrpcConverter} instead of duplicating it.
+ */
+public class ProxyAdminGrpcService extends AdminGrpc.AdminImplBase implements StartAndShutdown {
+
+    private static final Logger log = LoggerFactory.getLogger(ProxyAdminGrpcService.class);
+
+    /**
+     * Bounds every wait this service performs that is not already bounded by the callee: the answer
+     * to a relayed telemetry command, and each broker hop of a fan-out. The gRPC nonce sweeper and
+     * the remoting timeout cover the healthy paths, but a hop that never completes at all (an
+     * unwritable channel, a saturated blocking-call pool) would otherwise leave the RPC hanging
+     * forever with no response at all.
+     */
+    private final ScheduledExecutorService adminTimeoutScheduler =
+        ThreadUtils.newSingleThreadScheduledExecutor(new ThreadFactoryImpl("ProxyAdminTimeout_", true));
+
+    /**
+     * How much longer than the broker timeout a single hop is allowed to take. The broker call
+     * carries its own timeout, so this only catches a gateway future that never completes; the slack
+     * keeps a genuine broker timeout reporting its own cause instead of this generic deadline.
+     */
+    private static final long BROKER_CALL_DEADLINE_SLACK_MILLIS = 1000L;
+
+    private static final String ADMIN_SEND_PRODUCER_GROUP = "ADMIN_SEND_PRODUCER_GROUP";
+    private static final int DEFAULT_MAX_MESSAGE_NUMS = 32;
+
+    private final ServiceManager serviceManager;
+    private final MessagingProcessor messagingProcessor;
+    private final GrpcChannelManager grpcChannelManager;
+    private final GrpcClientSettingsManager grpcClientSettingsManager;
+    private final ProxyAdminForwarder forwarder;
+
+    public ProxyAdminGrpcService(ServiceManager serviceManager, MessagingProcessor messagingProcessor,
+        GrpcChannelManager grpcChannelManager, GrpcClientSettingsManager grpcClientSettingsManager,
+        ProxyAdminForwarder forwarder) {
+        this.serviceManager = serviceManager;
+        this.messagingProcessor = messagingProcessor;
+        this.grpcChannelManager = grpcChannelManager;
+        this.grpcClientSettingsManager = grpcClientSettingsManager;
+        this.forwarder = forwarder;
+    }
+
+    // =========================================================================
+    // helpers
+    // =========================================================================
+
+    private AdminService admin() {
+        return this.serviceManager.getAdminService();
+    }
+
+    private long timeoutMillis() {
+        return ConfigurationManager.getProxyConfig().getGrpcAdminServerRequestTimeoutMillis();
+    }
+
+    private ProxyContext ctx() {
+        return ProxyContext.create();
+    }
+
+    private static Status ok() {
+        return AdminModelConverter.ok();
+    }
+
+    private static Status err(Throwable t) {
+        return ResponseBuilder.getInstance().buildStatus(t);
+    }
+
+    private static Status err(Code code, String message) {
+        return ResponseBuilder.getInstance().buildStatus(code, message);
+    }
+
+    /** Sends exactly one response. Splitting onNext/onCompleted across a try and a catch is what
+     *  makes a StreamObserver throw IllegalStateException on a partially-succeeded call. */
+    private static <T> void respond(StreamObserver<T> observer, T response) {
+        observer.onNext(response);
+        observer.onCompleted();
+    }
+
+    /** Master broker address of every broker group in the cluster. */
+    private CompletableFuture<List<String>> allMasterBrokerAddrs() {
+        return admin().getBrokerClusterInfo(timeoutMillis()).thenApply(clusterInfo -> {
+            List<String> addrs = new ArrayList<>();
+            if (clusterInfo == null || clusterInfo.getBrokerAddrTable() == null) {
+                return addrs;
+            }
+            for (BrokerData brokerData : clusterInfo.getBrokerAddrTable().values()) {
+                String master = brokerData.getBrokerAddrs() == null ? null
+                    : brokerData.getBrokerAddrs().get(MixAll.MASTER_ID);
+                if (master != null && !master.isEmpty()) {
+                    addrs.add(master);
+                }
+            }
+            return addrs;
+        });
+    }
+
+    /**
+     * Addresses of the brokers hosting a topic. Write operations must use the write selector: the
+     * read selector can omit brokers that only accept writes and vice versa.
+     */
+    private List<String> topicBrokerAddrs(String topic, boolean forWrite) throws Exception {
+        MessageQueueView view = serviceManager.getTopicRouteService().getAllMessageQueueView(ctx(), topic);
+        if (view == null) {
+            throw new IllegalStateException("topic route not found for " + topic);
+        }
+        List<AddressableMessageQueue> queues = forWrite
+            ? view.getWriteSelector().getQueues() : view.getReadSelector().getQueues();
+        if (queues == null || queues.isEmpty()) {
+            throw new IllegalStateException("no " + (forWrite ? "writable" : "readable") + " queue for topic " + topic);
+        }
+        Set<String> addrs = new LinkedHashSet<>();
+        for (AddressableMessageQueue queue : queues) {
+            if (queue.getBrokerAddr() != null && !queue.getBrokerAddr().isEmpty()) {
+                addrs.add(queue.getBrokerAddr());
+            }
+        }
+        if (addrs.isEmpty()) {
+            throw new IllegalStateException("broker address not found for topic " + topic);
+        }
+        return new ArrayList<>(addrs);
+    }
+
+    /**
+     * Runs a query against every broker concurrently and keeps the successful answers. A broker
+     * that fails does not fail the whole call — a partially available cluster should still return
+     * the data it has. Only when every broker fails is the first error propagated, so the caller
+     * reports a real cause instead of an empty success.
+     */
+    private <T> CompletableFuture<Map<String, T>> queryAllBrokers(Collection<String> brokerAddrs,
+        BiFunction<String, Long, CompletableFuture<T>> query) {
+        if (brokerAddrs == null || brokerAddrs.isEmpty()) {
+            return CompletableFuture.completedFuture(Collections.emptyMap());
+        }
+        long timeout = timeoutMillis();
+        Map<String, CompletableFuture<T>> pending = new LinkedHashMap<>();
+        List<Throwable> failures = Collections.synchronizedList(new ArrayList<>());
+        for (String brokerAddr : brokerAddrs) {
+            CompletableFuture<T> hop = withTimeout(query.apply(brokerAddr, timeout),
+                timeout + BROKER_CALL_DEADLINE_SLACK_MILLIS, "broker call to " + brokerAddr);
+            pending.put(brokerAddr, hop
+                .whenComplete((result, throwable) -> {
+                    if (throwable != null) {
+                        failures.add(throwable);
+                    }
+                }));
+        }
+        return CompletableFuture.allOf(pending.values().toArray(new CompletableFuture[0]))
+            .handle((ignored, throwable) -> {
+                Map<String, T> succeeded = new LinkedHashMap<>();
+                for (Map.Entry<String, CompletableFuture<T>> entry : pending.entrySet()) {
+                    CompletableFuture<T> future = entry.getValue();
+                    if (!future.isCompletedExceptionally()) {
+                        T result = future.getNow(null);
+                        if (result != null) {
+                            succeeded.put(entry.getKey(), result);
+                        }
+                    }
+                }
+                return succeeded;
+            })
+            .thenCompose(succeeded -> {
+                if (succeeded.isEmpty() && !failures.isEmpty()) {
+                    CompletableFuture<Map<String, T>> failed = new CompletableFuture<>();
+                    failed.completeExceptionally(failures.get(0));
+                    return failed;
+                }
+                return CompletableFuture.completedFuture(succeeded);
+            });
+    }
+
+    private <T> CompletableFuture<T> withTimeout(CompletableFuture<T> future, long timeoutMillis,
+        String what) {
+        CompletableFuture<T> timeout = new CompletableFuture<>();
+        ScheduledFuture<?> timer = this.adminTimeoutScheduler.schedule(
+            () -> timeout.completeExceptionally(new IllegalStateException(
+                what + " timed out after " + timeoutMillis + "ms")),
+            timeoutMillis, TimeUnit.MILLISECONDS);
+        // one admin RPC fans out to every broker, so the timer is cancelled as soon as the answer
+        // arrives instead of leaving a dead task per hop queued until its deadline
+        return future.applyToEither(timeout, result -> result)
+            .whenComplete((result, throwable) -> timer.cancel(false));
+    }
+
+    private static Resource resource(String name) {
+        return Resource.newBuilder().setName(name == null ? "" : name).build();
+    }
+
+    /**
+     * Locates the client a client-targeted RPC is about. Returns the channel info when the client
+     * is known to this proxy's consumer manager (which, in cluster mode, also holds entries synced
+     * from peer proxies), or null.
+     */
+    private ClientChannelInfo findClientChannel(String group, String clientId) {
+        if (clientId == null || clientId.isEmpty()) {
+            return null;
+        }
+        ConsumerManager consumerManager = serviceManager.getConsumerManager();
+        if (consumerManager == null) {
+            return null;
+        }
+        if (group != null && !group.isEmpty()) {
+            ClientChannelInfo info = consumerManager.findChannel(group, clientId);
+            if (info != null) {
+                return info;
+            }
+        }
+        // the caller may not know the group; the local gRPC channel table is keyed by clientId alone
+        GrpcClientChannel channel = grpcChannelManager.getChannel(clientId);
+        if (channel == null) {
+            return null;
+        }
+        return new ClientChannelInfo(channel, clientId, null, 0);
+    }
+
+    // =========================================================================
+    // 1. ChangeLogLevel
+    // =========================================================================
+
+    @Override
+    public void changeLogLevel(ChangeLogLevelRequest request, StreamObserver<ChangeLogLevelResponse> responseObserver) {
+        String remark;
+        try {
+            org.apache.rocketmq.logging.org.slf4j.ILoggerFactory factory =
+                org.apache.rocketmq.logging.org.slf4j.LoggerFactory.getILoggerFactory();
+            if (!(factory instanceof org.apache.rocketmq.logging.ch.qos.logback.classic.LoggerContext)) {
+                remark = "unsupported logging backend, cannot change log level at runtime";
+            } else {
+                ChangeLogLevelRequest.Level level = request.getLevel();
+                // Level.TRACE is enum value 0, so proto3 cannot distinguish "caller asked for TRACE"
+                // from "caller sent an empty request". Honour the contract but make the consequence
+                // visible instead of silently switching a production proxy to the most verbose level.
+                if (level == ChangeLogLevelRequest.Level.TRACE) {
+                    log.warn("changeLogLevel requested TRACE, which is also the proto3 default for an "
+                        + "unset level; make sure this is intentional");
+                }
+                org.apache.rocketmq.logging.ch.qos.logback.classic.LoggerContext loggerContext =
+                    (org.apache.rocketmq.logging.ch.qos.logback.classic.LoggerContext) factory;
+                org.apache.rocketmq.logging.ch.qos.logback.classic.Level logbackLevel =
+                    org.apache.rocketmq.logging.ch.qos.logback.classic.Level.toLevel(level.name());
+                loggerContext.getLogger(
+                        org.apache.rocketmq.logging.ch.qos.logback.classic.Logger.ROOT_LOGGER_NAME)
+                    .setLevel(logbackLevel);
+                remark = "log level changed to " + logbackLevel;
+            }
+        } catch (Throwable t) {
+            log.warn("changeLogLevel failed", t);
+            remark = "failed to change log level: " + t.getMessage();
+        }
+        respond(responseObserver, ChangeLogLevelResponse.newBuilder().setRemark(remark).build());
+    }
+
+    // =========================================================================
+    // 2. DescribeTopicStatus
+    // =========================================================================
+
+    @Override
+    public void describeTopicStatus(DescribeTopicStatusRequest request,
+        StreamObserver<DescribeTopicStatusResponse> responseObserver) {
+        String topic = request.getTopic().getName();
+        List<String> brokerAddrs;
+        try {
+            brokerAddrs = topicBrokerAddrs(topic, false);
+        } catch (Throwable t) {
+            respond(responseObserver, DescribeTopicStatusResponse.newBuilder().setStatus(err(t)).build());
+            return;
+        }
+        // a topic is normally configured identically on every broker hosting it, but asking only the
+        // first one hides a real inconsistency, so all of them are queried and compared
+        queryAllBrokers(brokerAddrs, (addr, timeout) -> admin().getTopicConfig(addr, topic, timeout))
+            .whenComplete((configs, throwable) -> {
+                if (throwable != null) {
+                    respond(responseObserver, DescribeTopicStatusResponse.newBuilder().setStatus(err(throwable)).build());
+                    return;
+                }
+                try {
+                    respond(responseObserver, AdminModelConverter.toTopicStatus(
+                        mergeTopicConfigs(configs.values()), topic));
+                } catch (Throwable t) {
+                    respond(responseObserver, DescribeTopicStatusResponse.newBuilder().setStatus(err(t)).build());
+                }
+            });
+    }
+
+    private static TopicConfig mergeTopicConfigs(Collection<TopicConfig> configs) {
+        TopicConfig merged = null;
+        TopicMessageType messageType = null;
+        int readQueueNums = 0;
+        int writeQueueNums = 0;
+        boolean consistent = true;
+        for (TopicConfig config : configs) {
+            if (config == null) {
+                continue;
+            }
+            if (merged == null) {
+                merged = config;
+                messageType = config.getTopicMessageType();
+            } else if (messageType != config.getTopicMessageType()) {
+                consistent = false;
+            }
+            readQueueNums += config.getReadQueueNums();
+            writeQueueNums += config.getWriteQueueNums();
+        }
+        if (merged == null) {
+            return null;
+        }
+        TopicConfig result = new TopicConfig();
+        result.setTopicName(merged.getTopicName());
+        result.setReadQueueNums(readQueueNums);
+        result.setWriteQueueNums(writeQueueNums);
+        result.setPerm(merged.getPerm());
+        // MIXED is the honest answer when brokers disagree, and TopicConfig maps it back to
+        // MESSAGE_TYPE_UNSPECIFIED in the response rather than pretending one broker is right
+        result.setAttributes(new HashMap<>());
+        result.getAttributes().put(org.apache.rocketmq.common.TopicAttributes.TOPIC_MESSAGE_TYPE_ATTRIBUTE.getName(),
+            (consistent ? messageType : TopicMessageType.MIXED).name());
+        return result;
+    }
+
+    // =========================================================================
+    // 3. ListSubscription
+    // =========================================================================
+
+    @Override
+    public void listSubscription(ListSubscriptionRequest request, StreamObserver<ListSubscriptionResponse> responseObserver) {
+        boolean hasTopic = request.hasTopic() && !request.getTopic().getName().isEmpty();
+        boolean hasGroup = request.hasGroup() && !request.getGroup().getName().isEmpty();
+        if (!hasTopic && !hasGroup) {
+            respond(responseObserver, ListSubscriptionResponse.newBuilder()
+                .setStatus(err(Code.BAD_REQUEST, "at least one of topic or group must be set")).build());
+            return;
+        }
+        String topic = hasTopic ? request.getTopic().getName() : null;
+        String group = hasGroup ? request.getGroup().getName() : null;
+        resolveGroups(topic, group)
+            .thenCompose(groups -> allMasterBrokerAddrs()
+                .thenCompose(brokers -> collectConnections(brokers, groups)))
+            .whenComplete((connections, throwable) -> {
+                if (throwable != null) {
+                    respond(responseObserver, ListSubscriptionResponse.newBuilder().setStatus(err(throwable)).build());
+                    return;
+                }
+                try {
+                    respond(responseObserver, buildListSubscriptionResponse(connections, topic));
+                } catch (Throwable t) {
+                    respond(responseObserver, ListSubscriptionResponse.newBuilder().setStatus(err(t)).build());
+                }
+            });
+    }
+
+    private ListSubscriptionResponse buildListSubscriptionResponse(Map<String, ConsumerConnection> connections,
+        String topicFilter) {
+        ListSubscriptionResponse.Builder builder = ListSubscriptionResponse.newBuilder().setStatus(ok());
+        // a group reported by several brokers, or by several clients, describes the same
+        // subscription; keyed by group+topic+expression so the answer does not scale with
+        // the number of online consumers
+        Map<String, SubscriptionInfo> dedup = new LinkedHashMap<>();
+        String pullRetryPrefix = MixAll.RETRY_GROUP_TOPIC_PREFIX;
+        for (Map.Entry<String, ConsumerConnection> entry : connections.entrySet()) {
+            String group = entry.getKey();
+            ConsumerConnection connection = entry.getValue();
+            if (connection == null || connection.getSubscriptionTable() == null) {
+                continue;
+            }
+            boolean online = connection.getConnectionSet() != null && !connection.getConnectionSet().isEmpty();
+            apache.rocketmq.v2.MessageModel messageModel = AdminModelConverter.toMessageModel(connection.getMessageModel());
+            for (Map.Entry<String, org.apache.rocketmq.remoting.protocol.heartbeat.SubscriptionData> sub :
+                connection.getSubscriptionTable().entrySet()) {
+                String subTopic = sub.getKey();
+                if (subTopic == null || subTopic.startsWith(pullRetryPrefix) || KeyBuilder.isPopRetryTopicV2(subTopic)) {
+                    continue;
+                }
+                if (topicFilter != null && !topicFilter.equals(subTopic)) {
+                    continue;
+                }
+                org.apache.rocketmq.remoting.protocol.heartbeat.SubscriptionData data = sub.getValue();
+                SubscriptionInfo info = SubscriptionInfo.newBuilder()
+                    .setGroup(resource(group))
+                    .setTopic(resource(subTopic))
+                    .setExpression(AdminModelConverter.toFilterExpression(
+                        data == null ? null : data.getExpressionType(), data == null ? null : data.getSubString()))
+                    .setOnline(online)
+                    .setMessageModel(messageModel)
+                    .build();
+                dedup.put(group + "\u0001" + subTopic, info);
+            }
+        }
+        for (SubscriptionInfo info : dedup.values()) {
+            builder.addSubscriptionInfo(info);
+        }
+        return builder.build();
+    }
+
+    /** Groups to inspect: the requested one, or every group consuming the requested topic. */
+    private CompletableFuture<Set<String>> resolveGroups(String topic, String group) {
+        if (group != null) {
+            return CompletableFuture.completedFuture(Collections.singleton(group));
+        }
+        return allMasterBrokerAddrs().thenCompose(brokers ->
+            queryAllBrokers(brokers, (addr, timeout) -> admin().queryTopicConsumeByWho(addr, topic, timeout))
+                .thenApply(byBroker -> {
+                    Set<String> groups = new LinkedHashSet<>();
+                    for (GroupList groupList : byBroker.values()) {
+                        if (groupList != null && groupList.getGroupList() != null) {
+                            groups.addAll(groupList.getGroupList());
+                        }
+                    }
+                    return groups;
+                }));
+    }
+
+    /** Consumer connections of each group, merged across brokers. */
+    private CompletableFuture<Map<String, ConsumerConnection>> collectConnections(List<String> brokers,
+        Collection<String> groups) {
+        List<CompletableFuture<Void>> tasks = new ArrayList<>();
+        Map<String, ConsumerConnection> merged = new LinkedHashMap<>();
+        for (String group : groups) {
+            tasks.add(queryAllBrokers(brokers,
+                (addr, timeout) -> admin().getConsumerConnectionList(addr, group, timeout))
+                .handle((byBroker, throwable) -> {
+                    // seed with the proxy-side view: gRPC v2 consumers never reach the broker, and a
+                    // broker that does not know the group must not erase what this proxy does know
+                    ConsumerConnection acc = proxySideConnection(group);
+                    if (throwable != null) {
+                        log.info("broker has no consumer connection for group {}: {}", group, throwable.getMessage());
+                    } else {
+                        for (ConsumerConnection connection : byBroker.values()) {
+                            acc = mergeConsumerConnection(acc, connection);
+                        }
+                    }
+                    if (acc != null) {
+                        synchronized (merged) {
+                            merged.put(group, acc);
+                        }
+                    }
+                    return null;
+                }));
+        }
+        return CompletableFuture.allOf(tasks.toArray(new CompletableFuture[0]))
+            .thenApply(ignored -> merged);
+    }
+
+    /**
+     * Consumers registered through this proxy cluster, synthesised into the same shape the broker
+     * reports so the two sources can be merged without the callers knowing where a client came from.
+     *
+     * <p>A gRPC v2 client keeps its channel on the proxy and is never registered in the broker's
+     * consumer manager, so a broker-only query returns nothing for it. {@code HeartbeatSyncer}
+     * replicates registrations between proxies, so this view also covers clients connected to a peer
+     * proxy (they appear as {@link org.apache.rocketmq.proxy.processor.channel.RemoteChannel}).
+     */
+    private ConsumerConnection proxySideConnection(String group) {
+        ConsumerManager consumerManager = serviceManager.getConsumerManager();
+        ConsumerGroupInfo groupInfo = consumerManager == null ? null : consumerManager.getConsumerGroupInfo(group);
+        if (groupInfo == null) {
+            return null;
+        }
+        ConsumerConnection connection = new ConsumerConnection();
+        connection.setConsumeType(groupInfo.getConsumeType());
+        connection.setMessageModel(groupInfo.getMessageModel());
+        connection.setConsumeFromWhere(groupInfo.getConsumeFromWhere());
+        if (groupInfo.getSubscriptionTable() != null) {
+            connection.getSubscriptionTable().putAll(groupInfo.getSubscriptionTable());
+        }
+        for (ClientChannelInfo channelInfo : groupInfo.getChannelInfoTable().values()) {
+            if (channelInfo == null) {
+                continue;
+            }
+            Connection client = new Connection();
+            client.setClientId(channelInfo.getClientId());
+            client.setLanguage(channelInfo.getLanguage());
+            client.setVersion(channelInfo.getVersion());
+            client.setClientAddr(remoteAddressOf(channelInfo));
+            connection.getConnectionSet().add(client);
+        }
+        return connection;
+    }
+
+    /** Netty renders a socket address as "/ip:port"; drop the leading slash so it parses as an IP. */
+    private static String remoteAddressOf(ClientChannelInfo channelInfo) {
+        if (channelInfo.getChannel() == null || channelInfo.getChannel().remoteAddress() == null) {
+            return "";
+        }
+        String address = channelInfo.getChannel().remoteAddress().toString();
+        return address.startsWith("/") ? address.substring(1) : address;
+    }
+
+    private static ConsumerConnection mergeConsumerConnection(ConsumerConnection target, ConsumerConnection source) {
+        if (source == null) {
+            return target;
+        }
+        if (target == null) {
+            ConsumerConnection copy = new ConsumerConnection();
+            copy.setConsumeType(source.getConsumeType());
+            copy.setMessageModel(source.getMessageModel());
+            copy.setConsumeFromWhere(source.getConsumeFromWhere());
+            copy.getConnectionSet().addAll(source.getConnectionSet());
+            copy.getSubscriptionTable().putAll(source.getSubscriptionTable());
+            return copy;
+        }
+        for (Connection connection : source.getConnectionSet()) {
+            boolean seen = false;
+            for (Connection existing : target.getConnectionSet()) {
+                if (existing.getClientId() != null && existing.getClientId().equals(connection.getClientId())) {
+                    seen = true;
+                    break;
+                }
+            }
+            if (!seen) {
+                target.getConnectionSet().add(connection);
+            }
+        }
+        target.getSubscriptionTable().putAll(source.getSubscriptionTable());
+        return target;
+    }
+
+    // =========================================================================
+    // 4. DescribeSubscription
+    // =========================================================================
+
+    @Override
+    public void describeSubscription(DescribeSubscriptionRequest request,
+        StreamObserver<DescribeSubscriptionResponse> responseObserver) {
+        boolean hasTopic = request.hasTopic() && !request.getTopic().getName().isEmpty();
+        boolean hasGroup = request.hasGroup() && !request.getGroup().getName().isEmpty();
+        if (!hasTopic && !hasGroup) {
+            respond(responseObserver, DescribeSubscriptionResponse.newBuilder()
+                .setStatus(err(Code.BAD_REQUEST, "at least one of topic or group must be set")).build());
+            return;
+        }
+        String topic = hasTopic ? request.getTopic().getName() : null;
+        String group = hasGroup ? request.getGroup().getName() : null;
+        resolveGroups(topic, group)
+            .thenCompose(groups -> allMasterBrokerAddrs()
+                .thenCompose(brokers -> collectConnections(brokers, groups)))
+            .whenComplete((connections, throwable) -> {
+                if (throwable != null) {
+                    respond(responseObserver, DescribeSubscriptionResponse.newBuilder().setStatus(err(throwable)).build());
+                    return;
+                }
+                try {
+                    respond(responseObserver, buildDescribeSubscriptionResponse(connections, topic));
+                } catch (Throwable t) {
+                    respond(responseObserver, DescribeSubscriptionResponse.newBuilder().setStatus(err(t)).build());
+                }
+            });
+    }
+
+    /**
+     * One entry per connected client, which is what makes inconsistent subscriptions inside a group
+     * visible. Per-client settings are only held for clients connected to this proxy, so a client
+     * owned by a peer proxy is reported from the broker-side connection data with the group-level
+     * subscription rather than being dropped.
+     */
+    private DescribeSubscriptionResponse buildDescribeSubscriptionResponse(Map<String, ConsumerConnection> connections,
+        String topicFilter) {
+        DescribeSubscriptionResponse.Builder builder = DescribeSubscriptionResponse.newBuilder().setStatus(ok());
+        for (Map.Entry<String, ConsumerConnection> entry : connections.entrySet()) {
+            String group = entry.getKey();
+            ConsumerConnection connection = entry.getValue();
+            if (connection == null) {
+                continue;
+            }
+            apache.rocketmq.v2.MessageModel messageModel = AdminModelConverter.toMessageModel(connection.getMessageModel());
+            for (Connection conn : connection.getConnectionSet()) {
+                ClientInfo clientInfo = AdminModelConverter.toClientInfo(conn, messageModel);
+                apache.rocketmq.v2.Settings settings =
+                    grpcClientSettingsManager.getRawClientSettings(conn.getClientId());
+                List<SubscriptionInfo> infos = settings != null && settings.hasSubscription()
+                    ? subscriptionsFromSettings(group, settings, topicFilter, messageModel)
+                    : subscriptionsFromConnection(group, connection, topicFilter);
+                for (SubscriptionInfo info : infos) {
+                    builder.addClientSubscriptionInfo(
+                        DescribeSubscriptionResponse.ClientSubscriptionInfo.newBuilder()
+                            .setClientInfo(clientInfo)
+                            .setSubscriptionInfo(info)
+                            .build());
+                }
+            }
+        }
+        return builder.build();
+    }
+
+    private List<SubscriptionInfo> subscriptionsFromSettings(String group, apache.rocketmq.v2.Settings settings,
+        String topicFilter, apache.rocketmq.v2.MessageModel messageModel) {
+        List<SubscriptionInfo> result = new ArrayList<>();
+        for (apache.rocketmq.v2.SubscriptionEntry entry : settings.getSubscription().getSubscriptionsList()) {
+            String entryTopic = entry.hasTopic() ? entry.getTopic().getName() : "";
+            if (topicFilter != null && !topicFilter.equals(entryTopic)) {
+                continue;
+            }
+            SubscriptionInfo.Builder info = SubscriptionInfo.newBuilder()
+                .setGroup(resource(group))
+                .setTopic(resource(entryTopic))
+                .setOnline(true)
+                .setMessageModel(messageModel);
+            if (entry.hasExpression()) {
+                info.setExpression(entry.getExpression());
+            }
+            result.add(info.build());
+        }
+        return result;
+    }
+
+    private List<SubscriptionInfo> subscriptionsFromConnection(String group, ConsumerConnection connection,
+        String topicFilter) {
+        List<SubscriptionInfo> result = new ArrayList<>();
+        if (connection.getSubscriptionTable() == null) {
+            return result;
+        }
+        apache.rocketmq.v2.MessageModel messageModel = AdminModelConverter.toMessageModel(connection.getMessageModel());
+        for (Map.Entry<String, org.apache.rocketmq.remoting.protocol.heartbeat.SubscriptionData> sub :
+            connection.getSubscriptionTable().entrySet()) {
+            if (topicFilter != null && !topicFilter.equals(sub.getKey())) {
+                continue;
+            }
+            org.apache.rocketmq.remoting.protocol.heartbeat.SubscriptionData data = sub.getValue();
+            result.add(SubscriptionInfo.newBuilder()
+                .setGroup(resource(group))
+                .setTopic(resource(sub.getKey()))
+                .setExpression(AdminModelConverter.toFilterExpression(
+                    data == null ? null : data.getExpressionType(), data == null ? null : data.getSubString()))
+                .setOnline(true)
+                .setMessageModel(messageModel)
+                .build());
+        }
+        return result;
+    }
+
+    // =========================================================================
+    // 5. DeleteSubscription
+    // =========================================================================
+
+    /**
+     * Deletes ONE subscription relationship (group + topic + filter expression), which is what the
+     * contract asks for. It deliberately does NOT delete the consumer group: an earlier version
+     * answered this request with {@code deleteSubscriptionGroup} on every broker hosting the topic,
+     * which destroyed the group's other subscriptions along with its offsets.
+     *
+     * <p>The broker has no request code for a granular delete, so this is a read-modify-write on
+     * {@link SubscriptionGroupConfig#getSubscriptionDataSet()} via UPDATE_AND_CREATE_SUBSCRIPTIONGROUP.
+     *
+     * <p><b>In open source this is effectively a no-op, and that is expected rather than a bug.</b>
+     * Nothing in the codebase ever writes {@code subscriptionDataSet}: the broker only reads it
+     * ({@code ConsumerLagCalculator}, to attribute lag to topics), so on a stock deployment the set
+     * is {@code null} and there is no persisted relationship to remove. Durable per-relationship
+     * subscriptions are a downstream concern: distributions that need this RPC back it with an
+     * external subscription store that populates the same field. Live subscriptions held in the
+     * broker's {@code ConsumerManager} are deliberately not touched: they are rebuilt from client
+     * heartbeats, so removing them there would be undone within one heartbeat interval and would
+     * only look like a successful delete.
+     *
+     * <p>Callers therefore observe {@link Code#NOT_FOUND} with an explanation, and the consumer group
+     * and its offsets are left untouched. Making this RPC functional requires a persisted
+     * subscription store (or a contract change), which is out of scope for the proxy.
+     */
+    @Override
+    public void deleteSubscription(DeleteSubscriptionRequest request,
+        StreamObserver<DeleteSubscriptionResponse> responseObserver) {
+        String topic = request.getTopic().getName();
+        String group = request.hasGroup() ? request.getGroup().getName() : "";
+        if (topic.isEmpty() || group.isEmpty()) {
+            respond(responseObserver, DeleteSubscriptionResponse.newBuilder()
+                .setStatus(err(Code.BAD_REQUEST, "topic and group are required")).build());
+            return;
+        }
+        boolean hasExpression = request.hasExpression() && !request.getExpression().getExpression().isEmpty();
+        String expression = hasExpression ? request.getExpression().getExpression() : null;
+        boolean sql = hasExpression && request.getExpression().getType() == apache.rocketmq.v2.FilterType.SQL;
+        List<String> brokerAddrs;
+        try {
+            brokerAddrs = topicBrokerAddrs(topic, true);
+        } catch (Throwable t) {
+            respond(responseObserver, DeleteSubscriptionResponse.newBuilder().setStatus(err(t)).build());
+            return;
+        }
+        log.info("deleteSubscription group={} topic={} expression={} brokers={}", group, topic, expression, brokerAddrs);
+
+        long timeout = timeoutMillis();
+        List<CompletableFuture<Boolean>> tasks = new ArrayList<>();
+        List<Throwable> failures = Collections.synchronizedList(new ArrayList<>());
+        for (String brokerAddr : brokerAddrs) {
+            tasks.add(admin().getSubscriptionGroupConfig(brokerAddr, group, timeout)
+                .thenCompose(config -> {
+                    if (config == null) {
+                        return CompletableFuture.completedFuture(false);
+                    }
+                    Set<SimpleSubscriptionData> dataSet = config.getSubscriptionDataSet();
+                    if (dataSet == null || dataSet.isEmpty()) {
+                        // the normal path on a stock deployment: nothing persists relationships here,
+                        // so this falls through to the NOT_FOUND explained in the method javadoc
+                        return CompletableFuture.completedFuture(false);
+                    }
+                    Set<SimpleSubscriptionData> remaining = new HashSet<>();
+                    boolean removed = false;
+                    for (SimpleSubscriptionData data : dataSet) {
+                        if (matches(data, topic, expression, sql)) {
+                            removed = true;
+                        } else {
+                            remaining.add(data);
+                        }
+                    }
+                    if (!removed) {
+                        return CompletableFuture.completedFuture(false);
+                    }
+                    config.setSubscriptionDataSet(remaining);
+                    return admin().updateSubscriptionGroupConfig(brokerAddr, config, timeout).thenApply(v -> true);
+                })
+                .exceptionally(t -> {
+                    failures.add(t);
+                    log.warn("deleteSubscription failed on broker {} group={} topic={}", brokerAddr, group, topic, t);
+                    return false;
+                }));
+        }
+        CompletableFuture.allOf(tasks.toArray(new CompletableFuture[0])).whenComplete((ignored, throwable) -> {
+            boolean anyRemoved = false;
+            for (CompletableFuture<Boolean> task : tasks) {
+                if (Boolean.TRUE.equals(task.getNow(false))) {
+                    anyRemoved = true;
+                }
+            }
+            if (anyRemoved) {
+                respond(responseObserver, DeleteSubscriptionResponse.newBuilder().setStatus(ok()).build());
+            } else if (!failures.isEmpty()) {
+                respond(responseObserver, DeleteSubscriptionResponse.newBuilder().setStatus(err(failures.get(0))).build());
+            } else {
+                // Nothing matched. Say why precisely instead of reporting success for a no-op, and
+                // make clear the consumer group itself was left alone: the previous implementation
+                // answered this request by deleting the whole group on every broker.
+                respond(responseObserver, DeleteSubscriptionResponse.newBuilder()
+                    .setStatus(err(Code.NOT_FOUND,
+                        "no subscription of group " + group + " on topic " + topic
+                            + " is recorded on any broker. Open-source RocketMQ does not persist "
+                            + "per-topic subscription relationships (SubscriptionGroupConfig."
+                            + "subscriptionDataSet is only ever read by the broker, so it stays empty "
+                            + "unless an external subscription store writes it), so there is nothing to "
+                            + "delete. The consumer group and its offsets were left untouched."))
+                    .build());
+            }
+        });
+    }
+
+    private static boolean matches(SimpleSubscriptionData data, String topic, String expression, boolean sql) {
+        if (data == null || !topic.equals(data.getTopic())) {
+            return false;
+        }
+        if (expression == null) {
+            return true;
+        }
+        if (!expression.equals(data.getExpression())) {
+            return false;
+        }
+        String type = sql ? org.apache.rocketmq.common.filter.ExpressionType.SQL92
+            : org.apache.rocketmq.common.filter.ExpressionType.TAG;
+        return data.getExpressionType() == null || type.equals(data.getExpressionType());
+    }
+
+    // =========================================================================
+    // 6. DescribeGroupAccumulation
+    // =========================================================================
+
+    @Override
+    public void describeGroupAccumulation(DescribeGroupAccumulationRequest request,
+        StreamObserver<DescribeGroupAccumulationResponse> responseObserver) {
+        String group = request.getGroup().getName();
+        if (group.isEmpty()) {
+            respond(responseObserver, DescribeGroupAccumulationResponse.newBuilder()
+                .setStatus(err(Code.BAD_REQUEST, "group is required")).build());
+            return;
+        }
+        Set<String> requested = new LinkedHashSet<>();
+        for (Resource resource : request.getTopicsList()) {
+            if (!resource.getName().isEmpty()) {
+                requested.add(resource.getName());
+            }
+        }
+        // The contract says an empty topic list means "the whole group". Resolving that by treating
+        // the group name as a topic name cannot work, so the topics the group actually consumes are
+        // asked from the brokers instead.
+        CompletableFuture<Set<String>> topics = requested.isEmpty()
+            ? resolveTopicsOfGroup(group)
+            : CompletableFuture.completedFuture(requested);
+
+        topics.thenCompose(topicSet -> allMasterBrokerAddrs()
+                .thenCompose(brokers -> queryAllBrokers(brokers,
+                    (addr, timeout) -> admin().getConsumeStats(addr, group, "", timeout)))
+                .thenApply(byBroker -> AdminModelConverter.toAccumulation(
+                    filterToTopics(byBroker, group, topicSet), group)))
+            .whenComplete((result, throwable) -> {
+                if (throwable != null) {
+                    respond(responseObserver, DescribeGroupAccumulationResponse.newBuilder()
+                        .setStatus(err(throwable)).build());
+                    return;
+                }
+                try {
+                    DescribeGroupAccumulationResponse.Builder builder = DescribeGroupAccumulationResponse.newBuilder()
+                        .setStatus(ok())
+                        .setAccumulation(result.total);
+                    for (Map.Entry<String, apache.rocketmq.v2.DescribeGroupAccumulationResponse.GroupAccumulation> e :
+                        result.byTopic.entrySet()) {
+                        builder.putTopicAccumulation(e.getKey(), e.getValue());
+                    }
+                    respond(responseObserver, builder.build());
+                } catch (Throwable t) {
+                    respond(responseObserver, DescribeGroupAccumulationResponse.newBuilder().setStatus(err(t)).build());
+                }
+            });
+    }
+
+    /** Topics a group consumes, including its pop retry topics, asked from the brokers. */
+    private CompletableFuture<Set<String>> resolveTopicsOfGroup(String group) {
+        return allMasterBrokerAddrs().thenCompose(brokers ->
+            queryAllBrokers(brokers, (addr, timeout) -> admin().queryTopicsByConsumer(addr, group, timeout))
+                .thenApply(byBroker -> {
+                    Set<String> topics = new LinkedHashSet<>();
+                    for (TopicList topicList : byBroker.values()) {
+                        if (topicList == null || topicList.getTopicList() == null) {
+                            continue;
+                        }
+                        for (String topic : topicList.getTopicList()) {
+                            if (topic.startsWith(MixAll.RETRY_GROUP_TOPIC_PREFIX)) {
+                                continue;
+                            }
+                            topics.add(topic);
+                        }
+                    }
+                    return topics;
+                }));
+    }
+
+    /**
+     * Keeps only the queues belonging to the requested topics. When the caller asked for the whole
+     * group everything is kept, so a caller scoping the query to one topic does not get the backlog
+     * of unrelated topics mixed in.
+     */
+    private Map<String, ConsumeStats> filterToTopics(Map<String, ConsumeStats> byBroker, String group,
+        Set<String> requestedTopics) {
+        if (requestedTopics.isEmpty()) {
+            return byBroker;
+        }
+        Set<String> wanted = new HashSet<>();
+        for (String topic : requestedTopics) {
+            wanted.add(topic);
+            // a topic's backlog is spread over its own queues and the group's retry queues for it
+            wanted.add(KeyBuilder.buildPopRetryTopic(topic, group));
+            wanted.add(KeyBuilder.buildPopRetryTopicV2(topic, group));
+        }
+        wanted.add(MixAll.getRetryTopic(group));
+        Map<String, ConsumeStats> filtered = new LinkedHashMap<>();
+        for (Map.Entry<String, ConsumeStats> entry : byBroker.entrySet()) {
+            ConsumeStats stats = entry.getValue();
+            if (stats == null || stats.getOffsetTable() == null) {
+                continue;
+            }
+            ConsumeStats copy = new ConsumeStats();
+            for (Map.Entry<MessageQueue, org.apache.rocketmq.remoting.protocol.admin.OffsetWrapper> e :
+                stats.getOffsetTable().entrySet()) {
+                if (e.getKey() != null && wanted.contains(e.getKey().getTopic())) {
+                    copy.getOffsetTable().put(e.getKey(), e.getValue());
+                }
+            }
+            filtered.put(entry.getKey(), copy);
+        }
+        return filtered;
+    }
+
+    // =========================================================================
+    // 7. ListConsumerConnection
+    // =========================================================================
+
+    @Override
+    public void listConsumerConnection(ListConsumerConnectionRequest request,
+        StreamObserver<ListConsumerConnectionResponse> responseObserver) {
+        String group = request.getGroup().getName();
+        if (group.isEmpty()) {
+            respond(responseObserver, ListConsumerConnectionResponse.newBuilder()
+                .setStatus(err(Code.BAD_REQUEST, "group is required")).build());
+            return;
+        }
+        String topicFilter = request.hasTopic() && !request.getTopic().getName().isEmpty()
+            ? request.getTopic().getName() : null;
+        allMasterBrokerAddrs()
+            .thenCompose(brokers -> collectConnections(brokers, Collections.singletonList(group)))
+            .whenComplete((byGroup, throwable) -> {
+                if (throwable != null) {
+                    // an offline group is a normal answer for a listing RPC, not a server error
+                    log.info("listConsumerConnection group={} unavailable: {}", group, throwable.getMessage());
+                    respond(responseObserver, ListConsumerConnectionResponse.newBuilder()
+                        .setStatus(ok()).build());
+                    return;
+                }
+                try {
+                    ConsumerConnection merged = byGroup.get(group);
+                    ListConsumerConnectionResponse.Builder builder =
+                        ListConsumerConnectionResponse.newBuilder().setStatus(ok());
+                    if (merged != null) {
+                        if (topicFilter != null && (merged.getSubscriptionTable() == null
+                            || !merged.getSubscriptionTable().containsKey(topicFilter))) {
+                            respond(responseObserver, builder.build());
+                            return;
+                        }
+                        apache.rocketmq.v2.MessageModel messageModel =
+                            AdminModelConverter.toMessageModel(merged.getMessageModel());
+                        for (Connection connection : merged.getConnectionSet()) {
+                            builder.addClientInfo(AdminModelConverter.toClientInfo(connection, messageModel));
+                        }
+                    }
+                    respond(responseObserver, builder.build());
+                } catch (Throwable t) {
+                    respond(responseObserver, ListConsumerConnectionResponse.newBuilder()
+                        .setStatus(err(t)).build());
+                }
+            });
+    }
+
+    // =========================================================================
+    // 8. ResetGroupOffset
+    // =========================================================================
+
+    @Override
+    public void resetGroupOffset(ResetGroupOffsetRequest request, StreamObserver<ResetGroupOffsetResponse> responseObserver) {
+        String group = request.getGroup().getName();
+        String topic = request.getTopic().getName();
+        if (group.isEmpty() || topic.isEmpty()) {
+            respond(responseObserver, ResetGroupOffsetResponse.newBuilder()
+                .setStatus(err(Code.BAD_REQUEST, "group and topic are required")).build());
+            return;
+        }
+        // An unset protobuf Timestamp is 0, which would silently mean "replay everything since 1970".
+        if (!request.hasResetTimestamp() || request.getResetTimestamp().getSeconds() <= 0) {
+            respond(responseObserver, ResetGroupOffsetResponse.newBuilder()
+                .setStatus(err(Code.BAD_REQUEST, "reset_timestamp is required and must be a positive Unix time"))
+                .build());
+            return;
+        }
+        long resetTimestamp = TimeUnit.SECONDS.toMillis(request.getResetTimestamp().getSeconds())
+            + TimeUnit.NANOSECONDS.toMillis(request.getResetTimestamp().getNanos());
+        List<String> brokerAddrs;
+        try {
+            brokerAddrs = topicBrokerAddrs(topic, true);
+        } catch (Throwable t) {
+            respond(responseObserver, ResetGroupOffsetResponse.newBuilder().setStatus(err(t)).build());
+            return;
+        }
+        log.info("resetGroupOffset group={} topic={} timestamp={} brokers={}", group, topic, resetTimestamp, brokerAddrs);
+        queryAllBrokers(brokerAddrs, (addr, timeout) ->
+                admin().resetOffset(addr, topic, group, resetTimestamp, true, timeout))
+            .whenComplete((ignored, throwable) -> {
+                if (throwable != null) {
+                    respond(responseObserver, ResetGroupOffsetResponse.newBuilder().setStatus(err(throwable)).build());
+                } else {
+                    respond(responseObserver, ResetGroupOffsetResponse.newBuilder().setStatus(ok()).build());
+                }
+            });
+    }
+
+    // =========================================================================
+    // 9. QueryMessage
+    // =========================================================================
+
+    @Override
+    public void queryMessage(ListMessageRequest request, StreamObserver<ListMessageResponse> responseObserver) {
+        String topic = request.getTopic().getName();
+        if (topic.isEmpty()) {
+            respond(responseObserver, ListMessageResponse.newBuilder()
+                .setStatus(err(Code.BAD_REQUEST, "topic is required")).build());
+            return;
+        }
+        int maxNums = request.getMaxMessageNums() > 0 ? request.getMaxMessageNums() : DEFAULT_MAX_MESSAGE_NUMS;
+        long begin = request.hasBeginTimestamp() ? TimeUnit.SECONDS.toMillis(request.getBeginTimestamp().getSeconds()) : 0L;
+        long end = request.hasEndTimestamp() ? TimeUnit.SECONDS.toMillis(request.getEndTimestamp().getSeconds())
+            : Long.MAX_VALUE;
+
+        List<String> brokerAddrs;
+        try {
+            brokerAddrs = topicBrokerAddrs(topic, false);
+        } catch (Throwable t) {
+            respond(responseObserver, ListMessageResponse.newBuilder().setStatus(err(t)).build());
+            return;
+        }
+
+        String key;
+        boolean uniqueKey;
+        switch (request.getSearchKeyCase()) {
+            case MESSAGE_ID:
+                // a v2 message_id is the client-generated unique key, not the broker's offset-encoded
+                // id, so it must be looked up through the unique-key index on every broker that
+                // hosts the topic — decoding it as an offset would query unrelated data
+                key = request.getMessageId();
+                uniqueKey = true;
+                break;
+            case MESSAGE_KEY:
+                key = request.getMessageKey();
+                uniqueKey = false;
+                break;
+            case SUBSCRIPTION:
+            case LITE_TOPIC:
+            case SEARCHKEY_NOT_SET:
+            default:
+                respond(responseObserver, ListMessageResponse.newBuilder()
+                    .setStatus(err(Code.BAD_REQUEST,
+                        "querying by " + request.getSearchKeyCase() + " is not supported; "
+                            + "message_id or message_key is required"))
+                    .build());
+                return;
+        }
+        if (key == null || key.isEmpty()) {
+            respond(responseObserver, ListMessageResponse.newBuilder()
+                .setStatus(err(Code.BAD_REQUEST, "the search key must not be empty")).build());
+            return;
+        }
+
+        final String queryKey = key;
+        final boolean queryUniqueKey = uniqueKey;
+        queryAllBrokers(brokerAddrs, (addr, timeout) ->
+                admin().queryMessage(addr, topic, queryKey, maxNums, begin, end, queryUniqueKey, true, timeout))
+            .whenComplete((byBroker, throwable) -> {
+                if (throwable != null) {
+                    respond(responseObserver, ListMessageResponse.newBuilder().setStatus(err(throwable)).build());
+                    return;
+                }
+                ListMessageResponse.Builder builder = ListMessageResponse.newBuilder().setStatus(ok());
+                if (request.hasScrollId()) {
+                    builder.setScrollId(request.getScrollId());
+                }
+                int count = 0;
+                outer:
+                for (List<MessageExt> messages : byBroker.values()) {
+                    if (messages == null) {
+                        continue;
+                    }
+                    for (MessageExt messageExt : messages) {
+                        if (messageExt == null) {
+                            continue;
+                        }
+                        if (count >= maxNums) {
+                            break outer;
+                        }
+                        // reuse the data plane converter so born/store timestamps, hosts, delivery
+                        // time, message group and user properties are all carried over
+                        builder.addMessages(GrpcConverter.getInstance().buildMessage(messageExt));
+                        count++;
+                    }
+                }
+                if (count == 0) {
+                    builder.setStatus(err(Code.MESSAGE_NOT_FOUND,
+                        "no message found for " + (queryUniqueKey ? "message_id " : "message_key ") + queryKey));
+                }
+                respond(responseObserver, builder.build());
+            });
+    }
+
+    // =========================================================================
+    // 10. PrintThreadStackTrace
+    // =========================================================================
+
+    @Override
+    public void printThreadStackTrace(PrintThreadStackTraceRequest request,
+        StreamObserver<PrintThreadStackTraceResponse> responseObserver) {
+        String group = request.getGroup().getName();
+        String clientId = request.getClientId();
+        if (forwarder.forwardIfRemote(group, clientId, responseObserver,
+            (stub, observer) -> stub.printThreadStackTrace(request, observer))) {
+            return;
+        }
+        relayConsumerRunningInfo(group, clientId, true)
+            .whenComplete((result, throwable) -> {
+                if (throwable != null) {
+                    respond(responseObserver, PrintThreadStackTraceResponse.newBuilder()
+                        .setStatus(err(throwable)).build());
+                    return;
+                }
+                org.apache.rocketmq.remoting.protocol.body.ConsumerRunningInfo runningInfo = result.getResult();
+                String jstack = runningInfo == null ? null : runningInfo.getJstack();
+                if (result.getCode() != ResponseCode.SUCCESS || jstack == null || jstack.isEmpty()) {
+                    respond(responseObserver, PrintThreadStackTraceResponse.newBuilder()
+                        .setStatus(err(Code.NOT_FOUND,
+                            "client " + clientId + " did not return a thread stack: " + result.getRemark()))
+                        .build());
+                    return;
+                }
+                respond(responseObserver, PrintThreadStackTraceResponse.newBuilder()
+                    .setStatus(ok())
+                    .setThreadStackTrace(jstack)
+                    .build());
+            });
+    }
+
+    // =========================================================================
+    // 11. VerifyMessage
+    // =========================================================================
+
+    @Override
+    public void verifyMessage(VerifyMessageRequest request, StreamObserver<VerifyMessageResponse> responseObserver) {
+        String group = request.getGroup().getName();
+        String clientId = request.getClientId();
+        String topic = request.getTopic().getName();
+        String messageId = request.getMessageId();
+        if (group.isEmpty() || clientId.isEmpty() || topic.isEmpty() || messageId.isEmpty()) {
+            respond(responseObserver, VerifyMessageResponse.newBuilder()
+                .setStatus(err(Code.BAD_REQUEST, "group, client_id, topic and message_id are required")).build());
+            return;
+        }
+        if (forwarder.forwardIfRemote(group, clientId, responseObserver,
+            (stub, observer) -> stub.verifyMessage(request, observer))) {
+            return;
+        }
+        // the real message has to be fetched first: asking a client to consume an empty shell would
+        // prove nothing about its consumer logic
+        List<String> brokerAddrs;
+        try {
+            brokerAddrs = topicBrokerAddrs(topic, false);
+        } catch (Throwable t) {
+            respond(responseObserver, VerifyMessageResponse.newBuilder().setStatus(err(t)).build());
+            return;
+        }
+        // The message is fetched with its body still compressed: it is handed to the client
+        // verbatim, so inflating it here would leave the body and the compression flag the client
+        // reads disagreeing with each other.
+        queryAllBrokers(brokerAddrs, (addr, timeout) ->
+                admin().queryMessage(addr, topic, messageId, 1, 0L, Long.MAX_VALUE, true, false, timeout))
+            .whenComplete((byBroker, throwable) -> {
+                if (throwable != null) {
+                    respond(responseObserver, VerifyMessageResponse.newBuilder().setStatus(err(throwable)).build());
+                    return;
+                }
+                MessageExt messageExt = null;
+                for (List<MessageExt> messages : byBroker.values()) {
+                    if (messages != null && !messages.isEmpty() && messages.get(0) != null) {
+                        messageExt = messages.get(0);
+                        break;
+                    }
+                }
+                if (messageExt == null) {
+                    // "no such message" is a normal answer, not a proxy fault
+                    respond(responseObserver, VerifyMessageResponse.newBuilder()
+                        .setStatus(err(Code.MESSAGE_NOT_FOUND,
+                            "message " + messageId + " not found on topic " + topic))
+                        .build());
+                    return;
+                }
+                relayConsumeMessageDirectly(group, clientId, messageExt)
+                    .whenComplete((result, relayThrowable) -> {
+                        if (relayThrowable != null) {
+                            respond(responseObserver, VerifyMessageResponse.newBuilder()
+                                .setStatus(err(relayThrowable)).build());
+                            return;
+                        }
+                        ConsumeMessageDirectlyResult directlyResult = result.getResult();
+                        CMResult consumeResult = directlyResult == null ? null : directlyResult.getConsumeResult();
+                        if (consumeResult == CMResult.CR_SUCCESS) {
+                            respond(responseObserver, VerifyMessageResponse.newBuilder().setStatus(ok()).build());
+                        } else {
+                            respond(responseObserver, VerifyMessageResponse.newBuilder()
+                                .setStatus(err(Code.MESSAGE_CORRUPTED,
+                                    "client " + clientId + " failed to consume message " + messageId + ": "
+                                        + (consumeResult == null ? result.getRemark() : consumeResult.name())
+                                        + (directlyResult != null && directlyResult.getRemark() != null
+                                            ? " (" + directlyResult.getRemark() + ")" : "")))
+                                .build());
+                        }
+                    });
+            });
+    }
+
+    // =========================================================================
+    // 12. AdminSendMessage
+    // =========================================================================
+
+    @Override
+    public void adminSendMessage(AdminSendMessageRequest request, StreamObserver<AdminSendMessageResponse> responseObserver) {
+        String topic = request.getTopic().getName();
+        if (topic.isEmpty()) {
+            respond(responseObserver, AdminSendMessageResponse.newBuilder()
+                .setStatus(err(Code.BAD_REQUEST, "topic is required")).build());
+            return;
+        }
+        org.apache.rocketmq.common.message.Message message;
+        try {
+            message = buildAdminMessage(request);
+        } catch (Throwable t) {
+            // building the message validates caller-supplied properties, so a rejection has to be
+            // answered with a Status; letting it escape this method would reply with a bare gRPC
+            // error carrying no Status at all
+            respond(responseObserver, AdminSendMessageResponse.newBuilder().setStatus(err(t)).build());
+            return;
+        }
+        // ext_info is request-scoped and unknown keys must be ignored, so it is only logged here
+        if (!request.getExtInfoMap().isEmpty()) {
+            log.info("adminSendMessage ext_info={}", request.getExtInfoMap());
+        }
+
+        List<org.apache.rocketmq.common.message.Message> messages = new ArrayList<>();
+        messages.add(message);
+        final String shardingKey = message.getProperty(MessageConst.PROPERTY_SHARDING_KEY);
+        long timeout = timeoutMillis();
+        CompletableFuture<List<SendResult>> sendFuture;
+        try {
+            sendFuture = messagingProcessor.sendMessage(ctx(),
+                (queueContext, messageQueueView) -> selectQueue(messageQueueView, shardingKey),
+                ADMIN_SEND_PRODUCER_GROUP, 0, messages, timeout);
+        } catch (Throwable t) {
+            respond(responseObserver, AdminSendMessageResponse.newBuilder().setStatus(err(t)).build());
+            return;
+        }
+        withTimeout(sendFuture, timeout * 2, "adminSendMessage")
+            .whenComplete((sendResults, throwable) -> {
+                if (throwable != null) {
+                    respond(responseObserver, AdminSendMessageResponse.newBuilder().setStatus(err(throwable)).build());
+                    return;
+                }
+                String messageId = sendResults != null && !sendResults.isEmpty() && sendResults.get(0) != null
+                    ? sendResults.get(0).getMsgId() : "";
+                respond(responseObserver, AdminSendMessageResponse.newBuilder()
+                    .setStatus(ok())
+                    .setMessageId(messageId == null ? "" : messageId)
+                    .build());
+            });
+    }
+
+    /**
+     * Builds the message an admin send request describes, mirroring what the data plane's
+     * {@code SendMessageActivity} does for a producer.
+     *
+     * <p>System properties are written with {@link MessageAccessor#putProperty}, never with
+     * {@code Message#putUserProperty}: the latter rejects every name in
+     * {@link MessageConst#STRING_HASH_SET}, so using it for a system property such as the delivery
+     * timestamp throws instead of sending the message. Caller-supplied user properties are checked
+     * against that same set first and refused as {@link Code#ILLEGAL_MESSAGE_PROPERTY_KEY}, so a
+     * request that tries to forge a system property gets a graded answer rather than an internal
+     * error.
+     */
+    private static org.apache.rocketmq.common.message.Message buildAdminMessage(AdminSendMessageRequest request) {
+        org.apache.rocketmq.common.message.Message message = new org.apache.rocketmq.common.message.Message(
+            request.getTopic().getName(), request.getBody().toByteArray());
+        if (request.hasTag() && !request.getTag().isEmpty()) {
+            message.setTags(request.getTag());
+        }
+        if (request.hasKey() && !request.getKey().isEmpty()) {
+            message.setKeys(request.getKey());
+        }
+        for (Map.Entry<String, String> property : request.getUserPropertiesMap().entrySet()) {
+            if (MessageConst.STRING_HASH_SET.contains(property.getKey())) {
+                throw new GrpcProxyException(Code.ILLEGAL_MESSAGE_PROPERTY_KEY,
+                    "property is used by system: " + property.getKey());
+            }
+            MessageAccessor.putProperty(message, property.getKey(), property.getValue());
+        }
+        if (!request.hasSystemProperties()) {
+            return message;
+        }
+        SystemProperties systemProperties = request.getSystemProperties();
+        if (systemProperties.hasDeliveryTimestamp()) {
+            Timestamp deliveryTimestamp = systemProperties.getDeliveryTimestamp();
+            if (!Timestamps.isValid(deliveryTimestamp)) {
+                throw new GrpcProxyException(Code.ILLEGAL_DELIVERY_TIME,
+                    "delivery_timestamp is not a valid timestamp");
+            }
+            long deliverAt = Timestamps.toMillis(deliveryTimestamp);
+            if (deliverAt <= System.currentTimeMillis()) {
+                throw new GrpcProxyException(Code.ILLEGAL_DELIVERY_TIME,
+                    "delivery_timestamp must be in the future");
+            }
+            MessageAccessor.putProperty(message, MessageConst.PROPERTY_TIMER_DELIVER_MS, String.valueOf(deliverAt));
+        }
+        if (!systemProperties.getMessageGroup().isEmpty()) {
+            MessageAccessor.putProperty(message, MessageConst.PROPERTY_SHARDING_KEY,
+                systemProperties.getMessageGroup());
+        }
+        for (String key : systemProperties.getKeysList()) {
+            if (!key.isEmpty()) {
+                message.setKeys(key);
+                break;
+            }
+        }
+        if (systemProperties.hasTag() && !systemProperties.getTag().isEmpty()) {
+            message.setTags(systemProperties.getTag());
+        }
+        return message;
+    }
+
+    /**
+     * A FIFO message must keep going to the queue its message group hashes to; anything else is
+     * spread over the writable queues instead of always landing on queue 0.
+     */
+    private static AddressableMessageQueue selectQueue(MessageQueueView messageQueueView, String shardingKey) {
+        List<AddressableMessageQueue> queues = messageQueueView.getWriteSelector().getQueues();
+        if (queues == null || queues.isEmpty()) {
+            throw new IllegalStateException("no writable queue available");
+        }
+        if (shardingKey != null && !shardingKey.isEmpty()) {
+            int index = Math.floorMod(shardingKey.hashCode(), queues.size());
+            return queues.get(index);
+        }
+        return queues.get(ThreadLocalRandom.current().nextInt(queues.size()));
+    }
+
+    // =========================================================================
+    // 13. GetConsumerRunningInfo
+    // =========================================================================
+
+    @Override
+    public void getConsumerRunningInfo(GetConsumerRunningInfoRequest request,
+        StreamObserver<GetConsumerRunningInfoResponse> responseObserver) {
+        String group = request.getGroup().getName();
+        String clientId = request.getClientId();
+        if (clientId.isEmpty()) {
+            respond(responseObserver, GetConsumerRunningInfoResponse.newBuilder()
+                .setStatus(err(Code.BAD_REQUEST, "client_id is required")).build());
+            return;
+        }
+        if (forwarder.forwardIfRemote(group, clientId, responseObserver,
+            (stub, observer) -> stub.getConsumerRunningInfo(request, observer))) {
+            return;
+        }
+        relayConsumerRunningInfo(group, clientId, false)
+            .whenComplete((result, throwable) -> {
+                if (throwable == null && result.getCode() == ResponseCode.SUCCESS && result.getResult() != null) {
+                    respond(responseObserver, GetConsumerRunningInfoResponse.newBuilder()
+                        .setStatus(ok())
+                        .setConsumerRunningInfo(AdminModelConverter.toConsumerRunningInfo(result.getResult()))
+                        .build());
+                    return;
+                }
+                // A gRPC v2 client cannot report running info: the telemetry contract has no reply
+                // message carrying properties, process queue snapshots or consume statistics. Only
+                // remoting clients can. Rather than returning an empty shell that looks like a
+                // healthy consumer, fall back to what the proxy does know and say so.
+                ConsumerRunningInfo.Builder builder = ConsumerRunningInfo.newBuilder();
+                apache.rocketmq.v2.Settings settings = grpcClientSettingsManager.getRawClientSettings(clientId);
+                if (settings == null || !settings.hasSubscription()) {
+                    respond(responseObserver, GetConsumerRunningInfoResponse.newBuilder()
+                        .setStatus(err(throwable != null ? throwable
+                            : new IllegalStateException("client " + clientId + " is not connected to this cluster")))
+                        .build());
+                    return;
+                }
+                for (apache.rocketmq.v2.SubscriptionEntry entry : settings.getSubscription().getSubscriptionsList()) {
+                    builder.putSubscriptions(entry.hasTopic() ? entry.getTopic().getName() : "",
+                        entry.hasExpression() ? entry.getExpression()
+                            : apache.rocketmq.v2.FilterExpression.newBuilder()
+                                .setType(apache.rocketmq.v2.FilterType.TAG).setExpression("*").build());
+                }
+                // The client was found and its subscriptions are returned, so this is a success with
+                // an explanatory message rather than NOT_FOUND, which would claim the client does not
+                // exist and hide the fact that usable data came back.
+                respond(responseObserver, GetConsumerRunningInfoResponse.newBuilder()
+                    .setStatus(ResponseBuilder.getInstance().buildStatus(Code.OK,
+                        "only subscriptions are available: a gRPC v2 client does not report properties, "
+                            + "process queue snapshots or consume statistics over the telemetry channel; "
+                            + "use a remoting client for the full running info"))
+                    .setConsumerRunningInfo(builder.build())
+                    .build());
+            });
+    }
+
+    // =========================================================================
+    // 14. GetTopicRoute
+    // =========================================================================
+
+    @Override
+    public void getTopicRoute(GetTopicRouteRequest request, StreamObserver<GetTopicRouteResponse> responseObserver) {
+        String topic = request.getTopic().getName();
+        if (topic.isEmpty()) {
+            respond(responseObserver, GetTopicRouteResponse.newBuilder()
+                .setStatus(err(Code.BAD_REQUEST, "topic is required")).build());
+            return;
+        }
+        admin().getTopicRouteData(topic).whenComplete((topicRouteData, throwable) -> {
+            if (throwable != null) {
+                respond(responseObserver, GetTopicRouteResponse.newBuilder().setStatus(err(throwable)).build());
+                return;
+            }
+            // topic_route_data is documented as the serialized route table, so the request's
+            // network_type / protocol_type / client_address hints have no bearing on it: they only
+            // matter when the response carries protocol-specific access points, which this one does not
+            respond(responseObserver, AdminModelConverter.toTopicRoute((TopicRouteData) topicRouteData));
+        });
+    }
+
+    // =========================================================================
+    // 15. QueryTimeSpan
+    // =========================================================================
+
+    @Override
+    public void queryTimeSpan(QueryTimeSpanRequest request, StreamObserver<QueryTimeSpanResponse> responseObserver) {
+        String group = request.getGroup().getName();
+        if (group.isEmpty()) {
+            respond(responseObserver, QueryTimeSpanResponse.newBuilder()
+                .setStatus(err(Code.BAD_REQUEST, "group is required")).build());
+            return;
+        }
+        Set<String> topics = new LinkedHashSet<>();
+        for (Resource resource : request.getTopicsList()) {
+            if (!resource.getName().isEmpty()) {
+                topics.add(resource.getName());
+            }
+        }
+        if (topics.isEmpty()) {
+            respond(responseObserver, QueryTimeSpanResponse.newBuilder()
+                .setStatus(err(Code.BAD_REQUEST, "at least one topic is required")).build());
+            return;
+        }
+        // every requested topic is queried, on every broker hosting it; the broker computes the
+        // per-queue min/max/consume timestamps and delay itself (QUERY_CONSUME_TIME_SPAN)
+        List<CompletableFuture<List<QueueTimeSpan>>> tasks = new ArrayList<>();
+        List<Throwable> failures = Collections.synchronizedList(new ArrayList<>());
+        for (String topic : topics) {
+            List<String> brokerAddrs;
+            try {
+                brokerAddrs = topicBrokerAddrs(topic, false);
+            } catch (Throwable t) {
+                failures.add(t);
+                log.warn("queryTimeSpan cannot resolve brokers for topic {} group {}", topic, group, t);
+                continue;
+            }
+            tasks.add(queryAllBrokers(brokerAddrs, (addr, timeout) ->
+                    admin().queryConsumeTimeSpan(addr, topic, group, timeout))
+                .thenApply(byBroker -> {
+                    List<QueueTimeSpan> spans = new ArrayList<>();
+                    for (List<QueueTimeSpan> perBroker : byBroker.values()) {
+                        if (perBroker != null) {
+                            spans.addAll(perBroker);
+                        }
+                    }
+                    return spans;
+                })
+                .exceptionally(t -> {
+                    failures.add(t);
+                    return Collections.emptyList();
+                }));
+        }
+        CompletableFuture.allOf(tasks.toArray(new CompletableFuture[0])).whenComplete((ignored, throwable) -> {
+            List<QueueTimeSpan> all = new ArrayList<>();
+            for (CompletableFuture<List<QueueTimeSpan>> task : tasks) {
+                List<QueueTimeSpan> spans = task.getNow(null);
+                if (spans != null) {
+                    all.addAll(spans);
+                }
+            }
+            if (all.isEmpty() && !failures.isEmpty()) {
+                respond(responseObserver, QueryTimeSpanResponse.newBuilder().setStatus(err(failures.get(0))).build());
+                return;
+            }
+            try {
+                respond(responseObserver, AdminModelConverter.toQueryTimeSpan(all));
+            } catch (Throwable t) {
+                respond(responseObserver, QueryTimeSpanResponse.newBuilder().setStatus(err(t)).build());
+            }
+        });
+    }
+
+    // =========================================================================
+    // 16. GetProxyRuntimeStats
+    // =========================================================================
+
+    @Override
+    public void getProxyRuntimeStats(GetProxyRuntimeStatsRequest request,
+        StreamObserver<GetProxyRuntimeStatsResponse> responseObserver) {
+        try {
+            long producers = 0L;
+            long consumers = 0L;
+            Collection<GrpcClientChannel> channels = grpcChannelManager.getClientChannels();
+            for (GrpcClientChannel channel : channels) {
+                apache.rocketmq.v2.Settings settings = grpcClientSettingsManager.getRawClientSettings(channel.getClientId());
+                if (settings == null) {
+                    continue;
+                }
+                switch (settings.getClientType()) {
+                    case PRODUCER:
+                        producers++;
+                        break;
+                    case PUSH_CONSUMER:
+                    case SIMPLE_CONSUMER:
+                    case PULL_CONSUMER:
+                    case LITE_PUSH_CONSUMER:
+                    case LITE_SIMPLE_CONSUMER:
+                        consumers++;
+                        break;
+                    default:
+                        break;
+                }
+            }
+            // in_tps / out_tps stay unset on purpose: the open-source proxy keeps no per-process
+            // throughput counter, and reporting 0 would be indistinguishable from an idle proxy
+            respond(responseObserver, GetProxyRuntimeStatsResponse.newBuilder()
+                .setStatus(ok())
+                .setProxyName(ConfigurationManager.getProxyConfig().getProxyName())
+                .setVersion(MQVersion.getVersionDesc(MQVersion.CURRENT_VERSION))
+                .setConnections(channels.size())
+                .setProducers(producers)
+                .setConsumers(consumers)
+                .build());
+        } catch (Throwable t) {
+            log.warn("getProxyRuntimeStats failed", t);
+            respond(responseObserver, GetProxyRuntimeStatsResponse.newBuilder().setStatus(err(t)).build());
+        }
+    }
+
+    // =========================================================================
+    // client relay
+    // =========================================================================
+
+    /**
+     * Asks a connected client for its running info through the proxy's in-process relay. The result
+     * future is carried on the {@link ProxyRelayRequest} so that
+     * {@code ProxyChannel.writeAndFlush} can hand it to the channel implementation, which completes
+     * it when the client answers.
+     */
+    private CompletableFuture<ProxyRelayResult<org.apache.rocketmq.remoting.protocol.body.ConsumerRunningInfo>>
+        relayConsumerRunningInfo(String group, String clientId, boolean jstack) {
+        CompletableFuture<ProxyRelayResult<org.apache.rocketmq.remoting.protocol.body.ConsumerRunningInfo>> future =
+            new CompletableFuture<>();
+        ClientChannelInfo channelInfo = findClientChannel(group, clientId);
+        if (channelInfo == null || channelInfo.getChannel() == null) {
+            future.complete(new ProxyRelayResult<>(ResponseCode.SYSTEM_ERROR,
+                "client " + clientId + " is not connected to this cluster", null));
+            return future;
+        }
+        if (!channelInfo.getChannel().isActive()) {
+            future.complete(new ProxyRelayResult<>(ResponseCode.SYSTEM_ERROR,
+                "client " + clientId + " channel is inactive", null));
+            return future;
+        }
+        try {
+            GetConsumerRunningInfoRequestHeader header = new GetConsumerRunningInfoRequestHeader();
+            header.setConsumerGroup(group);
+            header.setClientId(clientId);
+            header.setJstackEnable(jstack);
+            ProxyRelayRequest relayRequest = ProxyRelayRequest.createRequestCommand(
+                RequestCode.GET_CONSUMER_RUNNING_INFO, header, future);
+            channelInfo.getChannel().writeAndFlush(relayRequest);
+        } catch (Throwable t) {
+            future.completeExceptionally(t);
+            return future;
+        }
+        return withTimeout(future, relayTimeoutMillis(), "getConsumerRunningInfo for client " + clientId);
+    }
+
+    private CompletableFuture<ProxyRelayResult<ConsumeMessageDirectlyResult>> relayConsumeMessageDirectly(
+        String group, String clientId, MessageExt messageExt) {
+        CompletableFuture<ProxyRelayResult<ConsumeMessageDirectlyResult>> future = new CompletableFuture<>();
+        ClientChannelInfo channelInfo = findClientChannel(group, clientId);
+        if (channelInfo == null || channelInfo.getChannel() == null) {
+            future.complete(new ProxyRelayResult<>(ResponseCode.SYSTEM_ERROR,
+                "client " + clientId + " is not connected to this cluster", null));
+            return future;
+        }
+        try {
+            ConsumeMessageDirectlyResultRequestHeader header = new ConsumeMessageDirectlyResultRequestHeader();
+            header.setConsumerGroup(group);
+            header.setClientId(clientId);
+            header.setTopic(messageExt.getTopic());
+            header.setMsgId(messageExt.getMsgId());
+            // the header requires a broker name although the write never reaches a broker; the
+            // message's own store queue is the most meaningful value available here
+            header.setBrokerName(messageExt.getBrokerName() == null ? "" : messageExt.getBrokerName());
+            ProxyRelayRequest relayRequest = ProxyRelayRequest.createRequestCommand(
+                RequestCode.CONSUME_MESSAGE_DIRECTLY, header, future);
+            // storeSize is what the broker recorded on disk and MessageDecoder.encode allocates
+            // exactly that many bytes; zero it so the size is recomputed from what is actually being
+            // written, or a body that no longer matches the stored one overflows the buffer
+            messageExt.setStoreSize(0);
+            relayRequest.setBody(org.apache.rocketmq.common.message.MessageDecoder.encode(messageExt, false));
+            channelInfo.getChannel().writeAndFlush(relayRequest);
+        } catch (Throwable t) {
+            future.completeExceptionally(t);
+            return future;
+        }
+        return withTimeout(future, relayTimeoutMillis(), "verifyMessage for client " + clientId);
+    }
+
+    private long relayTimeoutMillis() {
+        return TimeUnit.SECONDS.toMillis(
+            ConfigurationManager.getProxyConfig().getGrpcProxyRelayRequestTimeoutInSeconds()) + 1000L;
+    }
+
+    @Override
+    public void start() {
+    }
+
+    @Override
+    public void shutdown() {
+        this.adminTimeoutScheduler.shutdownNow();
+    }
+
+    @Override
+    public String toString() {
+        return "ProxyAdminGrpcService{proxyName=" + ConfigurationManager.getProxyConfig().getProxyName()
+            + ", adminPort=" + ConfigurationManager.getProxyConfig().getGrpcAdminServerPort() + "}";
+    }
+}
diff --git a/proxy/src/main/java/org/apache/rocketmq/proxy/grpc/v2/DefaultGrpcMessagingActivity.java b/proxy/src/main/java/org/apache/rocketmq/proxy/grpc/v2/DefaultGrpcMessagingActivity.java
index 88099207b9..fc602c99ea 100644
--- a/proxy/src/main/java/org/apache/rocketmq/proxy/grpc/v2/DefaultGrpcMessagingActivity.java
+++ b/proxy/src/main/java/org/apache/rocketmq/proxy/grpc/v2/DefaultGrpcMessagingActivity.java
@@ -97,6 +97,14 @@ public class DefaultGrpcMessagingActivity extends AbstractStartAndShutdown imple
         this.appendStartAndShutdown(this.grpcClientSettingsManager);
     }
 
+    public GrpcChannelManager getGrpcChannelManager() {
+        return this.grpcChannelManager;
+    }
+
+    public GrpcClientSettingsManager getGrpcClientSettingsManager() {
+        return this.grpcClientSettingsManager;
+    }
+
     @Override
     public CompletableFuture<QueryRouteResponse> queryRoute(ProxyContext ctx, QueryRouteRequest request) {
         return this.routeActivity.queryRoute(ctx, request);
diff --git a/proxy/src/main/java/org/apache/rocketmq/proxy/grpc/v2/GrpcMessagingApplication.java b/proxy/src/main/java/org/apache/rocketmq/proxy/grpc/v2/GrpcMessagingApplication.java
index 3429ad54e2..a2b1150bf2 100644
--- a/proxy/src/main/java/org/apache/rocketmq/proxy/grpc/v2/GrpcMessagingApplication.java
+++ b/proxy/src/main/java/org/apache/rocketmq/proxy/grpc/v2/GrpcMessagingApplication.java
@@ -78,6 +78,10 @@ public class GrpcMessagingApplication extends MessagingServiceGrpc.MessagingServ
 
     private final GrpcMessagingActivity grpcMessagingActivity;
 
+    public GrpcMessagingActivity getGrpcMessagingActivity() {
+        return this.grpcMessagingActivity;
+    }
+
     protected final RequestPipeline requestPipeline;
 
     protected ThreadPoolExecutor routeThreadPoolExecutor;
diff --git a/proxy/src/main/java/org/apache/rocketmq/proxy/grpc/v2/channel/GrpcChannelManager.java b/proxy/src/main/java/org/apache/rocketmq/proxy/grpc/v2/channel/GrpcChannelManager.java
index a18cf7600c..306b5ee486 100644
--- a/proxy/src/main/java/org/apache/rocketmq/proxy/grpc/v2/channel/GrpcChannelManager.java
+++ b/proxy/src/main/java/org/apache/rocketmq/proxy/grpc/v2/channel/GrpcChannelManager.java
@@ -17,6 +17,7 @@
 
 package org.apache.rocketmq.proxy.grpc.v2.channel;
 
+import java.util.Collection;
 import java.util.Set;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.ConcurrentHashMap;
@@ -69,6 +70,10 @@ public class GrpcChannelManager implements StartAndShutdown {
         return clientIdChannelMap.get(clientId);
     }
 
+    public Collection<GrpcClientChannel> getClientChannels() {
+        return clientIdChannelMap.values();
+    }
+
     public GrpcClientChannel removeChannel(String clientId) {
         return this.clientIdChannelMap.remove(clientId);
     }
diff --git a/proxy/src/main/java/org/apache/rocketmq/proxy/grpc/v2/channel/GrpcClientChannel.java b/proxy/src/main/java/org/apache/rocketmq/proxy/grpc/v2/channel/GrpcClientChannel.java
index 0135818fb3..7c42b7f47d 100644
--- a/proxy/src/main/java/org/apache/rocketmq/proxy/grpc/v2/channel/GrpcClientChannel.java
+++ b/proxy/src/main/java/org/apache/rocketmq/proxy/grpc/v2/channel/GrpcClientChannel.java
@@ -52,6 +52,7 @@ import org.apache.rocketmq.proxy.service.relay.ProxyRelayResult;
 import org.apache.rocketmq.proxy.service.relay.ProxyRelayService;
 import org.apache.rocketmq.proxy.service.transaction.TransactionData;
 import org.apache.rocketmq.remoting.protocol.RemotingCommand;
+import org.apache.rocketmq.remoting.protocol.ResponseCode;
 import org.apache.rocketmq.remoting.protocol.body.ConsumeMessageDirectlyResult;
 import org.apache.rocketmq.remoting.protocol.body.ConsumerRunningInfo;
 import org.apache.rocketmq.remoting.protocol.header.CheckTransactionStateRequestHeader;
@@ -233,6 +234,21 @@ public class GrpcClientChannel extends ProxyChannel implements ChannelExtendAttr
         GetConsumerRunningInfoRequestHeader header,
         CompletableFuture<ProxyRelayResult<ConsumerRunningInfo>> responseFuture) {
         if (Objects.isNull(header) || !header.isJstackEnable()) {
+            // The gRPC v2 telemetry protocol only offers PrintThreadStackTraceCommand for this
+            // request: there is no command that reports consumer running info without a stack dump.
+            // Returning without touching responseFuture used to leave the caller hanging until
+            // GrpcChannelManager#scanExpireResultFuture fired SYSTEM_BUSY "call remote timeout",
+            // which both wastes grpcProxyRelayRequestTimeoutInSeconds and misreports the cause as a
+            // timeout. REQUEST_CODE_NOT_SUPPORTED is the accurate code here: the request itself is
+            // understood and the peer is healthy, the protocol simply cannot serve this variant.
+            // (SYSTEM_ERROR/SYSTEM_BUSY would advertise a retryable proxy-side fault, which is
+            // wrong and would make callers loop.)
+            if (responseFuture != null) {
+                responseFuture.complete(new ProxyRelayResult<>(ResponseCode.REQUEST_CODE_NOT_SUPPORTED,
+                    "gRPC v2 protocol cannot report consumer running info without jstack, "
+                        + "retry with jstackEnable=true",
+                    null));
+            }
             return CompletableFuture.completedFuture(null);
         }
         this.writeTelemetryCommand(TelemetryCommand.newBuilder()
diff --git a/proxy/src/main/java/org/apache/rocketmq/proxy/grpc/v2/common/GrpcConverter.java b/proxy/src/main/java/org/apache/rocketmq/proxy/grpc/v2/common/GrpcConverter.java
index 87d20ebca1..695ac137d9 100644
--- a/proxy/src/main/java/org/apache/rocketmq/proxy/grpc/v2/common/GrpcConverter.java
+++ b/proxy/src/main/java/org/apache/rocketmq/proxy/grpc/v2/common/GrpcConverter.java
@@ -109,10 +109,11 @@ public class GrpcConverter {
     protected Map<String, String> buildUserAttributes(MessageExt messageExt) {
         Map<String, String> userAttributes = new HashMap<>();
         Map<String, String> properties = messageExt.getProperties();
-
-        for (Map.Entry<String, String> property : properties.entrySet()) {
-            if (!MessageConst.STRING_HASH_SET.contains(property.getKey())) {
-                userAttributes.put(property.getKey(), property.getValue());
+        if (properties != null) {
+            for (Map.Entry<String, String> property : properties.entrySet()) {
+                if (!MessageConst.STRING_HASH_SET.contains(property.getKey())) {
+                    userAttributes.put(property.getKey(), property.getValue());
+                }
             }
         }
 
@@ -161,7 +162,9 @@ public class GrpcConverter {
         }
 
         // message_type
-        TopicMessageType topicMessageType = TopicMessageType.parseFromMessageProperty(messageExt.getProperties());
+        Map<String, String> properties = messageExt.getProperties();
+        TopicMessageType topicMessageType = TopicMessageType.parseFromMessageProperty(
+            properties != null ? properties : new HashMap<String, String>());
         systemPropertiesBuilder.setMessageType(convertToGrpcMessageType(topicMessageType));
 
         // born_timestamp (millis)
diff --git a/proxy/src/main/java/org/apache/rocketmq/proxy/processor/DefaultMessagingProcessor.java b/proxy/src/main/java/org/apache/rocketmq/proxy/processor/DefaultMessagingProcessor.java
index a56bc42596..a9ebe83c17 100644
--- a/proxy/src/main/java/org/apache/rocketmq/proxy/processor/DefaultMessagingProcessor.java
+++ b/proxy/src/main/java/org/apache/rocketmq/proxy/processor/DefaultMessagingProcessor.java
@@ -385,6 +385,10 @@ public class DefaultMessagingProcessor extends AbstractStartAndShutdown implemen
         return this.serviceManager.getMetadataService();
     }
 
+    public ServiceManager getServiceManager() {
+        return this.serviceManager;
+    }
+
     @Override
     public void addReceiptHandle(ProxyContext ctx, Channel channel, String group, String msgID,
         MessageReceiptHandle messageReceiptHandle) {
diff --git a/proxy/src/main/java/org/apache/rocketmq/proxy/service/ClusterServiceManager.java b/proxy/src/main/java/org/apache/rocketmq/proxy/service/ClusterServiceManager.java
index 89539244f2..703d9431d5 100644
--- a/proxy/src/main/java/org/apache/rocketmq/proxy/service/ClusterServiceManager.java
+++ b/proxy/src/main/java/org/apache/rocketmq/proxy/service/ClusterServiceManager.java
@@ -158,6 +158,7 @@ public class ClusterServiceManager extends AbstractStartAndShutdown implements S
         this.appendShutdown(scheduledExecutorService::shutdown);
         this.appendStartAndShutdown(this.messagingClientAPIFactory);
         this.appendStartAndShutdown(this.operationClientAPIFactory);
+        this.appendStartAndShutdown(this.adminService);
         this.appendStartAndShutdown(this.transactionClientAPIFactory);
         this.appendStartAndShutdown(this.liteSubscriptionAPIFactory);
         this.appendStartAndShutdown(this.topicRouteService);
diff --git a/proxy/src/main/java/org/apache/rocketmq/proxy/service/LocalServiceManager.java b/proxy/src/main/java/org/apache/rocketmq/proxy/service/LocalServiceManager.java
index 8f5073bb3a..6e7827c4eb 100644
--- a/proxy/src/main/java/org/apache/rocketmq/proxy/service/LocalServiceManager.java
+++ b/proxy/src/main/java/org/apache/rocketmq/proxy/service/LocalServiceManager.java
@@ -88,6 +88,7 @@ public class LocalServiceManager extends AbstractStartAndShutdown implements Ser
     protected void init() {
         this.appendStartAndShutdown(this.mqClientAPIFactory);
         this.appendStartAndShutdown(this.topicRouteService);
+        this.appendStartAndShutdown(this.adminService);
         this.appendStartAndShutdown(new LocalServiceManagerStartAndShutdown());
     }
 
diff --git a/proxy/src/main/java/org/apache/rocketmq/proxy/service/admin/AdminService.java b/proxy/src/main/java/org/apache/rocketmq/proxy/service/admin/AdminService.java
index a9e6686b43..4b8f3f9889 100644
--- a/proxy/src/main/java/org/apache/rocketmq/proxy/service/admin/AdminService.java
+++ b/proxy/src/main/java/org/apache/rocketmq/proxy/service/admin/AdminService.java
@@ -14,13 +14,35 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
 package org.apache.rocketmq.proxy.service.admin;
 
 import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import org.apache.rocketmq.common.TopicConfig;
+import org.apache.rocketmq.common.utils.StartAndShutdown;
+import org.apache.rocketmq.common.message.MessageExt;
+import org.apache.rocketmq.common.message.MessageQueue;
+import org.apache.rocketmq.remoting.protocol.admin.ConsumeStats;
+import org.apache.rocketmq.remoting.protocol.admin.TopicStatsTable;
+import org.apache.rocketmq.remoting.protocol.body.ClusterInfo;
+import org.apache.rocketmq.remoting.protocol.body.ConsumerConnection;
+import org.apache.rocketmq.remoting.protocol.body.GroupList;
+import org.apache.rocketmq.remoting.protocol.body.QueueTimeSpan;
+import org.apache.rocketmq.remoting.protocol.body.TopicList;
 import org.apache.rocketmq.remoting.protocol.route.BrokerData;
+import org.apache.rocketmq.remoting.protocol.route.TopicRouteData;
+import org.apache.rocketmq.remoting.protocol.subscription.SubscriptionGroupConfig;
+
+public interface AdminService extends StartAndShutdown {
+
+    @Override
+    default void start() {
+    }
 
-public interface AdminService {
+    @Override
+    default void shutdown() {
+    }
 
     boolean topicExist(String topic);
 
@@ -29,4 +51,68 @@ public interface AdminService {
 
     boolean createTopicOnBroker(String topic, int wQueueNum, int rQueueNum, List<BrokerData> curBrokerDataList,
         List<BrokerData> sampleBrokerDataList, boolean examineTopic, int retryCheckCount) throws Exception;
+
+    // =========================================================================
+    // Admin gateway: broker-facing queries and mutations.
+    //
+    // Every call is delegated to the proxy's OWN managed broker client
+    // (rocketmq-proxy's MQClientAPIFactory), so the admin surface never opens a
+    // direct link to a broker. All methods are asynchronous: the gRPC admin
+    // handlers run on the shared gRPC executor and must not block it, and most
+    // of them fan out to several brokers at once.
+    //
+    // A blank {@code topic} on the statistics methods means "every topic of the
+    // group", which the broker resolves from its own consumer offset table.
+    // =========================================================================
+
+    CompletableFuture<ClusterInfo> getBrokerClusterInfo(long timeoutMillis);
+
+    CompletableFuture<TopicRouteData> getTopicRouteData(String topic);
+
+    CompletableFuture<TopicConfig> getTopicConfig(String brokerAddr, String topic, long timeoutMillis);
+
+    CompletableFuture<TopicStatsTable> getTopicStats(String brokerAddr, String topic, long timeoutMillis);
+
+    CompletableFuture<ConsumeStats> getConsumeStats(String brokerAddr, String group, String topic, long timeoutMillis);
+
+    CompletableFuture<List<QueueTimeSpan>> queryConsumeTimeSpan(String brokerAddr, String topic, String group,
+        long timeoutMillis);
+
+    CompletableFuture<Map<MessageQueue, Long>> resetOffset(String brokerAddr, String topic, String group,
+        long timestamp, boolean isForce, long timeoutMillis);
+
+    CompletableFuture<ConsumerConnection> getConsumerConnectionList(String brokerAddr, String group,
+        long timeoutMillis);
+
+    CompletableFuture<GroupList> queryTopicConsumeByWho(String brokerAddr, String topic, long timeoutMillis);
+
+    CompletableFuture<TopicList> queryTopicsByConsumer(String brokerAddr, String group, long timeoutMillis);
+
+    CompletableFuture<SubscriptionGroupConfig> getSubscriptionGroupConfig(String brokerAddr, String group,
+        long timeoutMillis);
+
+    CompletableFuture<Void> updateSubscriptionGroupConfig(String brokerAddr, SubscriptionGroupConfig config,
+        long timeoutMillis);
+
+    CompletableFuture<Void> deleteSubscriptionGroup(String brokerAddr, String group, boolean cleanOffset,
+        long timeoutMillis);
+
+    /**
+     * Query messages of a topic. When {@code uniqueKey} is true the key is treated as the
+     * client-generated unique message id (what the gRPC protocol exposes as {@code message_id});
+     * otherwise it is matched against the message keys index.
+     *
+     * @param decompressBody inflate a compressed body before returning. Callers that render the
+     *                       message (QueryMessage) want it inflated; callers that hand the message
+     *                       back to a client as-is (VerifyMessage) must not, so the body keeps
+     *                       matching the compression flag the client will act on.
+     */
+    CompletableFuture<List<MessageExt>> queryMessage(String brokerAddr, String topic, String key, int maxNum,
+        long beginTimestamp, long endTimestamp, boolean uniqueKey, boolean decompressBody, long timeoutMillis);
+
+    CompletableFuture<MessageExt> viewMessage(String brokerAddr, String topic, long physicalOffset,
+        long timeoutMillis);
+
+    CompletableFuture<Long> searchOffsetByTimestamp(String brokerAddr, MessageQueue messageQueue, long timestamp,
+        long timeoutMillis);
 }
diff --git a/proxy/src/main/java/org/apache/rocketmq/proxy/service/admin/DefaultAdminService.java b/proxy/src/main/java/org/apache/rocketmq/proxy/service/admin/DefaultAdminService.java
index f3c68eab5c..af668ac04f 100644
--- a/proxy/src/main/java/org/apache/rocketmq/proxy/service/admin/DefaultAdminService.java
+++ b/proxy/src/main/java/org/apache/rocketmq/proxy/service/admin/DefaultAdminService.java
@@ -20,24 +20,72 @@ package org.apache.rocketmq.proxy.service.admin;
 import java.time.Duration;
 import java.util.HashSet;
 import java.util.List;
+import java.util.Map;
 import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ThreadFactory;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.apache.rocketmq.client.impl.admin.MqClientAdminImpl;
+import org.apache.rocketmq.client.impl.mqclient.MQClientAPIExt;
+import org.apache.rocketmq.client.impl.mqclient.MQClientAPIFactory;
 import org.apache.rocketmq.common.MixAll;
 import org.apache.rocketmq.common.TopicConfig;
 import org.apache.rocketmq.common.constant.LoggerName;
 import org.apache.rocketmq.common.constant.PermName;
-import org.apache.rocketmq.remoting.protocol.route.BrokerData;
-import org.apache.rocketmq.remoting.protocol.route.TopicRouteData;
+import org.apache.rocketmq.common.message.MessageExt;
+import org.apache.rocketmq.common.message.MessageQueue;
 import org.apache.rocketmq.common.topic.TopicValidator;
 import org.apache.rocketmq.logging.org.slf4j.Logger;
 import org.apache.rocketmq.logging.org.slf4j.LoggerFactory;
-import org.apache.rocketmq.client.impl.mqclient.MQClientAPIExt;
-import org.apache.rocketmq.client.impl.mqclient.MQClientAPIFactory;
 import org.apache.rocketmq.proxy.service.route.TopicRouteHelper;
+import org.apache.rocketmq.remoting.protocol.admin.ConsumeStats;
+import org.apache.rocketmq.remoting.protocol.admin.TopicStatsTable;
+import org.apache.rocketmq.remoting.protocol.body.ClusterInfo;
+import org.apache.rocketmq.remoting.protocol.body.ConsumerConnection;
+import org.apache.rocketmq.remoting.protocol.body.GroupList;
+import org.apache.rocketmq.remoting.protocol.body.QueueTimeSpan;
+import org.apache.rocketmq.remoting.protocol.body.TopicList;
+import org.apache.rocketmq.remoting.protocol.header.DeleteSubscriptionGroupRequestHeader;
+import org.apache.rocketmq.remoting.protocol.header.GetConsumeStatsRequestHeader;
+import org.apache.rocketmq.remoting.protocol.header.GetConsumerConnectionListRequestHeader;
+import org.apache.rocketmq.remoting.protocol.header.GetTopicStatsInfoRequestHeader;
+import org.apache.rocketmq.remoting.protocol.header.QueryConsumeTimeSpanRequestHeader;
+import org.apache.rocketmq.remoting.protocol.header.QueryMessageRequestHeader;
+import org.apache.rocketmq.remoting.protocol.header.QueryTopicConsumeByWhoRequestHeader;
+import org.apache.rocketmq.remoting.protocol.header.QueryTopicsByConsumerRequestHeader;
+import org.apache.rocketmq.remoting.protocol.header.ResetOffsetRequestHeader;
+import org.apache.rocketmq.remoting.protocol.header.ViewMessageRequestHeader;
+import org.apache.rocketmq.remoting.protocol.route.BrokerData;
+import org.apache.rocketmq.remoting.protocol.route.TopicRouteData;
+import org.apache.rocketmq.remoting.protocol.subscription.SubscriptionGroupConfig;
 
 public class DefaultAdminService implements AdminService {
     private static final Logger log = LoggerFactory.getLogger(LoggerName.PROXY_LOGGER_NAME);
+    private static final long ROUTE_TIMEOUT_MILLIS = Duration.ofSeconds(3).toMillis();
+
     private final MQClientAPIFactory mqClientAPIFactory;
 
+    /**
+     * A few broker requests only have a blocking client method (topic config, subscription group
+     * config, search offset, nameserver route). The admin gateway promises non-blocking calls so
+     * that a handler fanning out to N brokers never parks a gRPC executor thread, so those are
+     * lifted onto this pool. Daemon threads: the pool lives as long as the proxy process and must
+     * not keep it alive on shutdown.
+     */
+    private final ExecutorService blockingCallExecutor = Executors.newFixedThreadPool(
+        Math.max(4, Runtime.getRuntime().availableProcessors()), new ThreadFactory() {
+            private final AtomicInteger seq = new AtomicInteger();
+
+            @Override
+            public Thread newThread(Runnable r) {
+                Thread thread = new Thread(r, "AdminBlockingCall_" + seq.getAndIncrement());
+                thread.setDaemon(true);
+                return thread;
+            }
+        });
+
     public DefaultAdminService(MQClientAPIFactory mqClientAPIFactory) {
         this.mqClientAPIFactory = mqClientAPIFactory;
     }
@@ -116,7 +164,7 @@ public class DefaultAdminService implements AdminService {
             }
 
             try {
-                this.getClient().createTopic(addr, TopicValidator.AUTO_CREATE_TOPIC_KEY_TOPIC, topicConfig, Duration.ofSeconds(3).toMillis());
+                this.getClient().createTopic(addr, TopicValidator.AUTO_CREATE_TOPIC_KEY_TOPIC, topicConfig, ROUTE_TIMEOUT_MILLIS);
             } catch (Exception e) {
                 log.error("create topic on broker failed. topic:{}, broker:{}", topicConfig, addr, e);
             }
@@ -137,10 +185,179 @@ public class DefaultAdminService implements AdminService {
     }
 
     protected TopicRouteData getTopicRouteDataDirectlyFromNameServer(String topic) throws Exception {
-        return this.getClient().getTopicRouteInfoFromNameServer(topic, Duration.ofSeconds(3).toMillis());
+        return this.getClient().getTopicRouteInfoFromNameServer(topic, ROUTE_TIMEOUT_MILLIS);
     }
 
     protected MQClientAPIExt getClient() {
         return this.mqClientAPIFactory.getClient();
     }
+
+    protected MqClientAdminImpl getAdmin() {
+        return this.getClient().getMqClientAdmin();
+    }
+
+    @Override
+    public void start() {
+    }
+
+    @Override
+    public void shutdown() {
+        this.blockingCallExecutor.shutdownNow();
+    }
+
+    private <T> CompletableFuture<T> supplyBlocking(java.util.function.Supplier<T> call) {
+        return CompletableFuture.supplyAsync(call, blockingCallExecutor);
+    }
+
+    // =========================================================================
+    // Admin gateway: broker-facing queries and mutations.
+    // Every call goes through the proxy's OWN managed broker client.
+    // =========================================================================
+
+    @Override
+    public CompletableFuture<ClusterInfo> getBrokerClusterInfo(long timeoutMillis) {
+        // a null address makes the remoting client talk to the nameserver
+        return this.getAdmin().getBrokerClusterInfo(null, timeoutMillis);
+    }
+
+    @Override
+    public CompletableFuture<TopicRouteData> getTopicRouteData(String topic) {
+        return supplyBlocking(() -> {
+            try {
+                return this.getTopicRouteDataDirectlyFromNameServer(topic);
+            } catch (Exception e) {
+                throw new java.util.concurrent.CompletionException(e);
+            }
+        });
+    }
+
+    @Override
+    public CompletableFuture<TopicConfig> getTopicConfig(String brokerAddr, String topic, long timeoutMillis) {
+        return supplyBlocking(() -> {
+            try {
+                return this.getClient().getTopicConfig(brokerAddr, topic, timeoutMillis);
+            } catch (Exception e) {
+                throw new java.util.concurrent.CompletionException(e);
+            }
+        });
+    }
+
+    @Override
+    public CompletableFuture<TopicStatsTable> getTopicStats(String brokerAddr, String topic, long timeoutMillis) {
+        GetTopicStatsInfoRequestHeader header = new GetTopicStatsInfoRequestHeader();
+        header.setTopic(topic);
+        return this.getAdmin().getTopicStatsInfo(brokerAddr, header, timeoutMillis);
+    }
+
+    @Override
+    public CompletableFuture<ConsumeStats> getConsumeStats(String brokerAddr, String group, String topic,
+        long timeoutMillis) {
+        GetConsumeStatsRequestHeader header = new GetConsumeStatsRequestHeader();
+        header.setConsumerGroup(group);
+        // a blank topic tells the broker to collect stats over every topic the group has offsets for
+        header.setTopic(topic == null ? "" : topic);
+        return this.getAdmin().getConsumeStats(brokerAddr, header, timeoutMillis);
+    }
+
+    @Override
+    public CompletableFuture<List<QueueTimeSpan>> queryConsumeTimeSpan(String brokerAddr, String topic, String group,
+        long timeoutMillis) {
+        QueryConsumeTimeSpanRequestHeader header = new QueryConsumeTimeSpanRequestHeader();
+        header.setTopic(topic);
+        header.setGroup(group);
+        return this.getAdmin().queryConsumeTimeSpan(brokerAddr, header, timeoutMillis);
+    }
+
+    @Override
+    public CompletableFuture<Map<MessageQueue, Long>> resetOffset(String brokerAddr, String topic, String group,
+        long timestamp, boolean isForce, long timeoutMillis) {
+        ResetOffsetRequestHeader header = new ResetOffsetRequestHeader();
+        header.setTopic(topic);
+        header.setGroup(group);
+        header.setTimestamp(timestamp);
+        header.setForce(isForce);
+        return this.getAdmin().invokeBrokerToResetOffset(brokerAddr, header, timeoutMillis);
+    }
+
+    @Override
+    public CompletableFuture<ConsumerConnection> getConsumerConnectionList(String brokerAddr, String group,
+        long timeoutMillis) {
+        GetConsumerConnectionListRequestHeader header = new GetConsumerConnectionListRequestHeader();
+        header.setConsumerGroup(group);
+        return this.getAdmin().getConsumerConnectionList(brokerAddr, header, timeoutMillis);
+    }
+
+    @Override
+    public CompletableFuture<GroupList> queryTopicConsumeByWho(String brokerAddr, String topic, long timeoutMillis) {
+        QueryTopicConsumeByWhoRequestHeader header = new QueryTopicConsumeByWhoRequestHeader();
+        header.setTopic(topic);
+        return this.getAdmin().queryTopicConsumeByWho(brokerAddr, header, timeoutMillis);
+    }
+
+    @Override
+    public CompletableFuture<TopicList> queryTopicsByConsumer(String brokerAddr, String group, long timeoutMillis) {
+        QueryTopicsByConsumerRequestHeader header = new QueryTopicsByConsumerRequestHeader();
+        header.setGroup(group);
+        return this.getAdmin().queryTopicsByConsumer(brokerAddr, header, timeoutMillis);
+    }
+
+    @Override
+    public CompletableFuture<SubscriptionGroupConfig> getSubscriptionGroupConfig(String brokerAddr, String group,
+        long timeoutMillis) {
+        return supplyBlocking(() -> {
+            try {
+                return this.getClient().getSubscriptionGroupConfig(brokerAddr, group, timeoutMillis);
+            } catch (Exception e) {
+                throw new java.util.concurrent.CompletionException(e);
+            }
+        });
+    }
+
+    @Override
+    public CompletableFuture<Void> updateSubscriptionGroupConfig(String brokerAddr, SubscriptionGroupConfig config,
+        long timeoutMillis) {
+        return this.getAdmin().updateOrCreateSubscriptionGroup(brokerAddr, config, timeoutMillis);
+    }
+
+    @Override
+    public CompletableFuture<Void> deleteSubscriptionGroup(String brokerAddr, String group, boolean cleanOffset,
+        long timeoutMillis) {
+        DeleteSubscriptionGroupRequestHeader header = new DeleteSubscriptionGroupRequestHeader();
+        header.setGroupName(group);
+        header.setCleanOffset(cleanOffset);
+        return this.getAdmin().deleteSubscriptionGroup(brokerAddr, header, timeoutMillis);
+    }
+
+    @Override
+    public CompletableFuture<List<MessageExt>> queryMessage(String brokerAddr, String topic, String key, int maxNum,
+        long beginTimestamp, long endTimestamp, boolean uniqueKey, boolean decompressBody, long timeoutMillis) {
+        QueryMessageRequestHeader header = new QueryMessageRequestHeader();
+        header.setTopic(topic);
+        header.setKey(key);
+        header.setMaxNum(maxNum);
+        header.setBeginTimestamp(beginTimestamp);
+        header.setEndTimestamp(endTimestamp);
+        return this.getAdmin().queryMessage(brokerAddr, uniqueKey, decompressBody, header, timeoutMillis);
+    }
+
+    @Override
+    public CompletableFuture<MessageExt> viewMessage(String brokerAddr, String topic, long physicalOffset,
+        long timeoutMillis) {
+        ViewMessageRequestHeader header = new ViewMessageRequestHeader();
+        header.setTopic(topic);
+        header.setOffset(physicalOffset);
+        return this.getAdmin().viewMessage(brokerAddr, header, timeoutMillis);
+    }
+
+    @Override
+    public CompletableFuture<Long> searchOffsetByTimestamp(String brokerAddr, MessageQueue messageQueue,
+        long timestamp, long timeoutMillis) {
+        return supplyBlocking(() -> {
+            try {
+                return this.getClient().searchOffset(brokerAddr, messageQueue, timestamp, timeoutMillis);
+            } catch (Exception e) {
+                throw new java.util.concurrent.CompletionException(e);
+            }
+        });
+    }
 }
diff --git a/proxy/src/main/java/org/apache/rocketmq/proxy/service/relay/ClusterProxyRelayService.java b/proxy/src/main/java/org/apache/rocketmq/proxy/service/relay/ClusterProxyRelayService.java
index 71ce222a8c..6aed6ff6d9 100644
--- a/proxy/src/main/java/org/apache/rocketmq/proxy/service/relay/ClusterProxyRelayService.java
+++ b/proxy/src/main/java/org/apache/rocketmq/proxy/service/relay/ClusterProxyRelayService.java
@@ -26,7 +26,20 @@ import org.apache.rocketmq.remoting.protocol.header.ConsumeMessageDirectlyResult
 import org.apache.rocketmq.remoting.protocol.header.GetConsumerRunningInfoRequestHeader;
 
 /**
- * not implement yet
+ * Relay service used when the proxy runs in CLUSTER mode, i.e. there is no co-located broker
+ * whose {@code NettyRemotingAbstract} could receive the relayed answer (that is what
+ * {@link LocalProxyRelayService} does).
+ *
+ * <p>In CLUSTER mode the proxy process itself is the caller: it writes a
+ * {@link ProxyRelayRequest} into a {@link ProxyChannel} and waits on the future carried by that
+ * request. This class therefore hands back a real, non-null relay future (returning {@code null}
+ * used to make {@code GrpcChannelManager#addResponseFuture} store a null and the caller never
+ * observe any result) and bridges it to the caller's future, including the exceptional path and
+ * the {@code GrpcChannelManager#scanExpireResultFuture} timeout completion.
+ *
+ * <p>A plain {@link RemotingCommand} (not a {@link ProxyRelayRequest}) has no in-process waiter;
+ * the relay future is still returned so that the channel implementation can register it for the
+ * client answer, but nobody observes the outcome.
  */
 public class ClusterProxyRelayService extends AbstractProxyRelayService {
 
@@ -38,13 +51,41 @@ public class ClusterProxyRelayService extends AbstractProxyRelayService {
     public CompletableFuture<ProxyRelayResult<ConsumerRunningInfo>> processGetConsumerRunningInfo(
         ProxyContext context, RemotingCommand command,
         GetConsumerRunningInfoRequestHeader header) {
-        return null;
+        CompletableFuture<ProxyRelayResult<ConsumerRunningInfo>> relayFuture = new CompletableFuture<>();
+        bridgeToCaller(command, relayFuture);
+        return relayFuture;
     }
 
     @Override
     public CompletableFuture<ProxyRelayResult<ConsumeMessageDirectlyResult>> processConsumeMessageDirectly(
         ProxyContext context, RemotingCommand command,
         ConsumeMessageDirectlyResultRequestHeader header) {
-        return null;
+        CompletableFuture<ProxyRelayResult<ConsumeMessageDirectlyResult>> relayFuture = new CompletableFuture<>();
+        bridgeToCaller(command, relayFuture);
+        return relayFuture;
+    }
+
+    /**
+     * Propagates the relay outcome to the in-process caller that attached its future to the
+     * command. Both normal and exceptional completions are forwarded so the caller never has to
+     * fall back on its own timeout.
+     */
+    private static <T> void bridgeToCaller(RemotingCommand command,
+        CompletableFuture<ProxyRelayResult<T>> relayFuture) {
+        if (!(command instanceof ProxyRelayRequest)) {
+            return;
+        }
+        CompletableFuture<ProxyRelayResult<T>> callerFuture =
+            ((ProxyRelayRequest) command).typedResponseFuture();
+        if (callerFuture == null) {
+            return;
+        }
+        relayFuture.whenComplete((relayResult, throwable) -> {
+            if (throwable != null) {
+                callerFuture.completeExceptionally(throwable);
+            } else {
+                callerFuture.complete(relayResult);
+            }
+        });
     }
 }
diff --git a/proxy/src/main/java/org/apache/rocketmq/proxy/service/relay/ProxyRelayRequest.java b/proxy/src/main/java/org/apache/rocketmq/proxy/service/relay/ProxyRelayRequest.java
new file mode 100644
index 0000000000..982cd2e54d
--- /dev/null
+++ b/proxy/src/main/java/org/apache/rocketmq/proxy/service/relay/ProxyRelayRequest.java
@@ -0,0 +1,118 @@
+/*
+ * 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.rocketmq.proxy.service.relay;
+
+import com.alibaba.fastjson2.annotation.JSONField;
+import java.util.concurrent.CompletableFuture;
+import org.apache.rocketmq.remoting.CommandCustomHeader;
+import org.apache.rocketmq.remoting.protocol.RemotingCommand;
+import org.apache.rocketmq.remoting.protocol.body.ConsumeMessageDirectlyResult;
+import org.apache.rocketmq.remoting.protocol.body.ConsumerRunningInfo;
+
+/**
+ * A {@link RemotingCommand} that travels through the in-process relay pipeline
+ * ({@code ProxyChannel#writeAndFlush}) instead of a real Netty socket, and therefore has to
+ * carry the caller's {@link CompletableFuture} along with the wire fields.
+ *
+ * <p>Why this exists: {@code ProxyChannel#writeAndFlush(Object)} can only return a Netty
+ * {@code ChannelFuture}, so an in-process caller (CLUSTER-mode proxy admin) that writes a
+ * {@code GET_CONSUMER_RUNNING_INFO} / {@code CONSUME_MESSAGE_DIRECTLY} command into a
+ * {@code ProxyChannel} has no way to observe the client's answer. The relay future created by
+ * {@link ProxyRelayService} is consumed by the concrete channel implementation (it is handed to
+ * {@code GrpcChannelManager#addResponseFuture}) and never surfaces again. Attaching the caller's
+ * future to the command lets {@link ClusterProxyRelayService} bridge the two.
+ *
+ * <p><b>Payload typing.</b> The future is stored type-erased. The request code fully determines
+ * the payload type and is the only contract between the caller and the relay:
+ * <ul>
+ *   <li>{@code RequestCode.GET_CONSUMER_RUNNING_INFO} &rarr; a future of {@link ProxyRelayResult}
+ *       whose payload is a {@link ConsumerRunningInfo}</li>
+ *   <li>{@code RequestCode.CONSUME_MESSAGE_DIRECTLY} &rarr; a future of {@link ProxyRelayResult}
+ *       whose payload is a {@link ConsumeMessageDirectlyResult}</li>
+ * </ul>
+ *
+ * <p>This command is strictly in-process: it is never encoded onto a socket, so the non-wire
+ * field below is excluded from the fastjson2 view of the command.
+ */
+public class ProxyRelayRequest extends RemotingCommand {
+
+    /**
+     * The in-process caller's future, kept type-erased (see the class javadoc for the code to
+     * payload mapping). Never serialized.
+     */
+    @JSONField(serialize = false)
+    private final transient CompletableFuture<?> responseFuture;
+
+    protected ProxyRelayRequest(CompletableFuture<?> responseFuture) {
+        this.responseFuture = responseFuture;
+    }
+
+    /**
+     * Mirrors {@link RemotingCommand#createRequestCommand(int, CommandCustomHeader)}. The only
+     * difference is that the private {@code customHeader} field of the superclass is populated
+     * through the public {@link #writeCustomHeader(CommandCustomHeader)} hook, so that
+     * {@code ProxyChannel#writeAndFlush} can read it back with {@code readCustomHeader()}.
+     *
+     * @param code           {@code RequestCode.GET_CONSUMER_RUNNING_INFO} or
+     *                       {@code RequestCode.CONSUME_MESSAGE_DIRECTLY}
+     * @param customHeader   the matching request header
+     * @param responseFuture the caller's future, typed as documented on the class
+     */
+    public static ProxyRelayRequest createRequestCommand(int code, CommandCustomHeader customHeader,
+        CompletableFuture<?> responseFuture) {
+        ProxyRelayRequest cmd = new ProxyRelayRequest(responseFuture);
+        cmd.setCode(code);
+        cmd.writeCustomHeader(customHeader);
+        setCmdVersion(cmd);
+        return cmd;
+    }
+
+    /**
+     * Type-erased view of the caller's future. Prefer {@link #typedResponseFuture()} unless the
+     * caller only needs to know whether a future was attached at all.
+     */
+    public CompletableFuture<?> getResponseFuture() {
+        return responseFuture;
+    }
+
+    /**
+     * Typed view of the caller's future, e.g.
+     * {@code CompletableFuture<ProxyRelayResult<ConsumerRunningInfo>>}.
+     *
+     * @param <T> the payload type implied by the request code
+     */
+    public <T> CompletableFuture<ProxyRelayResult<T>> typedResponseFuture() {
+        return castResponseFuture(this.responseFuture);
+    }
+
+    /**
+     * The single unavoidable unchecked cast of this design.
+     *
+     * <p>It is safe because both ends of the future live inside the same proxy process and are
+     * written by the same feature: the caller creates
+     * {@code CompletableFuture<ProxyRelayResult<ConsumerRunningInfo>>} (or the
+     * {@link ConsumeMessageDirectlyResult} twin), pairs it with the matching request code in
+     * {@link #createRequestCommand}, and the relay only ever completes it with the payload of the
+     * same {@link ProxyRelayService} method that the request code selected. The command never
+     * crosses a process boundary, so no untrusted producer can violate the pairing.
+     */
+    @SuppressWarnings("unchecked")
+    private static <T> CompletableFuture<ProxyRelayResult<T>> castResponseFuture(CompletableFuture<?> raw) {
+        return (CompletableFuture<ProxyRelayResult<T>>) raw;
+    }
+}
diff --git a/proxy/src/test/java/org/apache/rocketmq/proxy/grpc/GrpcServerInterceptorOrderTest.java b/proxy/src/test/java/org/apache/rocketmq/proxy/grpc/GrpcServerInterceptorOrderTest.java
new file mode 100644
index 0000000000..2c1044342c
--- /dev/null
+++ b/proxy/src/test/java/org/apache/rocketmq/proxy/grpc/GrpcServerInterceptorOrderTest.java
@@ -0,0 +1,102 @@
+/*
+ * 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.rocketmq.proxy.grpc;
+
+import io.grpc.Attributes;
+import io.grpc.Metadata;
+import io.grpc.ServerBuilder;
+import io.grpc.ServerCall;
+import io.grpc.ServerCallHandler;
+import io.grpc.ServerInterceptor;
+import java.util.List;
+import org.apache.rocketmq.common.constant.GrpcConstants;
+import org.apache.rocketmq.proxy.grpc.constant.AttributeKeys;
+import org.apache.rocketmq.proxy.grpc.interceptor.HeaderInterceptor;
+import org.junit.Test;
+import org.mockito.ArgumentCaptor;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.atLeastOnce;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * The admin server authenticates in an interceptor, and authentication reads the channel id from the
+ * request metadata. Since authentication results are cached per channel id, it must never run on the
+ * client-supplied value: a second connection could otherwise present the same id and reuse the first
+ * connection's successful authentication. These tests pin the two properties that together prevent
+ * that — the ordering of the pipeline, and the header normalization it performs.
+ */
+public class GrpcServerInterceptorOrderTest {
+
+    /**
+     * gRPC invokes server interceptors in reverse registration order, so an interceptor that must
+     * run after {@link HeaderInterceptor} has to be registered before it.
+     */
+    @Test
+    public void postHeaderInterceptorsAreOrderedAfterHeaderInterceptorTest() {
+        ServerInterceptor authInterceptor = mock(ServerInterceptor.class);
+        @SuppressWarnings("unchecked")
+        ServerBuilder<?> serverBuilder = mock(ServerBuilder.class);
+
+        GrpcServerBuilder.configureInterceptors(serverBuilder, authInterceptor);
+
+        ArgumentCaptor<ServerInterceptor> captor = ArgumentCaptor.forClass(ServerInterceptor.class);
+        verify(serverBuilder, atLeastOnce()).intercept(captor.capture());
+        List<ServerInterceptor> registrationOrder = captor.getAllValues();
+
+        int authIndex = registrationOrder.indexOf(authInterceptor);
+        int headerIndex = -1;
+        for (int i = 0; i < registrationOrder.size(); i++) {
+            if (registrationOrder.get(i) instanceof HeaderInterceptor) {
+                headerIndex = i;
+            }
+        }
+
+        assertSame(authInterceptor, registrationOrder.get(0));
+        assertTrue("HeaderInterceptor must be registered", headerIndex >= 0);
+        // registered earlier means executed later, so authentication observes normalized headers
+        assertTrue("authentication must be registered before HeaderInterceptor so that it runs after it",
+            authIndex < headerIndex);
+    }
+
+    /**
+     * The normalization the ordering above depends on: whatever channel id the client puts on the
+     * wire is discarded in favour of the one derived from the transport.
+     */
+    @Test
+    public void headerInterceptorReplacesClientSuppliedChannelIdTest() {
+        Metadata headers = new Metadata();
+        headers.put(GrpcConstants.CHANNEL_ID, "forged-by-client");
+
+        ServerCall<Object, Object> call = mock(ServerCall.class);
+        when(call.getAttributes()).thenReturn(Attributes.newBuilder()
+            .set(AttributeKeys.CHANNEL_ID, "transport-channel-id")
+            .build());
+        ServerCallHandler<Object, Object> next = mock(ServerCallHandler.class);
+
+        new HeaderInterceptor().interceptCall(call, headers, next);
+
+        assertEquals("transport-channel-id", headers.get(GrpcConstants.CHANNEL_ID));
+        verify(next).startCall(any(), any());
+    }
+}
diff --git a/proxy/src/test/java/org/apache/rocketmq/proxy/grpc/admin/AdminModelConverterTest.java b/proxy/src/test/java/org/apache/rocketmq/proxy/grpc/admin/AdminModelConverterTest.java
new file mode 100644
index 0000000000..40608cab5c
--- /dev/null
+++ b/proxy/src/test/java/org/apache/rocketmq/proxy/grpc/admin/AdminModelConverterTest.java
@@ -0,0 +1,533 @@
+/*
+ * 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.rocketmq.proxy.grpc.admin;
+
+import apache.rocketmq.v2.Code;
+import apache.rocketmq.v2.DescribeGroupAccumulationResponse.GroupAccumulation;
+import apache.rocketmq.v2.DescribeTopicStatusResponse;
+import apache.rocketmq.v2.FilterType;
+import apache.rocketmq.v2.GetTopicRouteResponse;
+import apache.rocketmq.v2.MessageQueueItem;
+import apache.rocketmq.v2.MessageType;
+import apache.rocketmq.v2.QueryTimeSpanResponse;
+import com.alibaba.fastjson2.JSON;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Properties;
+import org.apache.rocketmq.common.KeyBuilder;
+import org.apache.rocketmq.common.MixAll;
+import org.apache.rocketmq.common.TopicConfig;
+import org.apache.rocketmq.common.attribute.TopicMessageType;
+import org.apache.rocketmq.common.filter.ExpressionType;
+import org.apache.rocketmq.common.message.MessageQueue;
+import org.apache.rocketmq.remoting.protocol.LanguageCode;
+import org.apache.rocketmq.remoting.protocol.admin.ConsumeStats;
+import org.apache.rocketmq.remoting.protocol.admin.OffsetWrapper;
+import org.apache.rocketmq.remoting.protocol.body.Connection;
+import org.apache.rocketmq.remoting.protocol.body.ConsumeStatus;
+import org.apache.rocketmq.remoting.protocol.body.ConsumerRunningInfo;
+import org.apache.rocketmq.remoting.protocol.body.ProcessQueueInfo;
+import org.apache.rocketmq.remoting.protocol.body.QueueTimeSpan;
+import org.apache.rocketmq.remoting.protocol.heartbeat.MessageModel;
+import org.apache.rocketmq.remoting.protocol.heartbeat.SubscriptionData;
+import org.apache.rocketmq.remoting.protocol.route.TopicRouteData;
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+public class AdminModelConverterTest {
+
+    private static final String TOPIC = "topicA";
+    private static final String GROUP = "groupA";
+    private static final String BROKER_A = "broker-a";
+    private static final String BROKER_B = "broker-b";
+    private static final String ADDR_A = "127.0.0.1:10911";
+    private static final String ADDR_B = "127.0.0.2:10911";
+
+    // ------------------------------------------------------------------ helpers
+
+    private static OffsetWrapper offsetWrapper(long brokerOffset, long consumerOffset, long pullOffset,
+        long lastTimestamp) {
+        OffsetWrapper wrapper = new OffsetWrapper();
+        wrapper.setBrokerOffset(brokerOffset);
+        wrapper.setConsumerOffset(consumerOffset);
+        wrapper.setPullOffset(pullOffset);
+        wrapper.setLastTimestamp(lastTimestamp);
+        return wrapper;
+    }
+
+    private static ConsumeStats consumeStats(String topic, String brokerName, int queueId, OffsetWrapper wrapper) {
+        ConsumeStats stats = new ConsumeStats();
+        stats.getOffsetTable().put(new MessageQueue(topic, brokerName, queueId), wrapper);
+        return stats;
+    }
+
+    private static Map<String, ConsumeStats> statsByBroker(ConsumeStats... statsList) {
+        Map<String, ConsumeStats> map = new LinkedHashMap<>();
+        for (int i = 0; i < statsList.length; i++) {
+            map.put(i == 0 ? ADDR_A : ADDR_B, statsList[i]);
+        }
+        return map;
+    }
+
+    private static QueueTimeSpan queueTimeSpan(String topic, String brokerName, int queueId, long min, long max,
+        long consume, long delay) {
+        QueueTimeSpan span = new QueueTimeSpan();
+        span.setMessageQueue(new MessageQueue(topic, brokerName, queueId));
+        span.setMinTimeStamp(min);
+        span.setMaxTimeStamp(max);
+        span.setConsumeTimeStamp(consume);
+        span.setDelayTime(delay);
+        return span;
+    }
+
+    // ------------------------------------------------------------------ toAccumulation
+
+    @Test
+    public void toAccumulationSplitsInflightFromReadyTest() {
+        // brokerOffset=100 consumerOffset=60 pullOffset=75:
+        // accumulation = 100-60 = 40, inflight = 75-60 = 15, ready = 40-15 = 25
+        ConsumeStats stats = consumeStats(TOPIC, BROKER_A, 0, offsetWrapper(100L, 60L, 75L, 0L));
+
+        AdminModelConverter.AccumulationResult result =
+            AdminModelConverter.toAccumulation(statsByBroker(stats), GROUP);
+
+        assertEquals(40L, result.total.getAccumulation());
+        assertEquals(15L, result.total.getInflightMessages());
+        assertEquals(25L, result.total.getReadyMessages());
+        GroupAccumulation byTopic = result.byTopic.get(TOPIC);
+        assertNotNull(byTopic);
+        assertEquals(40L, byTopic.getAccumulation());
+        assertEquals(15L, byTopic.getInflightMessages());
+        assertEquals(25L, byTopic.getReadyMessages());
+    }
+
+    @Test
+    public void toAccumulationClampsNegativeDiffsToZeroTest() {
+        // consumer ahead of the broker (offset moved back, stats skew): never report negative lag
+        ConsumeStats stats = consumeStats(TOPIC, BROKER_A, 0, offsetWrapper(50L, 60L, 40L, 0L));
+
+        AdminModelConverter.AccumulationResult result =
+            AdminModelConverter.toAccumulation(statsByBroker(stats), GROUP);
+
+        assertEquals(0L, result.total.getAccumulation());
+        assertEquals(0L, result.total.getInflightMessages());
+        assertEquals(0L, result.total.getReadyMessages());
+        assertEquals(0L, result.byTopic.get(TOPIC).getAccumulation());
+    }
+
+    @Test
+    public void toAccumulationCapsInflightAtAccumulationTest() {
+        // pullOffset beyond brokerOffset: inflight must not exceed accumulation, ready stays >= 0
+        ConsumeStats stats = consumeStats(TOPIC, BROKER_A, 0, offsetWrapper(100L, 90L, 120L, 0L));
+
+        AdminModelConverter.AccumulationResult result =
+            AdminModelConverter.toAccumulation(statsByBroker(stats), GROUP);
+
+        assertEquals(10L, result.total.getAccumulation());
+        assertEquals(10L, result.total.getInflightMessages());
+        assertEquals(0L, result.total.getReadyMessages());
+    }
+
+    @Test
+    public void toAccumulationFoldsPopRetryTopicIntoNormalTopicTest() {
+        ConsumeStats normal = consumeStats(TOPIC, BROKER_A, 0, offsetWrapper(100L, 60L, 60L, 0L));
+        ConsumeStats popRetryV1 = consumeStats(KeyBuilder.buildPopRetryTopicV1(TOPIC, GROUP), BROKER_A, 0,
+            offsetWrapper(10L, 5L, 5L, 0L));
+        ConsumeStats popRetryV2 = consumeStats(KeyBuilder.buildPopRetryTopicV2(TOPIC, GROUP), BROKER_B, 0,
+            offsetWrapper(20L, 5L, 5L, 0L));
+        Map<String, ConsumeStats> byBroker = new LinkedHashMap<>();
+        byBroker.put(ADDR_A, merge(normal, popRetryV1));
+        byBroker.put(ADDR_B, popRetryV2);
+
+        AdminModelConverter.AccumulationResult result = AdminModelConverter.toAccumulation(byBroker, GROUP);
+
+        // 40 (normal) + 5 (pop retry v1) + 15 (pop retry v2)
+        assertEquals(60L, result.total.getAccumulation());
+        assertEquals(1, result.byTopic.size());
+        assertFalse(result.byTopic.containsKey(KeyBuilder.buildPopRetryTopicV1(TOPIC, GROUP)));
+        assertFalse(result.byTopic.containsKey(KeyBuilder.buildPopRetryTopicV2(TOPIC, GROUP)));
+        assertEquals(60L, result.byTopic.get(TOPIC).getAccumulation());
+    }
+
+    @Test
+    public void toAccumulationCountsPullRetryInTotalButNotInByTopicTest() {
+        String pullRetryTopic = MixAll.getRetryTopic(GROUP);
+        ConsumeStats pullRetry = consumeStats(pullRetryTopic, BROKER_A, 0, offsetWrapper(30L, 10L, 10L, 0L));
+        ConsumeStats normal = consumeStats(TOPIC, BROKER_A, 1, offsetWrapper(100L, 60L, 60L, 0L));
+
+        AdminModelConverter.AccumulationResult result =
+            AdminModelConverter.toAccumulation(statsByBroker(merge(pullRetry, normal)), GROUP);
+
+        // the pull retry backlog is real lag of the group, so it belongs to the total ...
+        assertEquals(60L, result.total.getAccumulation());
+        // ... but %RETRY%<group> is not a user-visible topic, so it must not show up per topic
+        assertEquals(1, result.byTopic.size());
+        assertFalse(result.byTopic.containsKey(pullRetryTopic));
+        assertEquals(40L, result.byTopic.get(TOPIC).getAccumulation());
+    }
+
+    @Test
+    public void toAccumulationSetsLastConsumeTimestampAndDelayTest() {
+        long lastTimestamp = System.currentTimeMillis() - 5000L;
+        ConsumeStats stats = consumeStats(TOPIC, BROKER_A, 0, offsetWrapper(100L, 60L, 60L, lastTimestamp));
+
+        AdminModelConverter.AccumulationResult result =
+            AdminModelConverter.toAccumulation(statsByBroker(stats), GROUP);
+
+        assertEquals(lastTimestamp, result.total.getLastConsumeTimestamp());
+        assertTrue(result.total.hasDeliverDelayTime());
+        assertTrue(result.total.getDeliverDelayTime().getSeconds() >= 4L);
+    }
+
+    @Test
+    public void toAccumulationIgnoresTimestampWhenCaughtUpTest() {
+        ConsumeStats stats = consumeStats(TOPIC, BROKER_A, 0,
+            offsetWrapper(60L, 60L, 60L, System.currentTimeMillis()));
+
+        AdminModelConverter.AccumulationResult result =
+            AdminModelConverter.toAccumulation(statsByBroker(stats), GROUP);
+
+        assertEquals(0L, result.total.getAccumulation());
+        assertEquals(0L, result.total.getLastConsumeTimestamp());
+        assertFalse(result.total.hasDeliverDelayTime());
+    }
+
+    @Test
+    public void toAccumulationEmptyInputIsZeroTest() {
+        AdminModelConverter.AccumulationResult result =
+            AdminModelConverter.toAccumulation(Collections.emptyMap(), GROUP);
+
+        assertEquals(0L, result.total.getAccumulation());
+        assertEquals(0L, result.total.getInflightMessages());
+        assertEquals(0L, result.total.getReadyMessages());
+        assertTrue(result.byTopic.isEmpty());
+    }
+
+    private static ConsumeStats merge(ConsumeStats first, ConsumeStats second) {
+        ConsumeStats merged = new ConsumeStats();
+        merged.getOffsetTable().putAll(first.getOffsetTable());
+        merged.getOffsetTable().putAll(second.getOffsetTable());
+        return merged;
+    }
+
+    // ------------------------------------------------------------------ toQueryTimeSpan
+
+    @Test
+    public void toQueryTimeSpanMapsAllFieldsTest() {
+        List<QueueTimeSpan> spans = Collections.singletonList(
+            queueTimeSpan(TOPIC, BROKER_A, 3, 100L, 200L, 150L, 42L));
+
+        QueryTimeSpanResponse response = AdminModelConverter.toQueryTimeSpan(spans);
+
+        assertEquals(Code.OK, response.getStatus().getCode());
+        assertEquals(1, response.getQueueTimeSpanListCount());
+        QueryTimeSpanResponse.QueueTimeSpan span = response.getQueueTimeSpanList(0);
+        assertEquals(TOPIC, span.getMessageQueue().getTopic().getName());
+        assertEquals(BROKER_A, span.getMessageQueue().getBroker().getName());
+        assertEquals(3, span.getMessageQueue().getId());
+        assertEquals(100L, span.getMinTimestamp());
+        assertEquals(200L, span.getMaxTimestamp());
+        assertEquals(150L, span.getConsumeTimestamp());
+        assertEquals(42L, span.getDelayTimeMs());
+    }
+
+    @Test
+    public void toQueryTimeSpanClampsNegativeDelayToZeroTest() {
+        List<QueueTimeSpan> spans = Collections.singletonList(
+            queueTimeSpan(TOPIC, BROKER_A, 0, 100L, 200L, 150L, -5L));
+
+        QueryTimeSpanResponse response = AdminModelConverter.toQueryTimeSpan(spans);
+
+        assertEquals(0L, response.getQueueTimeSpanList(0).getDelayTimeMs());
+    }
+
+    @Test
+    public void toQueryTimeSpanSkipsNullAndHandlesNullListTest() {
+        // a null list is tolerated and yields an OK, empty response
+        QueryTimeSpanResponse nullResponse = AdminModelConverter.toQueryTimeSpan(null);
+        assertEquals(Code.OK, nullResponse.getStatus().getCode());
+        assertEquals(0, nullResponse.getQueueTimeSpanListCount());
+
+        // null / queue-less entries are skipped, only the valid span is mapped
+        List<QueueTimeSpan> spans = new ArrayList<>();
+        spans.add(null);
+        spans.add(new QueueTimeSpan());
+        spans.add(queueTimeSpan(TOPIC, BROKER_A, 0, 1L, 2L, 3L, 4L));
+        QueryTimeSpanResponse response = AdminModelConverter.toQueryTimeSpan(spans);
+        assertEquals(Code.OK, response.getStatus().getCode());
+        assertEquals(1, response.getQueueTimeSpanListCount());
+    }
+
+    // ------------------------------------------------------------------ toMessageType / toTopicStatus
+
+    @Test
+    public void toMessageTypeMapsKnownAndDegradesUnknownTest() {
+        assertEquals(MessageType.NORMAL, AdminModelConverter.toMessageType(TopicMessageType.NORMAL));
+        assertEquals(MessageType.FIFO, AdminModelConverter.toMessageType(TopicMessageType.FIFO));
+        assertEquals(MessageType.DELAY, AdminModelConverter.toMessageType(TopicMessageType.DELAY));
+        assertEquals(MessageType.TRANSACTION, AdminModelConverter.toMessageType(TopicMessageType.TRANSACTION));
+        assertEquals(MessageType.LITE, AdminModelConverter.toMessageType(TopicMessageType.LITE));
+        // MessageType.UNRECOGNIZED cannot be set on a builder, so unknowns must degrade, not blow up
+        for (TopicMessageType type : Arrays.asList(TopicMessageType.MIXED, TopicMessageType.PRIORITY,
+            TopicMessageType.UNSPECIFIED, null)) {
+            MessageType mapped = AdminModelConverter.toMessageType(type);
+            assertEquals(MessageType.MESSAGE_TYPE_UNSPECIFIED, mapped);
+            assertNotEquals(MessageType.UNRECOGNIZED, mapped);
+        }
+    }
+
+    @Test
+    public void toTopicStatusReadsMessageTypeFromAttributesTest() {
+        TopicConfig config = new TopicConfig();
+        config.setTopicName(TOPIC);
+        config.setReadQueueNums(8);
+        config.setWriteQueueNums(16);
+        config.setPerm(6);
+        config.setTopicMessageType(TopicMessageType.FIFO);
+
+        DescribeTopicStatusResponse response = AdminModelConverter.toTopicStatus(config, TOPIC);
+
+        assertEquals(Code.OK, response.getStatus().getCode());
+        assertEquals(MessageType.FIFO, response.getTopicMessageType());
+        assertTrue(response.getDescription().contains("readQueueNums=8"));
+        assertTrue(response.getDescription().contains("writeQueueNums=16"));
+        assertTrue(response.getDescription().contains("perm=6"));
+    }
+
+    @Test
+    public void toTopicStatusFallsBackWhenConfigThinOrMissingTest() {
+        // an empty config (no message-type attribute) defaults to NORMAL
+        DescribeTopicStatusResponse normal = AdminModelConverter.toTopicStatus(new TopicConfig(), TOPIC);
+        assertEquals(MessageType.NORMAL, normal.getTopicMessageType());
+
+        // a null config stays honest: UNSPECIFIED type + an explanatory description
+        DescribeTopicStatusResponse missing = AdminModelConverter.toTopicStatus(null, TOPIC);
+        assertEquals(Code.OK, missing.getStatus().getCode());
+        assertEquals(MessageType.MESSAGE_TYPE_UNSPECIFIED, missing.getTopicMessageType());
+        assertTrue(missing.getDescription().contains("topic config not available for " + TOPIC));
+    }
+
+    // ------------------------------------------------------------------ toTopicRoute / toMessageQueue
+
+    @Test
+    public void toTopicRouteSerializesRouteDataOrEmptyJsonTest() {
+        TopicRouteData routeData = new TopicRouteData();
+        routeData.setOrderTopicConf("orderConf");
+        GetTopicRouteResponse response = AdminModelConverter.toTopicRoute(routeData);
+        assertEquals(Code.OK, response.getStatus().getCode());
+        assertEquals(JSON.toJSONString(routeData), response.getTopicRouteData());
+
+        // a null route serializes to empty JSON rather than the literal "null"
+        GetTopicRouteResponse nullResponse = AdminModelConverter.toTopicRoute(null);
+        assertEquals(Code.OK, nullResponse.getStatus().getCode());
+        assertEquals("{}", nullResponse.getTopicRouteData());
+    }
+
+    @Test
+    public void toMessageQueueSetsBrokerAndIdTest() {
+        apache.rocketmq.v2.MessageQueue v2 =
+            AdminModelConverter.toMessageQueue(new MessageQueue(TOPIC, BROKER_A, 3));
+
+        assertEquals(TOPIC, v2.getTopic().getName());
+        assertEquals(BROKER_A, v2.getBroker().getName());
+        assertEquals(3, v2.getId());
+    }
+
+    // ------------------------------------------------------------------ clients
+
+    @Test
+    public void toClientInfoParsesHostnameAndPlainAddrTest() {
+        // "hostname@ip:port" splits into hostname + egress ip; message model carried through
+        Connection withHost = new Connection();
+        withHost.setClientId("CID-1");
+        withHost.setClientAddr("consumer-host@192.168.1.5:43210");
+        withHost.setLanguage(LanguageCode.JAVA);
+        withHost.setVersion(355);
+        apache.rocketmq.v2.ClientInfo hostInfo = AdminModelConverter.toClientInfo(withHost,
+            apache.rocketmq.v2.MessageModel.CLUSTERING);
+        assertEquals("CID-1", hostInfo.getClientId());
+        assertEquals("JAVA", hostInfo.getLanguage());
+        assertEquals("355", hostInfo.getVersion());
+        assertEquals("192.168.1.5", hostInfo.getEgressIp());
+        assertEquals("consumer-host", hostInfo.getHostname());
+        assertEquals(apache.rocketmq.v2.MessageModel.CLUSTERING, hostInfo.getMessageModel());
+
+        // a plain "ip:port" yields no hostname; null message model degrades to UNSPECIFIED
+        Connection plain = new Connection();
+        plain.setClientId("CID-2");
+        plain.setClientAddr("192.168.1.5:43210");
+        apache.rocketmq.v2.ClientInfo plainInfo = AdminModelConverter.toClientInfo(plain, null);
+        assertEquals("192.168.1.5", plainInfo.getEgressIp());
+        assertEquals("", plainInfo.getHostname());
+        assertEquals(apache.rocketmq.v2.MessageModel.MESSAGE_MODEL_UNSPECIFIED, plainInfo.getMessageModel());
+    }
+
+    @Test
+    public void toMessageModelMapsValuesTest() {
+        assertEquals(apache.rocketmq.v2.MessageModel.BROADCASTING,
+            AdminModelConverter.toMessageModel(MessageModel.BROADCASTING));
+        assertEquals(apache.rocketmq.v2.MessageModel.CLUSTERING,
+            AdminModelConverter.toMessageModel(MessageModel.CLUSTERING));
+        assertEquals(apache.rocketmq.v2.MessageModel.MESSAGE_MODEL_UNSPECIFIED,
+            AdminModelConverter.toMessageModel(null));
+    }
+
+    @Test
+    public void toFilterExpressionMapsSqlAndTagTest() {
+        apache.rocketmq.v2.FilterExpression sql =
+            AdminModelConverter.toFilterExpression(ExpressionType.SQL92, "a > 1");
+        assertEquals(FilterType.SQL, sql.getType());
+        assertEquals("a > 1", sql.getExpression());
+
+        apache.rocketmq.v2.FilterExpression tag = AdminModelConverter.toFilterExpression(ExpressionType.TAG, "tagA");
+        assertEquals(FilterType.TAG, tag.getType());
+        assertEquals("tagA", tag.getExpression());
+
+        apache.rocketmq.v2.FilterExpression nullExpression = AdminModelConverter.toFilterExpression(null, null);
+        assertEquals(FilterType.TAG, nullExpression.getType());
+        assertEquals("", nullExpression.getExpression());
+    }
+
+    @Test
+    public void toConsumerRunningInfoMapsAllSectionsTest() {
+        ConsumerRunningInfo runningInfo = new ConsumerRunningInfo();
+        Properties properties = runningInfo.getProperties();
+        properties.setProperty(ConsumerRunningInfo.PROP_CLIENT_VERSION, "V5_0_0");
+        properties.setProperty(ConsumerRunningInfo.PROP_CONSUME_ORDERLY, "false");
+
+        SubscriptionData tagSub = new SubscriptionData();
+        tagSub.setTopic(TOPIC);
+        tagSub.setSubString("tagA");
+        tagSub.setExpressionType(ExpressionType.TAG);
+        SubscriptionData sqlSub = new SubscriptionData();
+        sqlSub.setTopic("topicB");
+        sqlSub.setSubString("a > 1");
+        sqlSub.setExpressionType(ExpressionType.SQL92);
+        runningInfo.getSubscriptionSet().add(tagSub);
+        runningInfo.getSubscriptionSet().add(sqlSub);
+
+        ProcessQueueInfo processQueueInfo = new ProcessQueueInfo();
+        processQueueInfo.setCommitOffset(10L);
+        processQueueInfo.setCachedMsgMinOffset(5L);
+        processQueueInfo.setCachedMsgMaxOffset(15L);
+        processQueueInfo.setCachedMsgCount(7);
+        processQueueInfo.setCachedMsgSizeInMiB(2);
+        processQueueInfo.setTransactionMsgMinOffset(1L);
+        processQueueInfo.setTransactionMsgMaxOffset(3L);
+        processQueueInfo.setTransactionMsgCount(4);
+        processQueueInfo.setLocked(true);
+        processQueueInfo.setTryUnlockTimes(9L);
+        processQueueInfo.setLastLockTimestamp(111L);
+        processQueueInfo.setDroped(false);
+        processQueueInfo.setLastPullTimestamp(222L);
+        processQueueInfo.setLastConsumeTimestamp(333L);
+        runningInfo.getMqTable().put(new MessageQueue(TOPIC, BROKER_A, 0), processQueueInfo);
+
+        ConsumeStatus consumeStatus = new ConsumeStatus();
+        consumeStatus.setPullRT(1.5D);
+        consumeStatus.setPullTPS(2.5D);
+        consumeStatus.setConsumeRT(3.5D);
+        consumeStatus.setConsumeOKTPS(4.5D);
+        consumeStatus.setConsumeFailedTPS(5.5D);
+        consumeStatus.setConsumeFailedMsgs(6L);
+        runningInfo.getStatusTable().put(TOPIC, consumeStatus);
+
+        apache.rocketmq.v2.ConsumerRunningInfo v2 = AdminModelConverter.toConsumerRunningInfo(runningInfo);
+
+        // 1. properties
+        assertEquals("V5_0_0", v2.getPropertiesMap().get(ConsumerRunningInfo.PROP_CLIENT_VERSION));
+        assertEquals("false", v2.getPropertiesMap().get(ConsumerRunningInfo.PROP_CONSUME_ORDERLY));
+        // 2. subscriptions
+        assertEquals(2, v2.getSubscriptionsCount());
+        assertEquals(FilterType.TAG, v2.getSubscriptionsMap().get(TOPIC).getType());
+        assertEquals("tagA", v2.getSubscriptionsMap().get(TOPIC).getExpression());
+        assertEquals(FilterType.SQL, v2.getSubscriptionsMap().get("topicB").getType());
+        assertEquals("a > 1", v2.getSubscriptionsMap().get("topicB").getExpression());
+        // 3. message queue table
+        assertEquals(1, v2.getMessageQueueTableCount());
+        MessageQueueItem item = v2.getMessageQueueTable(0);
+        assertEquals(TOPIC, item.getMessageQueue().getTopic().getName());
+        assertEquals(BROKER_A, item.getMessageQueue().getBroker().getName());
+        assertEquals(0, item.getMessageQueue().getId());
+        apache.rocketmq.v2.ProcessQueueInfo pqi = item.getProcessQueueInfo();
+        assertEquals(10L, pqi.getCommitOffset());
+        assertEquals(5L, pqi.getCachedMsgMinOffset());
+        assertEquals(15L, pqi.getCachedMsgMaxOffset());
+        assertEquals(7, pqi.getCachedMsgCount());
+        assertEquals(2, pqi.getCachedMsgSizeInMib());
+        assertEquals(1L, pqi.getTransactionMsgMinOffset());
+        assertEquals(3L, pqi.getTransactionMsgMaxOffset());
+        assertEquals(4, pqi.getTransactionMsgCount());
+        assertTrue(pqi.getLocked());
+        assertEquals(9L, pqi.getTryUnlockTimes());
+        assertEquals(111L, pqi.getLastLockTimestamp());
+        assertFalse(pqi.getDropped());
+        assertEquals(222L, pqi.getLastPullTimestamp());
+        assertEquals(333L, pqi.getLastConsumeTimestamp());
+        // 4. consume status table
+        assertEquals(1, v2.getConsumeStatusTableCount());
+        apache.rocketmq.v2.ConsumeStatus status = v2.getConsumeStatusTableMap().get(TOPIC);
+        assertNotNull(status);
+        assertEquals(1.5D, status.getReceiveRt(), 0.0001D);
+        assertEquals(2.5D, status.getReceiveTps(), 0.0001D);
+        assertEquals(3.5D, status.getConsumeRt(), 0.0001D);
+        assertEquals(4.5D, status.getConsumeOkTps(), 0.0001D);
+        assertEquals(5.5D, status.getConsumeFailedTps(), 0.0001D);
+        assertEquals(6L, status.getConsumeFailedMsgs());
+    }
+
+    @Test
+    public void toConsumerRunningInfoNullReturnsEmptyTest() {
+        apache.rocketmq.v2.ConsumerRunningInfo v2 = AdminModelConverter.toConsumerRunningInfo(null);
+
+        assertEquals(0, v2.getPropertiesCount());
+        assertEquals(0, v2.getSubscriptionsCount());
+        assertEquals(0, v2.getMessageQueueTableCount());
+        assertEquals(0, v2.getConsumeStatusTableCount());
+    }
+
+    // ------------------------------------------------------------------ misc
+
+    @Test
+    public void toAccumulationSkipsNullStatsEntriesTest() {
+        Map<String, ConsumeStats> byBroker = new HashMap<>();
+        byBroker.put(ADDR_A, null);
+        ConsumeStats withNulls = new ConsumeStats();
+        // the default offsetTable is a ConcurrentHashMap which forbids null values, so install a
+        // plain map to exercise the converter's null guard (deserialized stats can contain one)
+        Map<MessageQueue, OffsetWrapper> offsetTable = new HashMap<>();
+        offsetTable.put(new MessageQueue(TOPIC, BROKER_A, 0), null);
+        offsetTable.put(new MessageQueue(TOPIC, BROKER_A, 1), offsetWrapper(10L, 4L, 4L, 0L));
+        withNulls.setOffsetTable(offsetTable);
+        byBroker.put(ADDR_B, withNulls);
+
+        AdminModelConverter.AccumulationResult result = AdminModelConverter.toAccumulation(byBroker, GROUP);
+
+        assertEquals(6L, result.total.getAccumulation());
+        assertEquals(6L, result.byTopic.get(TOPIC).getAccumulation());
+    }
+}
diff --git a/proxy/src/test/java/org/apache/rocketmq/proxy/grpc/admin/ProxyAdminAuthInterceptorTest.java b/proxy/src/test/java/org/apache/rocketmq/proxy/grpc/admin/ProxyAdminAuthInterceptorTest.java
new file mode 100644
index 0000000000..c44038a727
--- /dev/null
+++ b/proxy/src/test/java/org/apache/rocketmq/proxy/grpc/admin/ProxyAdminAuthInterceptorTest.java
@@ -0,0 +1,269 @@
+/*
+ * 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.rocketmq.proxy.grpc.admin;
+
+import io.grpc.Metadata;
+import io.grpc.MethodDescriptor;
+import io.grpc.ServerCall;
+import io.grpc.ServerCallHandler;
+import io.grpc.Status;
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Set;
+import org.apache.rocketmq.auth.config.AuthConfig;
+import org.apache.rocketmq.common.action.Action;
+import org.apache.rocketmq.proxy.config.ConfigurationManager;
+import org.apache.rocketmq.proxy.config.InitConfigTest;
+import org.apache.rocketmq.proxy.processor.MessagingProcessor;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.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;
+
+@RunWith(MockitoJUnitRunner.class)
+public class ProxyAdminAuthInterceptorTest extends InitConfigTest {
+
+    @Mock
+    private MessagingProcessor messagingProcessor;
+
+    private static final MethodDescriptor.Marshaller<byte[]> BYTE_MARSHALLER =
+        new MethodDescriptor.Marshaller<byte[]>() {
+            @Override
+            public InputStream stream(byte[] value) {
+                return new ByteArrayInputStream(value == null ? new byte[0] : value);
+            }
+
+            @Override
+            public byte[] parse(InputStream stream) {
+                return new byte[0];
+            }
+        };
+
+    private static MethodDescriptor<byte[], byte[]> method(String name) {
+        return MethodDescriptor.<byte[], byte[]>newBuilder()
+            .setType(MethodDescriptor.MethodType.UNARY)
+            .setFullMethodName("apache.rocketmq.v2.Admin/" + name)
+            .setRequestMarshaller(BYTE_MARSHALLER)
+            .setResponseMarshaller(BYTE_MARSHALLER)
+            .build();
+    }
+
+    @SuppressWarnings("unchecked")
+    private static ServerCall<byte[], byte[]> serverCall(String methodName) {
+        ServerCall<byte[], byte[]> call = mock(ServerCall.class);
+        when(call.getMethodDescriptor()).thenReturn(method(methodName));
+        return call;
+    }
+
+    // ---------------------------------------------------------------------
+    // per-method resource / action mapping (read-only vs high-privilege isolation)
+    // ---------------------------------------------------------------------
+
+    @Test
+    public void everyAdminMethodMapsToExpectedResourceAndAction() {
+        // the full per-method ACL table: resource module + action. Read-only RPCs use GET/LIST,
+        // high-privilege mutations use UPDATE/DELETE/PUB, and each is scoped to its own resource.
+        Map<String, ProxyAdminAuthInterceptor.ResourceAction> expected = new LinkedHashMap<>();
+        expected.put("GetProxyRuntimeStats", ra(ProxyAdminAuthInterceptor.RESOURCE_OPS, Action.GET));
+        expected.put("GetTopicRoute", ra(ProxyAdminAuthInterceptor.RESOURCE_ROUTE, Action.GET));
+        expected.put("DescribeTopicStatus", ra(ProxyAdminAuthInterceptor.RESOURCE_OPS, Action.GET));
+        expected.put("ListSubscription", ra(ProxyAdminAuthInterceptor.RESOURCE_CLIENT, Action.LIST));
+        expected.put("DescribeSubscription", ra(ProxyAdminAuthInterceptor.RESOURCE_CLIENT, Action.GET));
+        expected.put("ListConsumerConnection", ra(ProxyAdminAuthInterceptor.RESOURCE_CLIENT, Action.LIST));
+        expected.put("DescribeGroupAccumulation", ra(ProxyAdminAuthInterceptor.RESOURCE_CLIENT, Action.GET));
+        expected.put("GetConsumerRunningInfo", ra(ProxyAdminAuthInterceptor.RESOURCE_CLIENT, Action.GET));
+        expected.put("QueryTimeSpan", ra(ProxyAdminAuthInterceptor.RESOURCE_CLIENT, Action.GET));
+        expected.put("QueryMessage", ra(ProxyAdminAuthInterceptor.RESOURCE_OPS, Action.GET));
+        expected.put("ChangeLogLevel", ra(ProxyAdminAuthInterceptor.RESOURCE_CONFIG, Action.UPDATE));
+        expected.put("DeleteSubscription", ra(ProxyAdminAuthInterceptor.RESOURCE_OPS, Action.DELETE));
+        expected.put("ResetGroupOffset", ra(ProxyAdminAuthInterceptor.RESOURCE_OPS, Action.UPDATE));
+        expected.put("AdminSendMessage", ra(ProxyAdminAuthInterceptor.RESOURCE_OPS, Action.PUB));
+        expected.put("PrintThreadStackTrace", ra(ProxyAdminAuthInterceptor.RESOURCE_CONNECTION, Action.UPDATE));
+        expected.put("VerifyMessage", ra(ProxyAdminAuthInterceptor.RESOURCE_CONNECTION, Action.UPDATE));
+
+        Set<String> resources = new HashSet<>();
+        for (Map.Entry<String, ProxyAdminAuthInterceptor.ResourceAction> e : expected.entrySet()) {
+            ProxyAdminAuthInterceptor.ResourceAction actual =
+                ProxyAdminAuthInterceptor.resolveResourceAction(e.getKey());
+            assertNotNull("missing mapping for " + e.getKey(), actual);
+            assertEquals("resource for " + e.getKey(), e.getValue().resource, actual.resource);
+            assertEquals("action for " + e.getKey(), e.getValue().action, actual.action);
+            resources.add(actual.resource);
+        }
+        // five distinct modules, all under the proxy.admin.* namespace
+        assertEquals(5, resources.size());
+        for (String resource : resources) {
+            assertTrue(resource.startsWith("proxy.admin."));
+        }
+    }
+
+    private static ProxyAdminAuthInterceptor.ResourceAction ra(String resource, Action action) {
+        return new ProxyAdminAuthInterceptor.ResourceAction(resource, action);
+    }
+
+    // ---------------------------------------------------------------------
+    // behavior modes
+    // ---------------------------------------------------------------------
+
+    @Test
+    @SuppressWarnings("unchecked")
+    public void openModePassesThroughWhenClusterAuthDisabled() {
+        AuthConfig authConfig = new AuthConfig();
+        authConfig.setAuthenticationEnabled(false);
+        authConfig.setAuthorizationEnabled(false);
+        ConfigurationManager.getProxyConfig().setGrpcAdminServerAuthEnable(false);
+
+        ProxyAdminAuthInterceptor interceptor = new ProxyAdminAuthInterceptor(authConfig, messagingProcessor);
+        ServerCall<byte[], byte[]> call = serverCall("ListConsumerConnection");
+        ServerCallHandler<byte[], byte[]> next = mock(ServerCallHandler.class);
+
+        interceptor.interceptCall(call, new Metadata(), next);
+        verify(next).startCall(any(), any());
+        verify(call, never()).close(any(), any());
+    }
+
+    @Test
+    @SuppressWarnings("unchecked")
+    public void failClosedRejectsWhenRequireAuthButClusterAuthDisabled() {
+        AuthConfig authConfig = new AuthConfig();
+        authConfig.setAuthenticationEnabled(false);
+        authConfig.setAuthorizationEnabled(false);
+        ConfigurationManager.getProxyConfig().setGrpcAdminServerAuthEnable(true);
+        try {
+            ProxyAdminAuthInterceptor interceptor = new ProxyAdminAuthInterceptor(authConfig, messagingProcessor);
+            ServerCall<byte[], byte[]> call = serverCall("ListClients");
+            ServerCallHandler<byte[], byte[]> next = mock(ServerCallHandler.class);
+
+            interceptor.interceptCall(call, new Metadata(), next);
+            verify(next, never()).startCall(any(), any());
+            org.mockito.ArgumentCaptor<Status> statusCaptor = org.mockito.ArgumentCaptor.forClass(Status.class);
+            verify(call).close(statusCaptor.capture(), any(Metadata.class));
+            assertEquals(Status.Code.UNAUTHENTICATED, statusCaptor.getValue().getCode());
+            assertTrue(statusCaptor.getValue().getDescription().contains("grpcAdminServerAuthEnable"));
+        } finally {
+            ConfigurationManager.getProxyConfig().setGrpcAdminServerAuthEnable(false);
+        }
+    }
+
+    @Test
+    @SuppressWarnings("unchecked")
+    public void failClosedRejectsWhenRequireAuthButAuthorizationDisabled() {
+        // authentication on but authorization off: the ACL evaluator is globally gated off, so
+        // enforcement would silently pass. Fail-closed must refuse instead of serving an
+        // unauthorized (potentially destructive) admin RPC to any authenticated identity.
+        AuthConfig authConfig = new AuthConfig();
+        authConfig.setAuthenticationEnabled(true);
+        authConfig.setAuthorizationEnabled(false);
+        ConfigurationManager.getProxyConfig().setGrpcAdminServerAuthEnable(true);
+        try {
+            ProxyAdminAuthInterceptor interceptor = new ProxyAdminAuthInterceptor(authConfig, messagingProcessor);
+            ServerCall<byte[], byte[]> call = serverCall("DeleteSubscription");
+            ServerCallHandler<byte[], byte[]> next = mock(ServerCallHandler.class);
+
+            interceptor.interceptCall(call, new Metadata(), next);
+            verify(next, never()).startCall(any(), any());
+            org.mockito.ArgumentCaptor<Status> statusCaptor = org.mockito.ArgumentCaptor.forClass(Status.class);
+            verify(call).close(statusCaptor.capture(), any(Metadata.class));
+            assertEquals(Status.Code.FAILED_PRECONDITION, statusCaptor.getValue().getCode());
+            assertTrue(statusCaptor.getValue().getDescription().contains("authorizationEnabled"));
+        } finally {
+            ConfigurationManager.getProxyConfig().setGrpcAdminServerAuthEnable(false);
+        }
+    }
+
+    @Test
+    @SuppressWarnings("unchecked")
+    public void failClosedRejectsAnonymousWhenRequireAuth() {
+        AuthConfig authConfig = new AuthConfig();
+        authConfig.setAuthenticationEnabled(true);
+        authConfig.setAuthorizationEnabled(true);
+        ConfigurationManager.getProxyConfig().setGrpcAdminServerAuthEnable(true);
+        try {
+            ProxyAdminAuthInterceptor interceptor = new ProxyAdminAuthInterceptor(authConfig, messagingProcessor);
+            ServerCall<byte[], byte[]> call = serverCall("PrintThreadStackTrace");
+            ServerCallHandler<byte[], byte[]> next = mock(ServerCallHandler.class);
+
+            // empty metadata: no credentials at all
+            ServerCall.Listener<byte[]> listener = interceptor.interceptCall(call, new Metadata(), next);
+            assertNotNull(listener);
+            verify(next, never()).startCall(any(), any());
+            org.mockito.ArgumentCaptor<Status> statusCaptor = org.mockito.ArgumentCaptor.forClass(Status.class);
+            verify(call).close(statusCaptor.capture(), any(Metadata.class));
+            assertEquals(Status.Code.UNAUTHENTICATED, statusCaptor.getValue().getCode());
+        } finally {
+            ConfigurationManager.getProxyConfig().setGrpcAdminServerAuthEnable(false);
+        }
+    }
+
+    @Test
+    @SuppressWarnings("unchecked")
+    public void authenticationFailureClosesUnauthenticated() {
+        // cluster authentication on + no credentials -> evaluator throws AuthenticationException.
+        // AuthenticationFactory caches evaluators per configName, so use a unique name to get
+        // a fresh evaluator bound to THIS config (otherwise a stale one from another test wins).
+        AuthConfig authConfig = new AuthConfig();
+        authConfig.setConfigName("proxy-admin-auth-failure-test-" + System.nanoTime());
+        authConfig.setAuthenticationEnabled(true);
+        authConfig.setAuthorizationEnabled(false);
+        ConfigurationManager.getProxyConfig().setGrpcAdminServerAuthEnable(false);
+
+        ProxyAdminAuthInterceptor interceptor = new ProxyAdminAuthInterceptor(authConfig, messagingProcessor);
+        ServerCall<byte[], byte[]> call = serverCall("ListConsumerConnection");
+        ServerCallHandler<byte[], byte[]> next = mock(ServerCallHandler.class);
+
+        interceptor.interceptCall(call, new Metadata(), next);
+        verify(next, never()).startCall(any(), any());
+        org.mockito.ArgumentCaptor<Status> statusCaptor = org.mockito.ArgumentCaptor.forClass(Status.class);
+        verify(call).close(statusCaptor.capture(), any(Metadata.class));
+        assertEquals(Status.Code.UNAUTHENTICATED, statusCaptor.getValue().getCode());
+        assertNotNull(statusCaptor.getValue().getDescription());
+    }
+
+    @Test
+    @SuppressWarnings("unchecked")
+    public void authorizationEnabledRejectsMissingCredentials() {
+        // authorization on without authentication -> mapped method demands credentials first
+        AuthConfig authConfig = new AuthConfig();
+        authConfig.setConfigName("proxy-admin-authz-only-test-" + System.nanoTime());
+        authConfig.setAuthenticationEnabled(false);
+        authConfig.setAuthorizationEnabled(true);
+        ConfigurationManager.getProxyConfig().setGrpcAdminServerAuthEnable(false);
+
+        ProxyAdminAuthInterceptor interceptor = new ProxyAdminAuthInterceptor(authConfig, messagingProcessor);
+        ServerCall<byte[], byte[]> call = serverCall("GetTopicRoute");
+        ServerCallHandler<byte[], byte[]> next = mock(ServerCallHandler.class);
+
+        interceptor.interceptCall(call, new Metadata(), next);
+        verify(next, never()).startCall(any(), any());
+        org.mockito.ArgumentCaptor<Status> statusCaptor = org.mockito.ArgumentCaptor.forClass(Status.class);
+        verify(call).close(statusCaptor.capture(), any(Metadata.class));
+        assertEquals(Status.Code.UNAUTHENTICATED, statusCaptor.getValue().getCode());
+        assertTrue(statusCaptor.getValue().getDescription().contains("missing credentials"));
+    }
+}
diff --git a/proxy/src/test/java/org/apache/rocketmq/proxy/grpc/admin/ProxyAdminGrpcServiceTest.java b/proxy/src/test/java/org/apache/rocketmq/proxy/grpc/admin/ProxyAdminGrpcServiceTest.java
new file mode 100644
index 0000000000..52362b2884
--- /dev/null
+++ b/proxy/src/test/java/org/apache/rocketmq/proxy/grpc/admin/ProxyAdminGrpcServiceTest.java
@@ -0,0 +1,1495 @@
+/*
+ * 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.rocketmq.proxy.grpc.admin;
+
+import apache.rocketmq.v2.AdminSendMessageRequest;
+import apache.rocketmq.v2.AdminSendMessageResponse;
+import apache.rocketmq.v2.ChangeLogLevelRequest;
+import apache.rocketmq.v2.ChangeLogLevelResponse;
+import apache.rocketmq.v2.ClientType;
+import apache.rocketmq.v2.Code;
+import apache.rocketmq.v2.DeleteSubscriptionRequest;
+import apache.rocketmq.v2.DeleteSubscriptionResponse;
+import apache.rocketmq.v2.DescribeGroupAccumulationRequest;
+import apache.rocketmq.v2.DescribeGroupAccumulationResponse;
+import apache.rocketmq.v2.DescribeSubscriptionRequest;
+import apache.rocketmq.v2.DescribeSubscriptionResponse;
+import apache.rocketmq.v2.DescribeTopicStatusRequest;
+import apache.rocketmq.v2.DescribeTopicStatusResponse;
+import apache.rocketmq.v2.FilterExpression;
+import apache.rocketmq.v2.FilterType;
+import apache.rocketmq.v2.GetConsumerRunningInfoRequest;
+import apache.rocketmq.v2.GetConsumerRunningInfoResponse;
+import apache.rocketmq.v2.GetProxyRuntimeStatsRequest;
+import apache.rocketmq.v2.GetProxyRuntimeStatsResponse;
+import apache.rocketmq.v2.GetTopicRouteRequest;
+import apache.rocketmq.v2.GetTopicRouteResponse;
+import apache.rocketmq.v2.ListConsumerConnectionRequest;
+import apache.rocketmq.v2.ListConsumerConnectionResponse;
+import apache.rocketmq.v2.ListMessageRequest;
+import apache.rocketmq.v2.ListMessageResponse;
+import apache.rocketmq.v2.ListSubscriptionRequest;
+import apache.rocketmq.v2.ListSubscriptionResponse;
+import apache.rocketmq.v2.MessageType;
+import apache.rocketmq.v2.PrintThreadStackTraceRequest;
+import apache.rocketmq.v2.PrintThreadStackTraceResponse;
+import apache.rocketmq.v2.QueryTimeSpanRequest;
+import apache.rocketmq.v2.QueryTimeSpanResponse;
+import apache.rocketmq.v2.ResetGroupOffsetRequest;
+import apache.rocketmq.v2.ResetGroupOffsetResponse;
+import apache.rocketmq.v2.Resource;
+import apache.rocketmq.v2.Settings;
+import apache.rocketmq.v2.Subscription;
+import apache.rocketmq.v2.SubscriptionEntry;
+import apache.rocketmq.v2.SubscriptionInfo;
+import apache.rocketmq.v2.SystemProperties;
+import apache.rocketmq.v2.VerifyMessageRequest;
+import apache.rocketmq.v2.VerifyMessageResponse;
+import com.alibaba.fastjson2.JSON;
+import com.google.protobuf.ByteString;
+import com.google.protobuf.Timestamp;
+import io.grpc.stub.StreamObserver;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Properties;
+import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+import org.apache.rocketmq.broker.client.ClientChannelInfo;
+import org.apache.rocketmq.broker.client.ConsumerManager;
+import org.apache.rocketmq.client.producer.SendResult;
+import org.apache.rocketmq.common.KeyBuilder;
+import org.apache.rocketmq.common.MQVersion;
+import org.apache.rocketmq.common.MixAll;
+import org.apache.rocketmq.common.TopicConfig;
+import org.apache.rocketmq.common.attribute.TopicMessageType;
+import org.apache.rocketmq.common.filter.ExpressionType;
+import org.apache.rocketmq.common.message.Message;
+import org.apache.rocketmq.common.message.MessageExt;
+import org.apache.rocketmq.common.message.MessageQueue;
+import org.apache.rocketmq.proxy.config.ConfigurationManager;
+import org.apache.rocketmq.proxy.config.InitConfigTest;
+import org.apache.rocketmq.proxy.grpc.v2.channel.GrpcChannelManager;
+import org.apache.rocketmq.proxy.grpc.v2.channel.GrpcClientChannel;
+import org.apache.rocketmq.proxy.grpc.v2.common.GrpcClientSettingsManager;
+import org.apache.rocketmq.proxy.processor.MessagingProcessor;
+import org.apache.rocketmq.proxy.service.ServiceManager;
+import org.apache.rocketmq.proxy.service.admin.AdminService;
+import org.apache.rocketmq.proxy.service.relay.ProxyRelayRequest;
+import org.apache.rocketmq.proxy.service.relay.ProxyRelayResult;
+import org.apache.rocketmq.proxy.service.route.AddressableMessageQueue;
+import org.apache.rocketmq.proxy.service.route.MessageQueueSelector;
+import org.apache.rocketmq.proxy.service.route.MessageQueueView;
+import org.apache.rocketmq.proxy.service.route.TopicRouteService;
+import org.apache.rocketmq.remoting.protocol.LanguageCode;
+import org.apache.rocketmq.remoting.protocol.RequestCode;
+import org.apache.rocketmq.remoting.protocol.ResponseCode;
+import org.apache.rocketmq.remoting.protocol.admin.ConsumeStats;
+import org.apache.rocketmq.remoting.protocol.admin.OffsetWrapper;
+import org.apache.rocketmq.remoting.protocol.body.CMResult;
+import org.apache.rocketmq.remoting.protocol.body.ClusterInfo;
+import org.apache.rocketmq.remoting.protocol.body.ConsumeMessageDirectlyResult;
+import org.apache.rocketmq.remoting.protocol.body.Connection;
+import org.apache.rocketmq.remoting.protocol.body.ConsumerConnection;
+import org.apache.rocketmq.remoting.protocol.body.ConsumerRunningInfo;
+import org.apache.rocketmq.remoting.protocol.body.GroupList;
+import org.apache.rocketmq.remoting.protocol.body.QueueTimeSpan;
+import org.apache.rocketmq.remoting.protocol.body.TopicList;
+import org.apache.rocketmq.remoting.protocol.header.ConsumeMessageDirectlyResultRequestHeader;
+import org.apache.rocketmq.remoting.protocol.header.GetConsumerRunningInfoRequestHeader;
+import org.apache.rocketmq.remoting.protocol.heartbeat.ConsumeType;
+import org.apache.rocketmq.remoting.protocol.heartbeat.MessageModel;
+import org.apache.rocketmq.remoting.protocol.heartbeat.SubscriptionData;
+import org.apache.rocketmq.remoting.protocol.route.BrokerData;
+import org.apache.rocketmq.remoting.protocol.route.TopicRouteData;
+import org.apache.rocketmq.remoting.protocol.subscription.SimpleSubscriptionData;
+import org.apache.rocketmq.remoting.protocol.subscription.SubscriptionGroupConfig;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@RunWith(MockitoJUnitRunner.class)
+public class ProxyAdminGrpcServiceTest extends InitConfigTest {
+
+    @Mock
+    private ServiceManager serviceManager;
+    @Mock
+    private MessagingProcessor messagingProcessor;
+    @Mock
+    private GrpcChannelManager grpcChannelManager;
+    @Mock
+    private GrpcClientSettingsManager grpcClientSettingsManager;
+    @Mock
+    private ProxyAdminForwarder forwarder;
+    @Mock
+    private AdminService adminService;
+    @Mock
+    private TopicRouteService topicRouteService;
+    @Mock
+    private ConsumerManager consumerManager;
+    @Mock
+    private GrpcClientChannel channel;
+
+    private ProxyAdminGrpcService service;
+
+    private static final String TOPIC = "topicA";
+    private static final String GROUP = "groupA";
+    private static final String CLIENT_ID = "client-1";
+    private static final String ADDRESS_A = "127.0.0.1:10911";
+    private static final String ADDRESS_B = "127.0.0.2:10911";
+
+    @Before
+    public void setUp() {
+        when(serviceManager.getAdminService()).thenReturn(adminService);
+        when(serviceManager.getTopicRouteService()).thenReturn(topicRouteService);
+        service = new ProxyAdminGrpcService(serviceManager, messagingProcessor, grpcChannelManager,
+            grpcClientSettingsManager, forwarder);
+    }
+
+    @After
+    public void tearDown() {
+        service.shutdown();
+    }
+
+    // ------------------------------------------------------------------ helpers
+
+    private static class SimpleObserver<T> implements StreamObserver<T> {
+        T value;
+        Throwable error;
+        boolean completed;
+
+        @Override
+        public void onNext(T value) {
+            this.value = value;
+        }
+
+        @Override
+        public void onError(Throwable t) {
+            this.error = t;
+        }
+
+        @Override
+        public void onCompleted() {
+            this.completed = true;
+        }
+    }
+
+    private static <T> CompletableFuture<T> failedFuture(Throwable t) {
+        CompletableFuture<T> future = new CompletableFuture<>();
+        future.completeExceptionally(t);
+        return future;
+    }
+
+    private void stubClusterInfo(String... addrs) {
+        Map<String, BrokerData> table = new HashMap<>();
+        for (int i = 0; i < addrs.length; i++) {
+            HashMap<Long, String> brokerAddrs = new HashMap<>();
+            brokerAddrs.put(MixAll.MASTER_ID, addrs[i]);
+            table.put("broker-" + i, new BrokerData("DefaultCluster", "broker-" + i, brokerAddrs));
+        }
+        ClusterInfo clusterInfo = new ClusterInfo();
+        clusterInfo.setBrokerAddrTable(table);
+        when(adminService.getBrokerClusterInfo(anyLong()))
+            .thenReturn(CompletableFuture.completedFuture(clusterInfo));
+    }
+
+    private void stubRoute(boolean forWrite, String... brokerAddrs) throws Exception {
+        List<AddressableMessageQueue> queues = new ArrayList<>();
+        for (int i = 0; i < brokerAddrs.length; i++) {
+            queues.add(new AddressableMessageQueue(new MessageQueue(TOPIC, "broker-" + i, i), brokerAddrs[i]));
+        }
+        MessageQueueSelector selector = mock(MessageQueueSelector.class);
+        when(selector.getQueues()).thenReturn(queues);
+        MessageQueueView view = mock(MessageQueueView.class);
+        if (forWrite) {
+            when(view.getWriteSelector()).thenReturn(selector);
+        } else {
+            when(view.getReadSelector()).thenReturn(selector);
+        }
+        when(topicRouteService.getAllMessageQueueView(any(), anyString())).thenReturn(view);
+    }
+
+    private SubscriptionData subscriptionData(String topic, String expressionType, String expression) {
+        SubscriptionData data = new SubscriptionData();
+        data.setTopic(topic);
+        data.setExpressionType(expressionType);
+        data.setSubString(expression);
+        return data;
+    }
+
+    private ConsumerConnection consumerConnection(String clientId, String clientAddr, String... topics) {
+        ConsumerConnection connection = new ConsumerConnection();
+        Connection conn = new Connection();
+        conn.setClientId(clientId);
+        conn.setClientAddr(clientAddr);
+        conn.setLanguage(LanguageCode.JAVA);
+        conn.setVersion(355);
+        connection.getConnectionSet().add(conn);
+        for (String topic : topics) {
+            connection.getSubscriptionTable().put(topic, subscriptionData(topic, ExpressionType.TAG, "tagA"));
+        }
+        connection.setMessageModel(MessageModel.CLUSTERING);
+        connection.setConsumeType(ConsumeType.CONSUME_PASSIVELY);
+        return connection;
+    }
+
+    private Settings settings(ClientType clientType, String group, String topic, String expression) {
+        return Settings.newBuilder()
+            .setClientType(clientType)
+            .setSubscription(Subscription.newBuilder()
+                .setGroup(Resource.newBuilder().setName(group).build())
+                .addSubscriptions(SubscriptionEntry.newBuilder()
+                    .setTopic(Resource.newBuilder().setName(topic).build())
+                    .setExpression(FilterExpression.newBuilder()
+                        .setType(FilterType.TAG).setExpression(expression).build())
+                    .build())
+                .build())
+            .build();
+    }
+
+    private MessageExt messageExt(String msgId) {
+        MessageExt ext = new MessageExt();
+        ext.setTopic(TOPIC);
+        ext.setMsgId(msgId);
+        ext.setBody("payload".getBytes(StandardCharsets.UTF_8));
+        ext.setQueueId(0);
+        ext.setQueueOffset(100L);
+        ext.setBrokerName("broker-0");
+        long now = System.currentTimeMillis();
+        ext.setBornTimestamp(now);
+        ext.setStoreTimestamp(now);
+        ext.setBornHost(new InetSocketAddress("127.0.0.1", 10909));
+        ext.setStoreHost(new InetSocketAddress("127.0.0.1", 10911));
+        return ext;
+    }
+
+    private ConsumeStats consumeStats(String topic, long brokerOffset, long consumerOffset, long pullOffset) {
+        ConsumeStats stats = new ConsumeStats();
+        OffsetWrapper wrapper = new OffsetWrapper();
+        wrapper.setBrokerOffset(brokerOffset);
+        wrapper.setConsumerOffset(consumerOffset);
+        wrapper.setPullOffset(pullOffset);
+        stats.getOffsetTable().put(new MessageQueue(topic, "broker-0", 0), wrapper);
+        return stats;
+    }
+
+    private TopicConfig topicConfig(TopicMessageType type, int readQueueNums, int writeQueueNums) {
+        TopicConfig config = new TopicConfig();
+        config.setTopicName(TOPIC);
+        config.setReadQueueNums(readQueueNums);
+        config.setWriteQueueNums(writeQueueNums);
+        config.setPerm(6);
+        if (type != null) {
+            config.setTopicMessageType(type);
+        }
+        return config;
+    }
+
+    /** Registers the client as a local channel reachable through the consumer manager. */
+    private void stubLocalClient() {
+        when(serviceManager.getConsumerManager()).thenReturn(consumerManager);
+        when(consumerManager.findChannel(GROUP, CLIENT_ID))
+            .thenReturn(new ClientChannelInfo(channel, CLIENT_ID, LanguageCode.JAVA, 0));
+        when(channel.isActive()).thenReturn(true);
+    }
+
+    /**
+     * Simulates the relay round trip: when the service writes a {@link ProxyRelayRequest} into the
+     * client channel, the captured caller future is completed with the given result, exactly like
+     * {@code ClusterProxyRelayService} + the channel implementation would do in production.
+     */
+    private <T> void answerRelayWith(int expectedCode, ProxyRelayResult<T> result) {
+        when(channel.writeAndFlush(any())).thenAnswer(invocation -> {
+            ProxyRelayRequest request = invocation.getArgument(0);
+            assertEquals(expectedCode, request.getCode());
+            CompletableFuture<ProxyRelayResult<T>> future = request.typedResponseFuture();
+            future.complete(result);
+            return null;
+        });
+    }
+
+    private static Resource resource(String name) {
+        return Resource.newBuilder().setName(name).build();
+    }
+
+    // ------------------------------------------------------------------ 1. ChangeLogLevel
+
+    @Test
+    public void changeLogLevelChangesRootLevelTest() {
+        SimpleObserver<ChangeLogLevelResponse> observer = new SimpleObserver<>();
+        service.changeLogLevel(ChangeLogLevelRequest.newBuilder()
+            .setLevel(ChangeLogLevelRequest.Level.DEBUG).build(), observer);
+
+        assertNotNull(observer.value);
+        assertTrue(observer.value.getRemark(), observer.value.getRemark().contains("log level changed"));
+        assertTrue(observer.completed);
+    }
+
+    // ------------------------------------------------------------------ 2. DescribeTopicStatus
+
+    @Test
+    public void describeTopicStatusReadsMessageTypeFromAttributesTest() throws Exception {
+        stubRoute(false, ADDRESS_A);
+        when(adminService.getTopicConfig(anyString(), eq(TOPIC), anyLong()))
+            .thenReturn(CompletableFuture.completedFuture(topicConfig(TopicMessageType.FIFO, 4, 4)));
+
+        SimpleObserver<DescribeTopicStatusResponse> observer = new SimpleObserver<>();
+        service.describeTopicStatus(DescribeTopicStatusRequest.newBuilder()
+            .setTopic(resource(TOPIC)).build(), observer);
+
+        assertNotNull(observer.value);
+        assertEquals(Code.OK, observer.value.getStatus().getCode());
+        assertEquals(MessageType.FIFO, observer.value.getTopicMessageType());
+        assertTrue(observer.value.getDescription().contains("readQueueNums=4"));
+    }
+
+    @Test
+    public void describeTopicStatusMergesQueueNumsAcrossBrokersTest() throws Exception {
+        stubRoute(false, ADDRESS_A, ADDRESS_B);
+        when(adminService.getTopicConfig(anyString(), eq(TOPIC), anyLong()))
+            .thenReturn(CompletableFuture.completedFuture(topicConfig(TopicMessageType.FIFO, 4, 4)));
+
+        SimpleObserver<DescribeTopicStatusResponse> observer = new SimpleObserver<>();
+        service.describeTopicStatus(DescribeTopicStatusRequest.newBuilder()
+            .setTopic(resource(TOPIC)).build(), observer);
+
+        assertNotNull(observer.value);
+        assertEquals(Code.OK, observer.value.getStatus().getCode());
+        assertEquals(MessageType.FIFO, observer.value.getTopicMessageType());
+        assertTrue(observer.value.getDescription().contains("readQueueNums=8"));
+        assertTrue(observer.value.getDescription().contains("writeQueueNums=8"));
+    }
+
+    @Test
+    public void describeTopicStatusReportsMixedWhenBrokersDisagreeTest() throws Exception {
+        stubRoute(false, ADDRESS_A, ADDRESS_B);
+        when(adminService.getTopicConfig(eq(ADDRESS_A), eq(TOPIC), anyLong()))
+            .thenReturn(CompletableFuture.completedFuture(topicConfig(TopicMessageType.FIFO, 4, 4)));
+        when(adminService.getTopicConfig(eq(ADDRESS_B), eq(TOPIC), anyLong()))
+            .thenReturn(CompletableFuture.completedFuture(topicConfig(TopicMessageType.NORMAL, 4, 4)));
+
+        SimpleObserver<DescribeTopicStatusResponse> observer = new SimpleObserver<>();
+        service.describeTopicStatus(DescribeTopicStatusRequest.newBuilder()
+            .setTopic(resource(TOPIC)).build(), observer);
+
+        assertNotNull(observer.value);
+        assertEquals(Code.OK, observer.value.getStatus().getCode());
+        // MIXED degrades to UNSPECIFIED rather than pretending one broker is right
+        assertEquals(MessageType.MESSAGE_TYPE_UNSPECIFIED, observer.value.getTopicMessageType());
+    }
+
+    @Test
+    public void describeTopicStatusFailsWhenRouteMissingTest() {
+        SimpleObserver<DescribeTopicStatusResponse> observer = new SimpleObserver<>();
+        service.describeTopicStatus(DescribeTopicStatusRequest.newBuilder()
+            .setTopic(resource("missingTopic")).build(), observer);
+
+        assertNotNull(observer.value);
+        assertEquals(Code.INTERNAL_SERVER_ERROR, observer.value.getStatus().getCode());
+        assertTrue(observer.value.getStatus().getMessage().contains("topic route not found"));
+    }
+
+    // ------------------------------------------------------------------ 3/4. List & DescribeSubscription
+
+    @Test
+    public void listSubscriptionRejectsEmptyFiltersTest() {
+        SimpleObserver<ListSubscriptionResponse> observer = new SimpleObserver<>();
+        service.listSubscription(ListSubscriptionRequest.newBuilder().build(), observer);
+
+        assertNotNull(observer.value);
+        assertEquals(Code.BAD_REQUEST, observer.value.getStatus().getCode());
+        assertTrue(observer.value.getStatus().getMessage().contains("at least one of topic or group"));
+    }
+
+    @Test
+    public void describeSubscriptionRejectsEmptyFiltersTest() {
+        SimpleObserver<DescribeSubscriptionResponse> observer = new SimpleObserver<>();
+        service.describeSubscription(DescribeSubscriptionRequest.newBuilder().build(), observer);
+
+        assertNotNull(observer.value);
+        assertEquals(Code.BAD_REQUEST, observer.value.getStatus().getCode());
+        assertTrue(observer.value.getStatus().getMessage().contains("at least one of topic or group"));
+    }
+
+    @Test
+    public void listSubscriptionReturnsBrokerSideSubscriptionsTest() {
+        stubClusterInfo(ADDRESS_A);
+        ConsumerConnection connection = consumerConnection(CLIENT_ID, "192.168.1.5:43210", TOPIC);
+        // retry topics are bookkeeping, not user-visible subscriptions
+        connection.getSubscriptionTable().put(MixAll.getRetryTopic(GROUP),
+            subscriptionData(MixAll.getRetryTopic(GROUP), ExpressionType.TAG, "*"));
+        connection.getSubscriptionTable().put(KeyBuilder.buildPopRetryTopicV2(TOPIC, GROUP),
+            subscriptionData(KeyBuilder.buildPopRetryTopicV2(TOPIC, GROUP), ExpressionType.TAG, "*"));
+        when(adminService.getConsumerConnectionList(eq(ADDRESS_A), eq(GROUP), anyLong()))
+            .thenReturn(CompletableFuture.completedFuture(connection));
+
+        SimpleObserver<ListSubscriptionResponse> observer = new SimpleObserver<>();
+        service.listSubscription(ListSubscriptionRequest.newBuilder()
+            .setGroup(resource(GROUP)).build(), observer);
+
+        assertNotNull(observer.value);
+        assertEquals(Code.OK, observer.value.getStatus().getCode());
+        assertEquals(1, observer.value.getSubscriptionInfoCount());
+        SubscriptionInfo info = observer.value.getSubscriptionInfo(0);
+        assertEquals(GROUP, info.getGroup().getName());
+        assertEquals(TOPIC, info.getTopic().getName());
+        assertEquals(FilterType.TAG, info.getExpression().getType());
+        assertEquals("tagA", info.getExpression().getExpression());
+        assertTrue(info.getOnline());
+        assertEquals(apache.rocketmq.v2.MessageModel.CLUSTERING, info.getMessageModel());
+    }
+
+    @Test
+    public void listSubscriptionByTopicResolvesGroupsFromBrokersTest() {
+        stubClusterInfo(ADDRESS_A);
+        GroupList groupList = new GroupList();
+        groupList.setGroupList(new HashSet<>(Collections.singletonList(GROUP)));
+        when(adminService.queryTopicConsumeByWho(eq(ADDRESS_A), eq(TOPIC), anyLong()))
+            .thenReturn(CompletableFuture.completedFuture(groupList));
+        when(adminService.getConsumerConnectionList(eq(ADDRESS_A), eq(GROUP), anyLong()))
+            .thenReturn(CompletableFuture.completedFuture(consumerConnection(CLIENT_ID, "192.168.1.5:43210", TOPIC)));
+
+        SimpleObserver<ListSubscriptionResponse> observer = new SimpleObserver<>();
+        service.listSubscription(ListSubscriptionRequest.newBuilder()
+            .setTopic(resource(TOPIC)).build(), observer);
+
+        assertNotNull(observer.value);
+        assertEquals(Code.OK, observer.value.getStatus().getCode());
+        assertEquals(1, observer.value.getSubscriptionInfoCount());
+        assertEquals(GROUP, observer.value.getSubscriptionInfo(0).getGroup().getName());
+        verify(adminService).queryTopicConsumeByWho(eq(ADDRESS_A), eq(TOPIC), anyLong());
+    }
+
+    @Test
+    public void describeSubscriptionReportsOneEntryPerClientTest() {
+        stubClusterInfo(ADDRESS_A);
+        when(adminService.getConsumerConnectionList(eq(ADDRESS_A), eq(GROUP), anyLong()))
+            .thenReturn(CompletableFuture.completedFuture(consumerConnection(CLIENT_ID, "192.168.1.5:43210", TOPIC)));
+
+        SimpleObserver<DescribeSubscriptionResponse> observer = new SimpleObserver<>();
+        service.describeSubscription(DescribeSubscriptionRequest.newBuilder()
+            .setGroup(resource(GROUP)).build(), observer);
+
+        assertNotNull(observer.value);
+        assertEquals(Code.OK, observer.value.getStatus().getCode());
+        assertEquals(1, observer.value.getClientSubscriptionInfoCount());
+        assertEquals(CLIENT_ID, observer.value.getClientSubscriptionInfo(0).getClientInfo().getClientId());
+        SubscriptionInfo info = observer.value.getClientSubscriptionInfo(0).getSubscriptionInfo();
+        assertEquals(GROUP, info.getGroup().getName());
+        assertEquals(TOPIC, info.getTopic().getName());
+        assertEquals("tagA", info.getExpression().getExpression());
+        assertTrue(info.getOnline());
+    }
+
+    @Test
+    public void describeSubscriptionPrefersClientSettingsTest() {
+        stubClusterInfo(ADDRESS_A);
+        when(adminService.getConsumerConnectionList(eq(ADDRESS_A), eq(GROUP), anyLong()))
+            .thenReturn(CompletableFuture.completedFuture(consumerConnection(CLIENT_ID, "192.168.1.5:43210", TOPIC)));
+        when(grpcClientSettingsManager.getRawClientSettings(CLIENT_ID))
+            .thenReturn(settings(ClientType.SIMPLE_CONSUMER, GROUP, TOPIC, "tagFromSettings"));
+
+        SimpleObserver<DescribeSubscriptionResponse> observer = new SimpleObserver<>();
+        service.describeSubscription(DescribeSubscriptionRequest.newBuilder()
+            .setGroup(resource(GROUP)).build(), observer);
+
+        assertNotNull(observer.value);
+        assertEquals(Code.OK, observer.value.getStatus().getCode());
+        assertEquals(1, observer.value.getClientSubscriptionInfoCount());
+        assertEquals("tagFromSettings",
+            observer.value.getClientSubscriptionInfo(0).getSubscriptionInfo().getExpression().getExpression());
+    }
+
+    // ------------------------------------------------------------------ 5. DeleteSubscription
+
+    @Test
+    public void deleteSubscriptionRejectsMissingTopicOrGroupTest() {
+        SimpleObserver<DeleteSubscriptionResponse> observer = new SimpleObserver<>();
+        service.deleteSubscription(DeleteSubscriptionRequest.newBuilder()
+            .setGroup(resource(GROUP)).build(), observer);
+
+        assertNotNull(observer.value);
+        assertEquals(Code.BAD_REQUEST, observer.value.getStatus().getCode());
+    }
+
+    @Test
+    public void deleteSubscriptionRemovesOnlyMatchingEntryTest() throws Exception {
+        stubRoute(true, ADDRESS_A);
+        SubscriptionGroupConfig config = new SubscriptionGroupConfig();
+        config.setGroupName(GROUP);
+        Set<SimpleSubscriptionData> dataSet = new HashSet<>();
+        dataSet.add(new SimpleSubscriptionData(TOPIC, ExpressionType.TAG, "tagA", 0L));
+        dataSet.add(new SimpleSubscriptionData(TOPIC, ExpressionType.TAG, "tagB", 0L));
+        dataSet.add(new SimpleSubscriptionData("topicOther", ExpressionType.TAG, "*", 0L));
+        config.setSubscriptionDataSet(dataSet);
+        when(adminService.getSubscriptionGroupConfig(eq(ADDRESS_A), eq(GROUP), anyLong()))
+            .thenReturn(CompletableFuture.completedFuture(config));
+        when(adminService.updateSubscriptionGroupConfig(eq(ADDRESS_A), any(SubscriptionGroupConfig.class), anyLong()))
+            .thenReturn(CompletableFuture.completedFuture(null));
+
+        SimpleObserver<DeleteSubscriptionResponse> observer = new SimpleObserver<>();
+        service.deleteSubscription(DeleteSubscriptionRequest.newBuilder()
+            .setTopic(resource(TOPIC))
+            .setGroup(resource(GROUP))
+            .setExpression(FilterExpression.newBuilder().setType(FilterType.TAG).setExpression("tagA").build())
+            .build(), observer);
+
+        assertNotNull(observer.value);
+        assertEquals(Code.OK, observer.value.getStatus().getCode());
+
+        ArgumentCaptor<SubscriptionGroupConfig> captor = ArgumentCaptor.forClass(SubscriptionGroupConfig.class);
+        verify(adminService).updateSubscriptionGroupConfig(eq(ADDRESS_A), captor.capture(), anyLong());
+        Set<SimpleSubscriptionData> remaining = captor.getValue().getSubscriptionDataSet();
+        assertEquals(2, remaining.size());
+        assertTrue(remaining.contains(new SimpleSubscriptionData(TOPIC, ExpressionType.TAG, "tagB", 0L)));
+        assertTrue(remaining.contains(new SimpleSubscriptionData("topicOther", ExpressionType.TAG, "*", 0L)));
+        assertFalse(remaining.contains(new SimpleSubscriptionData(TOPIC, ExpressionType.TAG, "tagA", 0L)));
+        // deleting one subscription must never delete the whole group
+        verify(adminService, never()).deleteSubscriptionGroup(anyString(), anyString(), anyBoolean(), anyLong());
+    }
+
+    @Test
+    public void deleteSubscriptionReturnsNotFoundWhenNothingMatchedTest() throws Exception {
+        stubRoute(true, ADDRESS_A);
+        SubscriptionGroupConfig config = new SubscriptionGroupConfig();
+        config.setGroupName(GROUP);
+        config.setSubscriptionDataSet(new HashSet<>(Collections.singletonList(
+            new SimpleSubscriptionData("topicOther", ExpressionType.TAG, "*", 0L))));
+        when(adminService.getSubscriptionGroupConfig(eq(ADDRESS_A), eq(GROUP), anyLong()))
+            .thenReturn(CompletableFuture.completedFuture(config));
+
+        SimpleObserver<DeleteSubscriptionResponse> observer = new SimpleObserver<>();
+        service.deleteSubscription(DeleteSubscriptionRequest.newBuilder()
+            .setTopic(resource(TOPIC))
+            .setGroup(resource(GROUP))
+            .build(), observer);
+
+        assertNotNull(observer.value);
+        assertEquals(Code.NOT_FOUND, observer.value.getStatus().getCode());
+        assertTrue(observer.value.getStatus().getMessage().contains("no subscription of group"));
+        verify(adminService, never()).updateSubscriptionGroupConfig(anyString(),
+            any(SubscriptionGroupConfig.class), anyLong());
+    }
+
+    @Test
+    public void deleteSubscriptionReturnsNotFoundWhenGroupUnknownTest() throws Exception {
+        stubRoute(true, ADDRESS_A);
+        when(adminService.getSubscriptionGroupConfig(eq(ADDRESS_A), eq(GROUP), anyLong()))
+            .thenReturn(CompletableFuture.completedFuture(null));
+
+        SimpleObserver<DeleteSubscriptionResponse> observer = new SimpleObserver<>();
+        service.deleteSubscription(DeleteSubscriptionRequest.newBuilder()
+            .setTopic(resource(TOPIC))
+            .setGroup(resource(GROUP))
+            .build(), observer);
+
+        assertNotNull(observer.value);
+        assertEquals(Code.NOT_FOUND, observer.value.getStatus().getCode());
+    }
+
+    @Test
+    public void deleteSubscriptionPropagatesBrokerFailureTest() throws Exception {
+        stubRoute(true, ADDRESS_A);
+        when(adminService.getSubscriptionGroupConfig(eq(ADDRESS_A), eq(GROUP), anyLong()))
+            .thenReturn(failedFuture(new RuntimeException("broker down")));
+
+        SimpleObserver<DeleteSubscriptionResponse> observer = new SimpleObserver<>();
+        service.deleteSubscription(DeleteSubscriptionRequest.newBuilder()
+            .setTopic(resource(TOPIC))
+            .setGroup(resource(GROUP))
+            .build(), observer);
+
+        assertNotNull(observer.value);
+        assertEquals(Code.INTERNAL_SERVER_ERROR, observer.value.getStatus().getCode());
+        assertTrue(observer.value.getStatus().getMessage().contains("broker down"));
+    }
+
+    // ------------------------------------------------------------------ 6. DescribeGroupAccumulation
+
+    @Test
+    public void describeGroupAccumulationRejectsMissingGroupTest() {
+        SimpleObserver<DescribeGroupAccumulationResponse> observer = new SimpleObserver<>();
+        service.describeGroupAccumulation(DescribeGroupAccumulationRequest.newBuilder().build(), observer);
+
+        assertNotNull(observer.value);
+        assertEquals(Code.BAD_REQUEST, observer.value.getStatus().getCode());
+    }
+
+    @Test
+    public void describeGroupAccumulationSplitsInflightReadyTest() {
+        stubClusterInfo(ADDRESS_A);
+        // the service asks the broker for the whole group (blank topic) and filters locally
+        when(adminService.getConsumeStats(eq(ADDRESS_A), eq(GROUP), eq(""), anyLong()))
+            .thenReturn(CompletableFuture.completedFuture(consumeStats(TOPIC, 100L, 60L, 75L)));
+
+        SimpleObserver<DescribeGroupAccumulationResponse> observer = new SimpleObserver<>();
+        service.describeGroupAccumulation(DescribeGroupAccumulationRequest.newBuilder()
+            .setGroup(resource(GROUP))
+            .addTopics(resource(TOPIC))
+            .build(), observer);
+
+        assertNotNull(observer.value);
+        assertEquals(Code.OK, observer.value.getStatus().getCode());
+        assertEquals(40L, observer.value.getAccumulation().getAccumulation());
+        assertEquals(15L, observer.value.getAccumulation().getInflightMessages());
+        assertEquals(25L, observer.value.getAccumulation().getReadyMessages());
+        assertTrue(observer.value.getTopicAccumulationMap().containsKey(TOPIC));
+        assertEquals(40L, observer.value.getTopicAccumulationMap().get(TOPIC).getAccumulation());
+    }
+
+    @Test
+    public void describeGroupAccumulationWholeGroupResolvesTopicsTest() {
+        stubClusterInfo(ADDRESS_A);
+        TopicList topicList = new TopicList();
+        topicList.setTopicList(new HashSet<>(Collections.singletonList(TOPIC)));
+        when(adminService.queryTopicsByConsumer(eq(ADDRESS_A), eq(GROUP), anyLong()))
+            .thenReturn(CompletableFuture.completedFuture(topicList));
+        when(adminService.getConsumeStats(eq(ADDRESS_A), eq(GROUP), eq(""), anyLong()))
+            .thenReturn(CompletableFuture.completedFuture(consumeStats(TOPIC, 100L, 60L, 60L)));
+
+        SimpleObserver<DescribeGroupAccumulationResponse> observer = new SimpleObserver<>();
+        service.describeGroupAccumulation(DescribeGroupAccumulationRequest.newBuilder()
+            .setGroup(resource(GROUP))
+            .build(), observer);
+
+        assertNotNull(observer.value);
+        assertEquals(Code.OK, observer.value.getStatus().getCode());
+        assertEquals(40L, observer.value.getAccumulation().getAccumulation());
+        verify(adminService).queryTopicsByConsumer(eq(ADDRESS_A), eq(GROUP), anyLong());
+    }
+
+    // ------------------------------------------------------------------ 7. ListConsumerConnection
+
+    @Test
+    public void listConsumerConnectionRejectsMissingGroupTest() {
+        SimpleObserver<ListConsumerConnectionResponse> observer = new SimpleObserver<>();
+        service.listConsumerConnection(ListConsumerConnectionRequest.newBuilder().build(), observer);
+
+        assertNotNull(observer.value);
+        assertEquals(Code.BAD_REQUEST, observer.value.getStatus().getCode());
+    }
+
+    @Test
+    public void listConsumerConnectionReturnsBrokerSideClientsTest() {
+        stubClusterInfo(ADDRESS_A);
+        when(adminService.getConsumerConnectionList(eq(ADDRESS_A), eq(GROUP), anyLong()))
+            .thenReturn(CompletableFuture.completedFuture(
+                consumerConnection(CLIENT_ID, "consumer-host@192.168.1.5:43210", TOPIC)));
+
+        SimpleObserver<ListConsumerConnectionResponse> observer = new SimpleObserver<>();
+        service.listConsumerConnection(ListConsumerConnectionRequest.newBuilder()
+            .setGroup(resource(GROUP)).build(), observer);
+
+        assertNotNull(observer.value);
+        assertEquals(Code.OK, observer.value.getStatus().getCode());
+        assertEquals(1, observer.value.getClientInfoCount());
+        assertEquals(CLIENT_ID, observer.value.getClientInfo(0).getClientId());
+        // egress_ip is the bare IP, hostname the client-reported name from "hostname@ip:port"
+        assertEquals("192.168.1.5", observer.value.getClientInfo(0).getEgressIp());
+        assertEquals("consumer-host", observer.value.getClientInfo(0).getHostname());
+        assertEquals("355", observer.value.getClientInfo(0).getVersion());
+        assertEquals(apache.rocketmq.v2.MessageModel.CLUSTERING, observer.value.getClientInfo(0).getMessageModel());
+    }
+
+    @Test
+    public void listConsumerConnectionOfflineGroupIsOkTest() {
+        stubClusterInfo(ADDRESS_A);
+        when(adminService.getConsumerConnectionList(eq(ADDRESS_A), eq(GROUP), anyLong()))
+            .thenReturn(failedFuture(new RuntimeException("consumer group not online")));
+
+        SimpleObserver<ListConsumerConnectionResponse> observer = new SimpleObserver<>();
+        service.listConsumerConnection(ListConsumerConnectionRequest.newBuilder()
+            .setGroup(resource(GROUP)).build(), observer);
+
+        assertNotNull(observer.value);
+        // an offline group is a normal answer for a listing RPC, not a server error
+        assertEquals(Code.OK, observer.value.getStatus().getCode());
+        assertEquals(0, observer.value.getClientInfoCount());
+    }
+
+    @Test
+    public void listConsumerConnectionTopicFilterHidesNonSubscribersTest() {
+        stubClusterInfo(ADDRESS_A);
+        when(adminService.getConsumerConnectionList(eq(ADDRESS_A), eq(GROUP), anyLong()))
+            .thenReturn(CompletableFuture.completedFuture(consumerConnection(CLIENT_ID, "192.168.1.5:43210", TOPIC)));
+
+        SimpleObserver<ListConsumerConnectionResponse> observer = new SimpleObserver<>();
+        service.listConsumerConnection(ListConsumerConnectionRequest.newBuilder()
+            .setGroup(resource(GROUP))
+            .setTopic(resource("topicOther"))
+            .build(), observer);
+
+        assertNotNull(observer.value);
+        assertEquals(Code.OK, observer.value.getStatus().getCode());
+        assertEquals(0, observer.value.getClientInfoCount());
+    }
+
+    // ------------------------------------------------------------------ 8. ResetGroupOffset
+
+    @Test
+    public void resetGroupOffsetRejectsMissingTimestampTest() {
+        SimpleObserver<ResetGroupOffsetResponse> observer = new SimpleObserver<>();
+        service.resetGroupOffset(ResetGroupOffsetRequest.newBuilder()
+            .setGroup(resource(GROUP))
+            .setTopic(resource(TOPIC))
+            .build(), observer);
+
+        assertNotNull(observer.value);
+        // an unset Timestamp used to mean "replay everything since 1970"
+        assertEquals(Code.BAD_REQUEST, observer.value.getStatus().getCode());
+        assertTrue(observer.value.getStatus().getMessage().contains("reset_timestamp"));
+        verify(adminService, never()).resetOffset(anyString(), anyString(), anyString(), anyLong(), anyBoolean(),
+            anyLong());
+    }
+
+    @Test
+    public void resetGroupOffsetRejectsZeroTimestampTest() {
+        SimpleObserver<ResetGroupOffsetResponse> observer = new SimpleObserver<>();
+        service.resetGroupOffset(ResetGroupOffsetRequest.newBuilder()
+            .setGroup(resource(GROUP))
+            .setTopic(resource(TOPIC))
+            .setResetTimestamp(Timestamp.newBuilder().setSeconds(0L).build())
+            .build(), observer);
+
+        assertNotNull(observer.value);
+        assertEquals(Code.BAD_REQUEST, observer.value.getStatus().getCode());
+        verify(adminService, never()).resetOffset(anyString(), anyString(), anyString(), anyLong(), anyBoolean(),
+            anyLong());
+    }
+
+    @Test
+    public void resetGroupOffsetReachesEveryBrokerTest() throws Exception {
+        stubRoute(true, ADDRESS_A, ADDRESS_B);
+        when(adminService.resetOffset(anyString(), eq(TOPIC), eq(GROUP), anyLong(), eq(true), anyLong()))
+            .thenReturn(CompletableFuture.completedFuture(Collections.emptyMap()));
+
+        SimpleObserver<ResetGroupOffsetResponse> observer = new SimpleObserver<>();
+        service.resetGroupOffset(ResetGroupOffsetRequest.newBuilder()
+            .setGroup(resource(GROUP))
+            .setTopic(resource(TOPIC))
+            .setResetTimestamp(Timestamp.newBuilder().setSeconds(1000L).setNanos(500_000_000).build())
+            .build(), observer);
+
+        assertNotNull(observer.value);
+        assertEquals(Code.OK, observer.value.getStatus().getCode());
+        // seconds + nanos must be converted to one millisecond timestamp
+        verify(adminService).resetOffset(eq(ADDRESS_A), eq(TOPIC), eq(GROUP), eq(1000500L), eq(true), anyLong());
+        verify(adminService).resetOffset(eq(ADDRESS_B), eq(TOPIC), eq(GROUP), eq(1000500L), eq(true), anyLong());
+    }
+
+    @Test
+    public void resetGroupOffsetPropagatesBrokerFailureTest() throws Exception {
+        stubRoute(true, ADDRESS_A);
+        when(adminService.resetOffset(anyString(), eq(TOPIC), eq(GROUP), anyLong(), eq(true), anyLong()))
+            .thenReturn(failedFuture(new RuntimeException("reset rejected")));
+
+        SimpleObserver<ResetGroupOffsetResponse> observer = new SimpleObserver<>();
+        service.resetGroupOffset(ResetGroupOffsetRequest.newBuilder()
+            .setGroup(resource(GROUP))
+            .setTopic(resource(TOPIC))
+            .setResetTimestamp(Timestamp.newBuilder().setSeconds(1000L).build())
+            .build(), observer);
+
+        assertNotNull(observer.value);
+        assertEquals(Code.INTERNAL_SERVER_ERROR, observer.value.getStatus().getCode());
+        assertTrue(observer.value.getStatus().getMessage().contains("reset rejected"));
+    }
+
+    // ------------------------------------------------------------------ 9. QueryMessage
+
+    @Test
+    public void queryMessageRejectsMissingTopicTest() {
+        SimpleObserver<ListMessageResponse> observer = new SimpleObserver<>();
+        service.queryMessage(ListMessageRequest.newBuilder().setMessageKey("key").build(), observer);
+
+        assertNotNull(observer.value);
+        assertEquals(Code.BAD_REQUEST, observer.value.getStatus().getCode());
+    }
+
+    @Test
+    public void queryMessageRejectsUnsupportedSearchKeyTest() throws Exception {
+        stubRoute(false, ADDRESS_A);
+        SimpleObserver<ListMessageResponse> noKeyObserver = new SimpleObserver<>();
+        service.queryMessage(ListMessageRequest.newBuilder().setTopic(resource(TOPIC)).build(), noKeyObserver);
+        assertNotNull(noKeyObserver.value);
+        assertEquals(Code.BAD_REQUEST, noKeyObserver.value.getStatus().getCode());
+
+        SimpleObserver<ListMessageResponse> subscriptionObserver = new SimpleObserver<>();
+        service.queryMessage(ListMessageRequest.newBuilder()
+            .setTopic(resource(TOPIC))
+            .setSubscription("tagA")
+            .build(), subscriptionObserver);
+        assertNotNull(subscriptionObserver.value);
+        assertEquals(Code.BAD_REQUEST, subscriptionObserver.value.getStatus().getCode());
+        assertTrue(subscriptionObserver.value.getStatus().getMessage().contains("not supported"));
+    }
+
+    @Test
+    public void queryMessageByMessageIdUsesUniqueKeyTest() throws Exception {
+        stubRoute(false, ADDRESS_A);
+        when(adminService.queryMessage(anyString(), anyString(), anyString(), anyInt(), anyLong(), anyLong(),
+            anyBoolean(), anyBoolean(), anyLong()))
+            .thenReturn(CompletableFuture.completedFuture(Collections.singletonList(messageExt("UNIQ-1"))));
+
+        SimpleObserver<ListMessageResponse> observer = new SimpleObserver<>();
+        service.queryMessage(ListMessageRequest.newBuilder()
+            .setTopic(resource(TOPIC))
+            .setMessageId("UNIQ-1")
+            .build(), observer);
+
+        assertNotNull(observer.value);
+        assertEquals(Code.OK, observer.value.getStatus().getCode());
+        assertEquals(1, observer.value.getMessagesCount());
+        assertEquals("UNIQ-1", observer.value.getMessages(0).getSystemProperties().getMessageId());
+        assertEquals("payload", observer.value.getMessages(0).getBody().toString(StandardCharsets.UTF_8));
+
+        // a v2 message_id is the client-generated unique key, so the lookup must use the
+        // unique-key index (uniqueKey=true), never the offset-decoding path
+        ArgumentCaptor<Boolean> uniqueKeyCaptor = ArgumentCaptor.forClass(Boolean.class);
+        verify(adminService).queryMessage(eq(ADDRESS_A), eq(TOPIC), eq("UNIQ-1"), anyInt(), anyLong(), anyLong(),
+            uniqueKeyCaptor.capture(), anyBoolean(), anyLong());
+        assertTrue(uniqueKeyCaptor.getValue());
+    }
+
+    @Test
+    public void queryMessageByKeyUsesKeyIndexTest() throws Exception {
+        stubRoute(false, ADDRESS_A);
+        when(adminService.queryMessage(anyString(), anyString(), anyString(), anyInt(), anyLong(), anyLong(),
+            anyBoolean(), anyBoolean(), anyLong()))
+            .thenReturn(CompletableFuture.completedFuture(Collections.singletonList(messageExt("MSG-1"))));
+
+        SimpleObserver<ListMessageResponse> observer = new SimpleObserver<>();
+        service.queryMessage(ListMessageRequest.newBuilder()
+            .setTopic(resource(TOPIC))
+            .setMessageKey("key-1")
+            .build(), observer);
+
+        assertNotNull(observer.value);
+        assertEquals(Code.OK, observer.value.getStatus().getCode());
+        assertEquals(1, observer.value.getMessagesCount());
+
+        ArgumentCaptor<Boolean> uniqueKeyCaptor = ArgumentCaptor.forClass(Boolean.class);
+        verify(adminService).queryMessage(eq(ADDRESS_A), eq(TOPIC), eq("key-1"), anyInt(), anyLong(), anyLong(),
+            uniqueKeyCaptor.capture(), anyBoolean(), anyLong());
+        assertFalse(uniqueKeyCaptor.getValue());
+    }
+
+    @Test
+    public void queryMessageReturnsNotFoundWhenNoBrokerHasItTest() throws Exception {
+        stubRoute(false, ADDRESS_A, ADDRESS_B);
+        when(adminService.queryMessage(anyString(), anyString(), anyString(), anyInt(), anyLong(), anyLong(),
+            anyBoolean(), anyBoolean(), anyLong()))
+            .thenReturn(CompletableFuture.completedFuture(Collections.emptyList()));
+
+        SimpleObserver<ListMessageResponse> observer = new SimpleObserver<>();
+        service.queryMessage(ListMessageRequest.newBuilder()
+            .setTopic(resource(TOPIC))
+            .setMessageId("UNIQ-MISSING")
+            .build(), observer);
+
+        assertNotNull(observer.value);
+        assertEquals(Code.MESSAGE_NOT_FOUND, observer.value.getStatus().getCode());
+        assertTrue(observer.value.getStatus().getMessage().contains("no message found"));
+        assertEquals(0, observer.value.getMessagesCount());
+    }
+
+    @Test
+    public void queryMessageSurvivesPartialBrokerFailureTest() throws Exception {
+        stubRoute(false, ADDRESS_A, ADDRESS_B);
+        when(adminService.queryMessage(eq(ADDRESS_A), eq(TOPIC), eq("key-1"), anyInt(), anyLong(), anyLong(),
+            anyBoolean(), anyBoolean(), anyLong()))
+            .thenReturn(failedFuture(new RuntimeException("broker-a down")));
+        when(adminService.queryMessage(eq(ADDRESS_B), eq(TOPIC), eq("key-1"), anyInt(), anyLong(), anyLong(),
+            anyBoolean(), anyBoolean(), anyLong()))
+            .thenReturn(CompletableFuture.completedFuture(Collections.singletonList(messageExt("MSG-2"))));
+
+        SimpleObserver<ListMessageResponse> observer = new SimpleObserver<>();
+        service.queryMessage(ListMessageRequest.newBuilder()
+            .setTopic(resource(TOPIC))
+            .setMessageKey("key-1")
+            .build(), observer);
+
+        assertNotNull(observer.value);
+        // a partially available cluster still returns the data it has
+        assertEquals(Code.OK, observer.value.getStatus().getCode());
+        assertEquals(1, observer.value.getMessagesCount());
+    }
+
+    @Test
+    public void queryMessageRespectsMaxNumsTest() throws Exception {
+        stubRoute(false, ADDRESS_A);
+        when(adminService.queryMessage(anyString(), anyString(), anyString(), anyInt(), anyLong(), anyLong(),
+            anyBoolean(), anyBoolean(), anyLong()))
+            .thenReturn(CompletableFuture.completedFuture(Arrays.asList(messageExt("MSG-1"), messageExt("MSG-2"))));
+
+        SimpleObserver<ListMessageResponse> observer = new SimpleObserver<>();
+        service.queryMessage(ListMessageRequest.newBuilder()
+            .setTopic(resource(TOPIC))
+            .setMessageKey("key-1")
+            .setMaxMessageNums(1)
+            .build(), observer);
+
+        assertNotNull(observer.value);
+        assertEquals(Code.OK, observer.value.getStatus().getCode());
+        assertEquals(1, observer.value.getMessagesCount());
+        verify(adminService).queryMessage(eq(ADDRESS_A), eq(TOPIC), eq("key-1"), eq(1), anyLong(), anyLong(),
+            anyBoolean(), anyBoolean(), anyLong());
+    }
+
+    // ------------------------------------------------------------------ 10/13. client relay RPCs
+
+    @Test
+    public void printThreadStackTraceReturnsJstackTest() {
+        stubLocalClient();
+        ConsumerRunningInfo runningInfo = new ConsumerRunningInfo();
+        runningInfo.setJstack("STACK-TRACE");
+        answerRelayWith(RequestCode.GET_CONSUMER_RUNNING_INFO,
+            new ProxyRelayResult<>(ResponseCode.SUCCESS, "ok", runningInfo));
+
+        SimpleObserver<PrintThreadStackTraceResponse> observer = new SimpleObserver<>();
+        service.printThreadStackTrace(PrintThreadStackTraceRequest.newBuilder()
+            .setGroup(resource(GROUP))
+            .setClientId(CLIENT_ID)
+            .build(), observer);
+
+        assertNotNull(observer.value);
+        assertEquals(Code.OK, observer.value.getStatus().getCode());
+        assertEquals("STACK-TRACE", observer.value.getThreadStackTrace());
+
+        ArgumentCaptor<ProxyRelayRequest> captor = ArgumentCaptor.forClass(ProxyRelayRequest.class);
+        verify(channel).writeAndFlush(captor.capture());
+        GetConsumerRunningInfoRequestHeader header =
+            (GetConsumerRunningInfoRequestHeader) captor.getValue().readCustomHeader();
+        assertTrue(header.isJstackEnable());
+        assertEquals(GROUP, header.getConsumerGroup());
+        assertEquals(CLIENT_ID, header.getClientId());
+    }
+
+    @Test
+    public void printThreadStackTraceEmptyJstackIsNotFoundTest() {
+        stubLocalClient();
+        answerRelayWith(RequestCode.GET_CONSUMER_RUNNING_INFO,
+            new ProxyRelayResult<>(ResponseCode.SUCCESS, "ok", new ConsumerRunningInfo()));
+
+        SimpleObserver<PrintThreadStackTraceResponse> observer = new SimpleObserver<>();
+        service.printThreadStackTrace(PrintThreadStackTraceRequest.newBuilder()
+            .setGroup(resource(GROUP))
+            .setClientId(CLIENT_ID)
+            .build(), observer);
+
+        assertNotNull(observer.value);
+        assertEquals(Code.NOT_FOUND, observer.value.getStatus().getCode());
+        assertTrue(observer.value.getStatus().getMessage().contains("did not return a thread stack"));
+    }
+
+    @Test
+    public void printThreadStackTraceMissingClientIsNotFoundTest() {
+        SimpleObserver<PrintThreadStackTraceResponse> observer = new SimpleObserver<>();
+        service.printThreadStackTrace(PrintThreadStackTraceRequest.newBuilder()
+            .setClientId("missing-client")
+            .build(), observer);
+
+        assertNotNull(observer.value);
+        assertEquals(Code.NOT_FOUND, observer.value.getStatus().getCode());
+        assertTrue(observer.value.getStatus().getMessage().contains("not connected"));
+    }
+
+    @Test
+    public void printThreadStackTraceForwardsToOwningProxyTest() {
+        when(forwarder.forwardIfRemote(eq(GROUP), eq(CLIENT_ID), any(), any())).thenReturn(true);
+
+        SimpleObserver<PrintThreadStackTraceResponse> observer = new SimpleObserver<>();
+        service.printThreadStackTrace(PrintThreadStackTraceRequest.newBuilder()
+            .setGroup(resource(GROUP))
+            .setClientId(CLIENT_ID)
+            .build(), observer);
+
+        // ownership of the observer moved to the peer proxy: the local service must stay silent
+        assertNull(observer.value);
+        assertNull(observer.error);
+        assertFalse(observer.completed);
+    }
+
+    @Test
+    public void getConsumerRunningInfoRejectsMissingClientIdTest() {
+        SimpleObserver<GetConsumerRunningInfoResponse> observer = new SimpleObserver<>();
+        service.getConsumerRunningInfo(GetConsumerRunningInfoRequest.newBuilder()
+            .setGroup(resource(GROUP)).build(), observer);
+
+        assertNotNull(observer.value);
+        assertEquals(Code.BAD_REQUEST, observer.value.getStatus().getCode());
+    }
+
+    @Test
+    public void getConsumerRunningInfoReturnsFullInfoTest() {
+        stubLocalClient();
+        ConsumerRunningInfo runningInfo = new ConsumerRunningInfo();
+        Properties properties = runningInfo.getProperties();
+        properties.setProperty(ConsumerRunningInfo.PROP_CLIENT_VERSION, "V5_0_0");
+        runningInfo.getSubscriptionSet().add(subscriptionData(TOPIC, ExpressionType.TAG, "tagA"));
+        answerRelayWith(RequestCode.GET_CONSUMER_RUNNING_INFO,
+            new ProxyRelayResult<>(ResponseCode.SUCCESS, "ok", runningInfo));
+
+        SimpleObserver<GetConsumerRunningInfoResponse> observer = new SimpleObserver<>();
+        service.getConsumerRunningInfo(GetConsumerRunningInfoRequest.newBuilder()
+            .setGroup(resource(GROUP))
+            .setClientId(CLIENT_ID)
+            .build(), observer);
+
+        assertNotNull(observer.value);
+        assertEquals(Code.OK, observer.value.getStatus().getCode());
+        assertEquals("V5_0_0", observer.value.getConsumerRunningInfo()
+            .getPropertiesMap().get(ConsumerRunningInfo.PROP_CLIENT_VERSION));
+        assertTrue(observer.value.getConsumerRunningInfo().getSubscriptionsMap().containsKey(TOPIC));
+
+        ArgumentCaptor<ProxyRelayRequest> captor = ArgumentCaptor.forClass(ProxyRelayRequest.class);
+        verify(channel).writeAndFlush(captor.capture());
+        GetConsumerRunningInfoRequestHeader header =
+            (GetConsumerRunningInfoRequestHeader) captor.getValue().readCustomHeader();
+        assertFalse(header.isJstackEnable());
+    }
+
+    @Test
+    public void getConsumerRunningInfoFallsBackToSubscriptionsForGrpcClientTest() {
+        stubLocalClient();
+        // the GrpcClientChannel jstack guard completes the relay future with this result for a
+        // gRPC v2 client that cannot answer a running-info request without a thread dump
+        answerRelayWith(RequestCode.GET_CONSUMER_RUNNING_INFO,
+            new ProxyRelayResult<>(ResponseCode.REQUEST_CODE_NOT_SUPPORTED,
+                "gRPC v2 protocol cannot report consumer running info without jstack, "
+                    + "retry with jstackEnable=true", null));
+        when(grpcClientSettingsManager.getRawClientSettings(CLIENT_ID))
+            .thenReturn(settings(ClientType.SIMPLE_CONSUMER, GROUP, TOPIC, "tagA"));
+
+        SimpleObserver<GetConsumerRunningInfoResponse> observer = new SimpleObserver<>();
+        service.getConsumerRunningInfo(GetConsumerRunningInfoRequest.newBuilder()
+            .setGroup(resource(GROUP))
+            .setClientId(CLIENT_ID)
+            .build(), observer);
+
+        assertNotNull(observer.value);
+        assertEquals(Code.OK, observer.value.getStatus().getCode());
+        assertTrue(observer.value.getStatus().getMessage().contains("only subscriptions are available"));
+        assertEquals("tagA", observer.value.getConsumerRunningInfo()
+            .getSubscriptionsMap().get(TOPIC).getExpression());
+    }
+
+    @Test
+    public void getConsumerRunningInfoErrorsWithoutSettingsOrChannelTest() {
+        SimpleObserver<GetConsumerRunningInfoResponse> observer = new SimpleObserver<>();
+        service.getConsumerRunningInfo(GetConsumerRunningInfoRequest.newBuilder()
+            .setClientId("missing-client")
+            .build(), observer);
+
+        assertNotNull(observer.value);
+        assertEquals(Code.INTERNAL_SERVER_ERROR, observer.value.getStatus().getCode());
+        assertTrue(observer.value.getStatus().getMessage().contains("not connected"));
+    }
+
+    // ------------------------------------------------------------------ 11. VerifyMessage
+
+    @Test
+    public void verifyMessageRejectsMissingFieldsTest() {
+        SimpleObserver<VerifyMessageResponse> observer = new SimpleObserver<>();
+        service.verifyMessage(VerifyMessageRequest.newBuilder()
+            .setClientId(CLIENT_ID)
+            .setTopic(resource(TOPIC))
+            .build(), observer);
+
+        assertNotNull(observer.value);
+        assertEquals(Code.BAD_REQUEST, observer.value.getStatus().getCode());
+    }
+
+    @Test
+    public void verifyMessageSuccessTest() throws Exception {
+        stubRoute(false, ADDRESS_A);
+        stubLocalClient();
+        when(adminService.queryMessage(anyString(), anyString(), anyString(), anyInt(), anyLong(), anyLong(),
+            anyBoolean(), anyBoolean(), anyLong()))
+            .thenReturn(CompletableFuture.completedFuture(Collections.singletonList(messageExt("UNIQ-9"))));
+        ConsumeMessageDirectlyResult directlyResult = new ConsumeMessageDirectlyResult();
+        directlyResult.setConsumeResult(CMResult.CR_SUCCESS);
+        answerRelayWith(RequestCode.CONSUME_MESSAGE_DIRECTLY,
+            new ProxyRelayResult<>(ResponseCode.SUCCESS, "ok", directlyResult));
+
+        SimpleObserver<VerifyMessageResponse> observer = new SimpleObserver<>();
+        service.verifyMessage(VerifyMessageRequest.newBuilder()
+            .setGroup(resource(GROUP))
+            .setClientId(CLIENT_ID)
+            .setTopic(resource(TOPIC))
+            .setMessageId("UNIQ-9")
+            .build(), observer);
+
+        assertNotNull(observer.value);
+        assertEquals(Code.OK, observer.value.getStatus().getCode());
+
+        // the real message is fetched through the unique-key index and relayed to the client
+        verify(adminService).queryMessage(eq(ADDRESS_A), eq(TOPIC), eq("UNIQ-9"), eq(1), eq(0L), eq(Long.MAX_VALUE),
+            eq(true), eq(false), anyLong());
+        ArgumentCaptor<ProxyRelayRequest> captor = ArgumentCaptor.forClass(ProxyRelayRequest.class);
+        verify(channel).writeAndFlush(captor.capture());
+        ConsumeMessageDirectlyResultRequestHeader header =
+            (ConsumeMessageDirectlyResultRequestHeader) captor.getValue().readCustomHeader();
+        assertEquals(GROUP, header.getConsumerGroup());
+        assertEquals(CLIENT_ID, header.getClientId());
+        assertEquals(TOPIC, header.getTopic());
+        assertEquals("UNIQ-9", header.getMsgId());
+        assertNotNull(captor.getValue().getBody());
+    }
+
+    @Test
+    public void verifyMessageReportsCorruptedOnFailureTest() throws Exception {
+        stubRoute(false, ADDRESS_A);
+        stubLocalClient();
+        when(adminService.queryMessage(anyString(), anyString(), anyString(), anyInt(), anyLong(), anyLong(),
+            anyBoolean(), anyBoolean(), anyLong()))
+            .thenReturn(CompletableFuture.completedFuture(Collections.singletonList(messageExt("UNIQ-9"))));
+        ConsumeMessageDirectlyResult directlyResult = new ConsumeMessageDirectlyResult();
+        directlyResult.setConsumeResult(CMResult.CR_THROW_EXCEPTION);
+        directlyResult.setRemark("consume crashed");
+        answerRelayWith(RequestCode.CONSUME_MESSAGE_DIRECTLY,
+            new ProxyRelayResult<>(ResponseCode.SUCCESS, "ok", directlyResult));
+
+        SimpleObserver<VerifyMessageResponse> observer = new SimpleObserver<>();
+        service.verifyMessage(VerifyMessageRequest.newBuilder()
+            .setGroup(resource(GROUP))
+            .setClientId(CLIENT_ID)
+            .setTopic(resource(TOPIC))
+            .setMessageId("UNIQ-9")
+            .build(), observer);
+
+        assertNotNull(observer.value);
+        assertEquals(Code.MESSAGE_CORRUPTED, observer.value.getStatus().getCode());
+        assertTrue(observer.value.getStatus().getMessage().contains("failed to consume message UNIQ-9"));
+        assertTrue(observer.value.getStatus().getMessage().contains("consume crashed"));
+    }
+
+    @Test
+    public void verifyMessageFailsWhenMessageNotFoundTest() throws Exception {
+        stubRoute(false, ADDRESS_A);
+        when(adminService.queryMessage(anyString(), anyString(), anyString(), anyInt(), anyLong(), anyLong(),
+            anyBoolean(), anyBoolean(), anyLong()))
+            .thenReturn(CompletableFuture.completedFuture(Collections.emptyList()));
+
+        SimpleObserver<VerifyMessageResponse> observer = new SimpleObserver<>();
+        service.verifyMessage(VerifyMessageRequest.newBuilder()
+            .setGroup(resource(GROUP))
+            .setClientId(CLIENT_ID)
+            .setTopic(resource(TOPIC))
+            .setMessageId("UNIQ-MISSING")
+            .build(), observer);
+
+        assertNotNull(observer.value);
+        // a missing message is a normal answer, so it must not be reported as a proxy fault;
+        // same grading queryMessage uses
+        assertEquals(Code.MESSAGE_NOT_FOUND, observer.value.getStatus().getCode());
+        assertTrue(observer.value.getStatus().getMessage().contains("not found"));
+    }
+
+    // ------------------------------------------------------------------ 12. AdminSendMessage
+
+    @Test
+    public void adminSendMessageReturnsIdTest() {
+        SendResult sendResult = new SendResult();
+        sendResult.setMsgId("SENT-1");
+        when(messagingProcessor.sendMessage(any(), any(), anyString(), anyInt(), anyList(), anyLong()))
+            .thenReturn(CompletableFuture.completedFuture(Collections.singletonList(sendResult)));
+
+        SimpleObserver<AdminSendMessageResponse> observer = new SimpleObserver<>();
+        service.adminSendMessage(AdminSendMessageRequest.newBuilder()
+            .setTopic(resource(TOPIC))
+            .setBody(ByteString.copyFromUtf8("hello"))
+            .build(), observer);
+
+        assertNotNull(observer.value);
+        assertEquals(Code.OK, observer.value.getStatus().getCode());
+        assertEquals("SENT-1", observer.value.getMessageId());
+    }
+
+    @Test
+    public void adminSendMessageCarriesTagsKeysAndPropertiesTest() {
+        SendResult sendResult = new SendResult();
+        sendResult.setMsgId("SENT-2");
+        when(messagingProcessor.sendMessage(any(), any(), anyString(), anyInt(), anyList(), anyLong()))
+            .thenReturn(CompletableFuture.completedFuture(Collections.singletonList(sendResult)));
+
+        SimpleObserver<AdminSendMessageResponse> observer = new SimpleObserver<>();
+        service.adminSendMessage(AdminSendMessageRequest.newBuilder()
+            .setTopic(resource(TOPIC))
+            .setBody(ByteString.copyFromUtf8("hello"))
+            .setTag("tagA")
+            .setKey("key1")
+            .putUserProperties("traceId", "abc")
+            .build(), observer);
+
+        assertNotNull(observer.value);
+        assertEquals(Code.OK, observer.value.getStatus().getCode());
+        assertEquals("SENT-2", observer.value.getMessageId());
+
+        @SuppressWarnings("rawtypes")
+        ArgumentCaptor<List> messagesCaptor = ArgumentCaptor.forClass(List.class);
+        verify(messagingProcessor).sendMessage(any(), any(), anyString(), anyInt(), messagesCaptor.capture(),
+            anyLong());
+        Message message = (Message) messagesCaptor.getValue().get(0);
+        assertEquals(TOPIC, message.getTopic());
+        assertEquals("tagA", message.getTags());
+        assertEquals("key1", message.getKeys());
+        assertEquals("abc", message.getUserProperty("traceId"));
+    }
+
+    @Test
+    public void adminSendMessageRejectsPastDeliveryTimestampTest() {
+        SimpleObserver<AdminSendMessageResponse> observer = new SimpleObserver<>();
+        service.adminSendMessage(AdminSendMessageRequest.newBuilder()
+            .setTopic(resource(TOPIC))
+            .setBody(ByteString.copyFromUtf8("hello"))
+            .setSystemProperties(SystemProperties.newBuilder()
+                .setDeliveryTimestamp(Timestamp.newBuilder()
+                    .setSeconds(System.currentTimeMillis() / 1000L - 100L).build())
+                .build())
+            .build(), observer);
+
+        assertNotNull(observer.value);
+        assertEquals(Code.ILLEGAL_DELIVERY_TIME, observer.value.getStatus().getCode());
+    }
+
+    @Test
+    public void adminSendMessageFailsGracefullyWhenSendFailsTest() {
+        when(messagingProcessor.sendMessage(any(), any(), anyString(), anyInt(), anyList(), anyLong()))
+            .thenReturn(failedFuture(new RuntimeException("send failed")));
+
+        SimpleObserver<AdminSendMessageResponse> observer = new SimpleObserver<>();
+        service.adminSendMessage(AdminSendMessageRequest.newBuilder()
+            .setTopic(resource(TOPIC))
+            .setBody(ByteString.copyFromUtf8("hello"))
+            .build(), observer);
+
+        assertNotNull(observer.value);
+        assertEquals(Code.INTERNAL_SERVER_ERROR, observer.value.getStatus().getCode());
+        assertTrue(observer.value.getStatus().getMessage().contains("send failed"));
+    }
+
+    // ------------------------------------------------------------------ 14. GetTopicRoute
+
+    @Test
+    public void getTopicRouteReturnsJsonTest() {
+        TopicRouteData routeData = new TopicRouteData();
+        routeData.setOrderTopicConf("orderConf");
+        when(adminService.getTopicRouteData(TOPIC)).thenReturn(CompletableFuture.completedFuture(routeData));
+
+        SimpleObserver<GetTopicRouteResponse> observer = new SimpleObserver<>();
+        service.getTopicRoute(GetTopicRouteRequest.newBuilder().setTopic(resource(TOPIC)).build(), observer);
+
+        assertNotNull(observer.value);
+        assertEquals(Code.OK, observer.value.getStatus().getCode());
+        assertEquals(JSON.toJSONString(routeData), observer.value.getTopicRouteData());
+    }
+
+    @Test
+    public void getTopicRouteFailsWhenAdminServiceFailsTest() {
+        when(adminService.getTopicRouteData(TOPIC))
+            .thenReturn(failedFuture(new RuntimeException("namesrv unreachable")));
+
+        SimpleObserver<GetTopicRouteResponse> observer = new SimpleObserver<>();
+        service.getTopicRoute(GetTopicRouteRequest.newBuilder().setTopic(resource(TOPIC)).build(), observer);
+
+        assertNotNull(observer.value);
+        assertEquals(Code.INTERNAL_SERVER_ERROR, observer.value.getStatus().getCode());
+        assertTrue(observer.value.getStatus().getMessage().contains("namesrv unreachable"));
+    }
+
+    @Test
+    public void getTopicRouteRejectsMissingTopicTest() {
+        SimpleObserver<GetTopicRouteResponse> observer = new SimpleObserver<>();
+        service.getTopicRoute(GetTopicRouteRequest.newBuilder().build(), observer);
+
+        assertNotNull(observer.value);
+        assertEquals(Code.BAD_REQUEST, observer.value.getStatus().getCode());
+    }
+
+    // ------------------------------------------------------------------ 15. QueryTimeSpan
+
+    @Test
+    public void queryTimeSpanRejectsMissingGroupTest() {
+        SimpleObserver<QueryTimeSpanResponse> observer = new SimpleObserver<>();
+        service.queryTimeSpan(QueryTimeSpanRequest.newBuilder()
+            .addTopics(resource(TOPIC)).build(), observer);
+
+        assertNotNull(observer.value);
+        assertEquals(Code.BAD_REQUEST, observer.value.getStatus().getCode());
+    }
+
+    @Test
+    public void queryTimeSpanRejectsMissingTopicsTest() {
+        SimpleObserver<QueryTimeSpanResponse> observer = new SimpleObserver<>();
+        service.queryTimeSpan(QueryTimeSpanRequest.newBuilder()
+            .setGroup(resource(GROUP)).build(), observer);
+
+        assertNotNull(observer.value);
+        assertEquals(Code.BAD_REQUEST, observer.value.getStatus().getCode());
+    }
+
+    @Test
+    public void queryTimeSpanReturnsBrokerComputedSpansTest() throws Exception {
+        stubRoute(false, ADDRESS_A);
+        QueueTimeSpan span = new QueueTimeSpan();
+        span.setMessageQueue(new MessageQueue(TOPIC, "broker-0", 0));
+        span.setMinTimeStamp(100L);
+        span.setMaxTimeStamp(200L);
+        span.setConsumeTimeStamp(150L);
+        span.setDelayTime(42L);
+        when(adminService.queryConsumeTimeSpan(eq(ADDRESS_A), eq(TOPIC), eq(GROUP), anyLong()))
+            .thenReturn(CompletableFuture.completedFuture(Collections.singletonList(span)));
+
+        SimpleObserver<QueryTimeSpanResponse> observer = new SimpleObserver<>();
+        service.queryTimeSpan(QueryTimeSpanRequest.newBuilder()
+            .setGroup(resource(GROUP))
+            .addTopics(resource(TOPIC))
+            .build(), observer);
+
+        assertNotNull(observer.value);
+        assertEquals(Code.OK, observer.value.getStatus().getCode());
+        assertEquals(1, observer.value.getQueueTimeSpanListCount());
+        QueryTimeSpanResponse.QueueTimeSpan v2Span = observer.value.getQueueTimeSpanList(0);
+        assertEquals("broker-0", v2Span.getMessageQueue().getBroker().getName());
+        assertEquals(100L, v2Span.getMinTimestamp());
+        assertEquals(200L, v2Span.getMaxTimestamp());
+        assertEquals(150L, v2Span.getConsumeTimestamp());
+        assertEquals(42L, v2Span.getDelayTimeMs());
+    }
+
+    @Test
+    public void queryTimeSpanPropagatesFailureWhenAllBrokersFailTest() throws Exception {
+        stubRoute(false, ADDRESS_A);
+        when(adminService.queryConsumeTimeSpan(eq(ADDRESS_A), eq(TOPIC), eq(GROUP), anyLong()))
+            .thenReturn(failedFuture(new RuntimeException("span query failed")));
+
+        SimpleObserver<QueryTimeSpanResponse> observer = new SimpleObserver<>();
+        service.queryTimeSpan(QueryTimeSpanRequest.newBuilder()
+            .setGroup(resource(GROUP))
+            .addTopics(resource(TOPIC))
+            .build(), observer);
+
+        assertNotNull(observer.value);
+        assertEquals(Code.INTERNAL_SERVER_ERROR, observer.value.getStatus().getCode());
+        assertTrue(observer.value.getStatus().getMessage().contains("span query failed"));
+    }
+
+    // ------------------------------------------------------------------ 16. GetProxyRuntimeStats
+
+    @Test
+    public void getProxyRuntimeStatsCountsLitePushConsumerAsConsumerTest() {
+        GrpcClientChannel consumerChannel = mock(GrpcClientChannel.class);
+        when(consumerChannel.getClientId()).thenReturn(CLIENT_ID);
+        GrpcClientChannel producerChannel = mock(GrpcClientChannel.class);
+        when(producerChannel.getClientId()).thenReturn("producer-1");
+        GrpcClientChannel litePushChannel = mock(GrpcClientChannel.class);
+        when(litePushChannel.getClientId()).thenReturn("lite-push-1");
+        when(grpcChannelManager.getClientChannels())
+            .thenReturn(Arrays.asList(consumerChannel, producerChannel, litePushChannel));
+        when(grpcClientSettingsManager.getRawClientSettings(CLIENT_ID))
+            .thenReturn(settings(ClientType.SIMPLE_CONSUMER, GROUP, TOPIC, "*"));
+        when(grpcClientSettingsManager.getRawClientSettings("producer-1"))
+            .thenReturn(settings(ClientType.PRODUCER, GROUP, TOPIC, "*"));
+        when(grpcClientSettingsManager.getRawClientSettings("lite-push-1"))
+            .thenReturn(settings(ClientType.LITE_PUSH_CONSUMER, GROUP, TOPIC, "*"));
+
+        SimpleObserver<GetProxyRuntimeStatsResponse> observer = new SimpleObserver<>();
+        service.getProxyRuntimeStats(GetProxyRuntimeStatsRequest.newBuilder().build(), observer);
+
+        assertNotNull(observer.value);
+        assertEquals(Code.OK, observer.value.getStatus().getCode());
+        // LITE_PUSH_CONSUMER is a consumer, not a producer
+        assertEquals(2L, observer.value.getConsumers());
+        assertEquals(1L, observer.value.getProducers());
+        assertEquals(3L, observer.value.getConnections());
+        // the version comes from MQVersion, not from a hardcoded string
+        assertEquals(MQVersion.getVersionDesc(MQVersion.CURRENT_VERSION), observer.value.getVersion());
+        assertEquals(ConfigurationManager.getProxyConfig().getProxyName(), observer.value.getProxyName());
+    }
+
+    @Test
+    public void getProxyRuntimeStatsSkipsChannelsWithoutSettingsTest() {
+        GrpcClientChannel unknownChannel = mock(GrpcClientChannel.class);
+        when(unknownChannel.getClientId()).thenReturn("unknown");
+        when(grpcChannelManager.getClientChannels()).thenReturn(Collections.singletonList(unknownChannel));
+
+        SimpleObserver<GetProxyRuntimeStatsResponse> observer = new SimpleObserver<>();
+        service.getProxyRuntimeStats(GetProxyRuntimeStatsRequest.newBuilder().build(), observer);
+
+        assertNotNull(observer.value);
+        assertEquals(Code.OK, observer.value.getStatus().getCode());
+        assertEquals(1L, observer.value.getConnections());
+        assertEquals(0L, observer.value.getProducers());
+        assertEquals(0L, observer.value.getConsumers());
+    }
+
+    // ------------------------------------------------------------------ cross-RPC
+
+    @Test
+    public void describeGroupAccumulationKeepsRetryBacklogInTotalTest() {
+        stubClusterInfo(ADDRESS_A);
+        ConsumeStats stats = consumeStats(TOPIC, 100L, 60L, 60L);
+        OffsetWrapper retryWrapper = new OffsetWrapper();
+        retryWrapper.setBrokerOffset(30L);
+        retryWrapper.setConsumerOffset(10L);
+        retryWrapper.setPullOffset(10L);
+        stats.getOffsetTable().put(new MessageQueue(MixAll.getRetryTopic(GROUP), "broker-0", 0), retryWrapper);
+        when(adminService.getConsumeStats(eq(ADDRESS_A), eq(GROUP), eq(""), anyLong()))
+            .thenReturn(CompletableFuture.completedFuture(stats));
+
+        SimpleObserver<DescribeGroupAccumulationResponse> observer = new SimpleObserver<>();
+        service.describeGroupAccumulation(DescribeGroupAccumulationRequest.newBuilder()
+            .setGroup(resource(GROUP))
+            .addTopics(resource(TOPIC))
+            .build(), observer);
+
+        assertNotNull(observer.value);
+        assertEquals(Code.OK, observer.value.getStatus().getCode());
+        // 40 from the topic + 20 from the group's retry topic
+        assertEquals(60L, observer.value.getAccumulation().getAccumulation());
+        assertEquals(40L, observer.value.getTopicAccumulationMap().get(TOPIC).getAccumulation());
+        assertFalse(observer.value.getTopicAccumulationMap().containsKey(MixAll.getRetryTopic(GROUP)));
+    }
+}
diff --git a/proxy/src/test/java/org/apache/rocketmq/proxy/grpc/v2/channel/GrpcClientChannelTest.java b/proxy/src/test/java/org/apache/rocketmq/proxy/grpc/v2/channel/GrpcClientChannelTest.java
index 1bdbdd9bef..4b84366f24 100644
--- a/proxy/src/test/java/org/apache/rocketmq/proxy/grpc/v2/channel/GrpcClientChannelTest.java
+++ b/proxy/src/test/java/org/apache/rocketmq/proxy/grpc/v2/channel/GrpcClientChannelTest.java
@@ -20,6 +20,10 @@ package org.apache.rocketmq.proxy.grpc.v2.channel;
 import apache.rocketmq.v2.Publishing;
 import apache.rocketmq.v2.Resource;
 import apache.rocketmq.v2.Settings;
+import apache.rocketmq.v2.TelemetryCommand;
+import io.grpc.stub.StreamObserver;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
 import org.apache.commons.lang3.RandomStringUtils;
 import org.apache.rocketmq.proxy.common.ProxyContext;
 import org.apache.rocketmq.proxy.config.InitConfigTest;
@@ -27,17 +31,30 @@ import org.apache.rocketmq.proxy.grpc.v2.common.GrpcClientSettingsManager;
 import org.apache.rocketmq.proxy.processor.channel.ChannelProtocolType;
 import org.apache.rocketmq.proxy.processor.channel.RemoteChannel;
 import org.apache.rocketmq.proxy.remoting.channel.RemotingChannel;
+import org.apache.rocketmq.proxy.service.relay.ClusterProxyRelayService;
+import org.apache.rocketmq.proxy.service.relay.ProxyRelayRequest;
+import org.apache.rocketmq.proxy.service.relay.ProxyRelayResult;
 import org.apache.rocketmq.proxy.service.relay.ProxyRelayService;
+import org.apache.rocketmq.proxy.service.transaction.TransactionService;
+import org.apache.rocketmq.remoting.protocol.RequestCode;
+import org.apache.rocketmq.remoting.protocol.ResponseCode;
+import org.apache.rocketmq.remoting.protocol.body.ConsumerRunningInfo;
+import org.apache.rocketmq.remoting.protocol.header.GetConsumerRunningInfoRequestHeader;
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
 import org.mockito.Mock;
 import org.mockito.junit.MockitoJUnitRunner;
 
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
 @RunWith(MockitoJUnitRunner.class)
@@ -79,4 +96,60 @@ public class GrpcClientChannelTest extends InitConfigTest {
         assertEquals(clientSettings, GrpcClientChannel.parseChannelExtendAttribute(this.grpcClientChannel));
         assertNull(GrpcClientChannel.parseChannelExtendAttribute(mock(RemotingChannel.class)));
     }
+
+    @Test
+    public void getConsumerRunningInfoWithoutJstackCompletesWithNotSupportedTest() throws Exception {
+        GrpcClientChannel channel = new GrpcClientChannel(
+            new ClusterProxyRelayService(mock(TransactionService.class)), grpcClientSettingsManager,
+            grpcChannelManager,
+            ProxyContext.create().setRemoteAddress("10.152.39.53:9768").setLocalAddress("11.193.0.1:1210"),
+            clientId);
+
+        GetConsumerRunningInfoRequestHeader header = new GetConsumerRunningInfoRequestHeader();
+        header.setConsumerGroup("group");
+        header.setClientId(clientId);
+        header.setJstackEnable(false);
+        CompletableFuture<ProxyRelayResult<ConsumerRunningInfo>> callerFuture = new CompletableFuture<>();
+
+        channel.writeAndFlush(ProxyRelayRequest.createRequestCommand(
+            RequestCode.GET_CONSUMER_RUNNING_INFO, header, callerFuture));
+
+        // the gRPC v2 telemetry protocol has no running-info reply without a thread dump; the guard
+        // must complete the caller immediately instead of letting it hang until the relay timeout
+        assertTrue(callerFuture.isDone());
+        ProxyRelayResult<ConsumerRunningInfo> result = callerFuture.get(1, TimeUnit.SECONDS);
+        assertEquals(ResponseCode.REQUEST_CODE_NOT_SUPPORTED, result.getCode());
+        assertTrue(result.getRemark().contains("jstackEnable=true"));
+        assertNull(result.getResult());
+    }
+
+    @Test
+    @SuppressWarnings("unchecked")
+    public void getConsumerRunningInfoWithJstackWritesTelemetryCommandTest() {
+        GrpcClientChannel channel = new GrpcClientChannel(
+            new ClusterProxyRelayService(mock(TransactionService.class)), grpcClientSettingsManager,
+            grpcChannelManager,
+            ProxyContext.create().setRemoteAddress("10.152.39.53:9768").setLocalAddress("11.193.0.1:1210"),
+            clientId);
+        StreamObserver<TelemetryCommand> observer = mock(StreamObserver.class);
+        channel.setClientObserver(observer);
+        when(grpcChannelManager.addResponseFuture(any())).thenReturn("nonce-1");
+
+        GetConsumerRunningInfoRequestHeader header = new GetConsumerRunningInfoRequestHeader();
+        header.setConsumerGroup("group");
+        header.setClientId(clientId);
+        header.setJstackEnable(true);
+        CompletableFuture<ProxyRelayResult<ConsumerRunningInfo>> callerFuture = new CompletableFuture<>();
+
+        channel.writeAndFlush(ProxyRelayRequest.createRequestCommand(
+            RequestCode.GET_CONSUMER_RUNNING_INFO, header, callerFuture));
+
+        ArgumentCaptor<TelemetryCommand> captor = ArgumentCaptor.forClass(TelemetryCommand.class);
+        verify(observer).onNext(captor.capture());
+        TelemetryCommand command = captor.getValue();
+        assertTrue(command.hasPrintThreadStackTraceCommand());
+        assertEquals("nonce-1", command.getPrintThreadStackTraceCommand().getNonce());
+        // the answer arrives through the telemetry stream, so the caller future stays pending here
+        assertFalse(callerFuture.isDone());
+    }
 }
\ No newline at end of file
diff --git a/proxy/src/test/java/org/apache/rocketmq/proxy/service/admin/DefaultAdminServiceTest.java b/proxy/src/test/java/org/apache/rocketmq/proxy/service/admin/DefaultAdminServiceTest.java
index cdfc7f7fc2..b8136e948d 100644
--- a/proxy/src/test/java/org/apache/rocketmq/proxy/service/admin/DefaultAdminServiceTest.java
+++ b/proxy/src/test/java/org/apache/rocketmq/proxy/service/admin/DefaultAdminServiceTest.java
@@ -14,19 +14,42 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
 package org.apache.rocketmq.proxy.service.admin;
 
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.Set;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import org.apache.rocketmq.client.impl.admin.MqClientAdminImpl;
+import org.apache.rocketmq.client.impl.mqclient.MQClientAPIExt;
+import org.apache.rocketmq.client.impl.mqclient.MQClientAPIFactory;
 import org.apache.rocketmq.client.exception.MQClientException;
 import org.apache.rocketmq.common.TopicConfig;
-import org.apache.rocketmq.remoting.protocol.ResponseCode;
-import org.apache.rocketmq.remoting.protocol.route.BrokerData;
+import org.apache.rocketmq.common.message.MessageExt;
+import org.apache.rocketmq.common.message.MessageQueue;
+import org.apache.rocketmq.remoting.protocol.admin.ConsumeStats;
+import org.apache.rocketmq.remoting.protocol.admin.TopicStatsTable;
+import org.apache.rocketmq.remoting.protocol.body.ClusterInfo;
+import org.apache.rocketmq.remoting.protocol.body.ConsumerConnection;
+import org.apache.rocketmq.remoting.protocol.body.GroupList;
+import org.apache.rocketmq.remoting.protocol.body.QueueTimeSpan;
+import org.apache.rocketmq.remoting.protocol.body.TopicList;
+import org.apache.rocketmq.remoting.protocol.header.DeleteSubscriptionGroupRequestHeader;
+import org.apache.rocketmq.remoting.protocol.header.GetConsumeStatsRequestHeader;
+import org.apache.rocketmq.remoting.protocol.header.GetConsumerConnectionListRequestHeader;
+import org.apache.rocketmq.remoting.protocol.header.GetTopicStatsInfoRequestHeader;
+import org.apache.rocketmq.remoting.protocol.header.QueryConsumeTimeSpanRequestHeader;
+import org.apache.rocketmq.remoting.protocol.header.QueryMessageRequestHeader;
+import org.apache.rocketmq.remoting.protocol.header.QueryTopicConsumeByWhoRequestHeader;
+import org.apache.rocketmq.remoting.protocol.header.QueryTopicsByConsumerRequestHeader;
+import org.apache.rocketmq.remoting.protocol.header.ResetOffsetRequestHeader;
+import org.apache.rocketmq.remoting.protocol.header.ViewMessageRequestHeader;
 import org.apache.rocketmq.remoting.protocol.route.TopicRouteData;
-import org.apache.rocketmq.client.impl.mqclient.MQClientAPIExt;
-import org.apache.rocketmq.client.impl.mqclient.MQClientAPIFactory;
+import org.apache.rocketmq.remoting.protocol.statictopic.TopicConfigAndQueueMapping;
+import org.apache.rocketmq.remoting.protocol.subscription.SubscriptionGroupConfig;
+import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
@@ -35,69 +58,320 @@ import org.mockito.Mock;
 import org.mockito.junit.MockitoJUnitRunner;
 
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertSame;
 import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
 import static org.mockito.ArgumentMatchers.anyLong;
 import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.ArgumentMatchers.eq;
-import static org.mockito.Mockito.doNothing;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
 @RunWith(MockitoJUnitRunner.class)
 public class DefaultAdminServiceTest {
+
     @Mock
     private MQClientAPIFactory mqClientAPIFactory;
     @Mock
     private MQClientAPIExt mqClientAPIExt;
+    @Mock
+    private MqClientAdminImpl mqClientAdmin;
 
-    private DefaultAdminService defaultAdminService;
+    private DefaultAdminService adminService;
+
+    private static final String ADDR = "127.0.0.1:10911";
+    private static final String TOPIC = "topicA";
+    private static final String GROUP = "groupA";
+    private static final long TIMEOUT = 3000L;
+    private static final long GET_TIMEOUT_SECONDS = 5L;
 
     @Before
-    public void before() {
+    public void setUp() {
         when(mqClientAPIFactory.getClient()).thenReturn(mqClientAPIExt);
-        defaultAdminService = new DefaultAdminService(mqClientAPIFactory);
-    }
-
-    @Test
-    public void testCreateTopic() throws Exception {
-        when(mqClientAPIExt.getTopicRouteInfoFromNameServer(eq("createTopic"), anyLong()))
-            .thenThrow(new MQClientException(ResponseCode.TOPIC_NOT_EXIST, ""))
-            .thenReturn(createTopicRouteData(1));
-        when(mqClientAPIExt.getTopicRouteInfoFromNameServer(eq("sampleTopic"), anyLong()))
-            .thenReturn(createTopicRouteData(2));
-
-        ArgumentCaptor<String> addrArgumentCaptor = ArgumentCaptor.forClass(String.class);
-        ArgumentCaptor<TopicConfig> topicConfigArgumentCaptor = ArgumentCaptor.forClass(TopicConfig.class);
-        doNothing().when(mqClientAPIExt).createTopic(addrArgumentCaptor.capture(), anyString(), topicConfigArgumentCaptor.capture(), anyLong());
-
-        assertTrue(defaultAdminService.createTopicOnTopicBrokerIfNotExist(
-            "createTopic",
-            "sampleTopic",
-            7,
-            8,
-            true,
-            1
-        ));
-
-        assertEquals(2, addrArgumentCaptor.getAllValues().size());
-        Set<String> createAddr = new HashSet<>(addrArgumentCaptor.getAllValues());
-        assertTrue(createAddr.contains("127.0.0.1:10911"));
-        assertTrue(createAddr.contains("127.0.0.2:10911"));
-        assertEquals("createTopic", topicConfigArgumentCaptor.getValue().getTopicName());
-        assertEquals(7, topicConfigArgumentCaptor.getValue().getWriteQueueNums());
-        assertEquals(8, topicConfigArgumentCaptor.getValue().getReadQueueNums());
-    }
-
-    private TopicRouteData createTopicRouteData(int brokerNum) {
-        TopicRouteData topicRouteData = new TopicRouteData();
-        for (int i = 0; i < brokerNum; i++) {
-            BrokerData brokerData = new BrokerData();
-            HashMap<Long, String> addrMap = new HashMap<>();
-            addrMap.put(0L, "127.0.0." + (i + 1) + ":10911");
-            brokerData.setBrokerAddrs(addrMap);
-            brokerData.setBrokerName("broker-" + i);
-            brokerData.setCluster("cluster");
-            topicRouteData.getBrokerDatas().add(brokerData);
+        when(mqClientAPIExt.getMqClientAdmin()).thenReturn(mqClientAdmin);
+        adminService = new DefaultAdminService(mqClientAPIFactory);
+    }
+
+    @After
+    public void tearDown() {
+        adminService.shutdown();
+    }
+
+    private static <T> T get(CompletableFuture<T> future) throws Exception {
+        return future.get(GET_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+    }
+
+    @Test
+    public void getBrokerClusterInfoQueriesNameserverTest() throws Exception {
+        ClusterInfo clusterInfo = new ClusterInfo();
+        when(mqClientAdmin.getBrokerClusterInfo(isNull(), eq(TIMEOUT)))
+            .thenReturn(CompletableFuture.completedFuture(clusterInfo));
+
+        assertSame(clusterInfo, get(adminService.getBrokerClusterInfo(TIMEOUT)));
+    }
+
+    @Test
+    public void getTopicRouteDataDelegatesToNameserverTest() throws Exception {
+        TopicRouteData routeData = new TopicRouteData();
+        routeData.setOrderTopicConf("orderConf");
+        when(mqClientAPIExt.getTopicRouteInfoFromNameServer(eq(TOPIC), anyLong())).thenReturn(routeData);
+
+        TopicRouteData result = get(adminService.getTopicRouteData(TOPIC));
+
+        assertNotNull(result);
+        assertEquals("orderConf", result.getOrderTopicConf());
+    }
+
+    @Test
+    public void getTopicRouteDataWrapsFailuresTest() throws Exception {
+        when(mqClientAPIExt.getTopicRouteInfoFromNameServer(eq(TOPIC), anyLong()))
+            .thenThrow(new MQClientException("boom", null));
+
+        try {
+            get(adminService.getTopicRouteData(TOPIC));
+            fail("expected getTopicRouteData to fail");
+        } catch (ExecutionException e) {
+            Throwable cause = e.getCause();
+            while (cause != null && !(cause instanceof MQClientException)) {
+                cause = cause.getCause();
+            }
+            assertNotNull(cause);
+            assertTrue(cause.getMessage().contains("boom"));
         }
-        return topicRouteData;
     }
-}
\ No newline at end of file
+
+    @Test
+    public void getTopicConfigRunsBlockingCallOnExecutorTest() throws Exception {
+        TopicConfigAndQueueMapping topicConfig = new TopicConfigAndQueueMapping();
+        topicConfig.setTopicName(TOPIC);
+        topicConfig.setReadQueueNums(8);
+        when(mqClientAPIExt.getTopicConfig(ADDR, TOPIC, TIMEOUT)).thenReturn(topicConfig);
+
+        TopicConfig result = get(adminService.getTopicConfig(ADDR, TOPIC, TIMEOUT));
+
+        assertSame(topicConfig, result);
+        assertEquals(8, result.getReadQueueNums());
+    }
+
+    @Test
+    public void getTopicStatsBuildsHeaderTest() throws Exception {
+        TopicStatsTable statsTable = new TopicStatsTable();
+        when(mqClientAdmin.getTopicStatsInfo(eq(ADDR), any(GetTopicStatsInfoRequestHeader.class), eq(TIMEOUT)))
+            .thenReturn(CompletableFuture.completedFuture(statsTable));
+
+        assertSame(statsTable, get(adminService.getTopicStats(ADDR, TOPIC, TIMEOUT)));
+
+        ArgumentCaptor<GetTopicStatsInfoRequestHeader> captor =
+            ArgumentCaptor.forClass(GetTopicStatsInfoRequestHeader.class);
+        verify(mqClientAdmin).getTopicStatsInfo(eq(ADDR), captor.capture(), eq(TIMEOUT));
+        assertEquals(TOPIC, captor.getValue().getTopic());
+    }
+
+    @Test
+    public void getConsumeStatsBuildsHeaderTest() throws Exception {
+        ConsumeStats consumeStats = new ConsumeStats();
+        when(mqClientAdmin.getConsumeStats(eq(ADDR), any(GetConsumeStatsRequestHeader.class), eq(TIMEOUT)))
+            .thenReturn(CompletableFuture.completedFuture(consumeStats));
+
+        assertSame(consumeStats, get(adminService.getConsumeStats(ADDR, GROUP, TOPIC, TIMEOUT)));
+        // a blank topic means "every topic of the group" and must reach the broker as ""
+        assertSame(consumeStats, get(adminService.getConsumeStats(ADDR, GROUP, null, TIMEOUT)));
+
+        ArgumentCaptor<GetConsumeStatsRequestHeader> captor =
+            ArgumentCaptor.forClass(GetConsumeStatsRequestHeader.class);
+        verify(mqClientAdmin, times(2))
+            .getConsumeStats(eq(ADDR), captor.capture(), eq(TIMEOUT));
+        List<GetConsumeStatsRequestHeader> headers = captor.getAllValues();
+        assertEquals(GROUP, headers.get(0).getConsumerGroup());
+        assertEquals(TOPIC, headers.get(0).getTopic());
+        assertEquals(GROUP, headers.get(1).getConsumerGroup());
+        assertEquals("", headers.get(1).getTopic());
+    }
+
+    @Test
+    public void queryConsumeTimeSpanBuildsHeaderTest() throws Exception {
+        List<QueueTimeSpan> spans = Collections.singletonList(new QueueTimeSpan());
+        when(mqClientAdmin.queryConsumeTimeSpan(eq(ADDR), any(QueryConsumeTimeSpanRequestHeader.class), eq(TIMEOUT)))
+            .thenReturn(CompletableFuture.completedFuture(spans));
+
+        assertSame(spans, get(adminService.queryConsumeTimeSpan(ADDR, TOPIC, GROUP, TIMEOUT)));
+
+        ArgumentCaptor<QueryConsumeTimeSpanRequestHeader> captor =
+            ArgumentCaptor.forClass(QueryConsumeTimeSpanRequestHeader.class);
+        verify(mqClientAdmin).queryConsumeTimeSpan(eq(ADDR), captor.capture(), eq(TIMEOUT));
+        assertEquals(TOPIC, captor.getValue().getTopic());
+        assertEquals(GROUP, captor.getValue().getGroup());
+    }
+
+    @Test
+    public void resetOffsetBuildsHeaderTest() throws Exception {
+        Map<MessageQueue, Long> offsets = Collections.singletonMap(new MessageQueue(TOPIC, "broker-a", 0), 42L);
+        when(mqClientAdmin.invokeBrokerToResetOffset(eq(ADDR), any(ResetOffsetRequestHeader.class), eq(TIMEOUT)))
+            .thenReturn(CompletableFuture.completedFuture(offsets));
+
+        Map<MessageQueue, Long> result =
+            get(adminService.resetOffset(ADDR, TOPIC, GROUP, 1234L, true, TIMEOUT));
+
+        assertEquals(42L, result.get(new MessageQueue(TOPIC, "broker-a", 0)).longValue());
+        ArgumentCaptor<ResetOffsetRequestHeader> captor = ArgumentCaptor.forClass(ResetOffsetRequestHeader.class);
+        verify(mqClientAdmin).invokeBrokerToResetOffset(eq(ADDR), captor.capture(), eq(TIMEOUT));
+        assertEquals(TOPIC, captor.getValue().getTopic());
+        assertEquals(GROUP, captor.getValue().getGroup());
+        assertEquals(1234L, captor.getValue().getTimestamp());
+        assertTrue(captor.getValue().isForce());
+    }
+
+    @Test
+    public void getConsumerConnectionListBuildsHeaderTest() throws Exception {
+        ConsumerConnection connection = new ConsumerConnection();
+        when(mqClientAdmin.getConsumerConnectionList(eq(ADDR),
+            any(GetConsumerConnectionListRequestHeader.class), eq(TIMEOUT)))
+            .thenReturn(CompletableFuture.completedFuture(connection));
+
+        assertSame(connection, get(adminService.getConsumerConnectionList(ADDR, GROUP, TIMEOUT)));
+
+        ArgumentCaptor<GetConsumerConnectionListRequestHeader> captor =
+            ArgumentCaptor.forClass(GetConsumerConnectionListRequestHeader.class);
+        verify(mqClientAdmin).getConsumerConnectionList(eq(ADDR), captor.capture(), eq(TIMEOUT));
+        assertEquals(GROUP, captor.getValue().getConsumerGroup());
+    }
+
+    @Test
+    public void queryTopicConsumeByWhoBuildsHeaderTest() throws Exception {
+        GroupList groupList = new GroupList();
+        when(mqClientAdmin.queryTopicConsumeByWho(eq(ADDR), any(QueryTopicConsumeByWhoRequestHeader.class),
+            eq(TIMEOUT))).thenReturn(CompletableFuture.completedFuture(groupList));
+
+        assertSame(groupList, get(adminService.queryTopicConsumeByWho(ADDR, TOPIC, TIMEOUT)));
+
+        ArgumentCaptor<QueryTopicConsumeByWhoRequestHeader> captor =
+            ArgumentCaptor.forClass(QueryTopicConsumeByWhoRequestHeader.class);
+        verify(mqClientAdmin).queryTopicConsumeByWho(eq(ADDR), captor.capture(), eq(TIMEOUT));
+        assertEquals(TOPIC, captor.getValue().getTopic());
+    }
+
+    @Test
+    public void queryTopicsByConsumerBuildsHeaderTest() throws Exception {
+        TopicList topicList = new TopicList();
+        when(mqClientAdmin.queryTopicsByConsumer(eq(ADDR), any(QueryTopicsByConsumerRequestHeader.class),
+            eq(TIMEOUT))).thenReturn(CompletableFuture.completedFuture(topicList));
+
+        assertSame(topicList, get(adminService.queryTopicsByConsumer(ADDR, GROUP, TIMEOUT)));
+
+        ArgumentCaptor<QueryTopicsByConsumerRequestHeader> captor =
+            ArgumentCaptor.forClass(QueryTopicsByConsumerRequestHeader.class);
+        verify(mqClientAdmin).queryTopicsByConsumer(eq(ADDR), captor.capture(), eq(TIMEOUT));
+        assertEquals(GROUP, captor.getValue().getGroup());
+    }
+
+    @Test
+    public void getSubscriptionGroupConfigRunsBlockingCallOnExecutorTest() throws Exception {
+        SubscriptionGroupConfig config = new SubscriptionGroupConfig();
+        config.setGroupName(GROUP);
+        when(mqClientAPIExt.getSubscriptionGroupConfig(ADDR, GROUP, TIMEOUT)).thenReturn(config);
+
+        SubscriptionGroupConfig result = get(adminService.getSubscriptionGroupConfig(ADDR, GROUP, TIMEOUT));
+
+        assertSame(config, result);
+    }
+
+    @Test
+    public void updateSubscriptionGroupConfigDelegatesTest() throws Exception {
+        SubscriptionGroupConfig config = new SubscriptionGroupConfig();
+        config.setGroupName(GROUP);
+        when(mqClientAdmin.updateOrCreateSubscriptionGroup(ADDR, config, TIMEOUT))
+            .thenReturn(CompletableFuture.completedFuture(null));
+
+        get(adminService.updateSubscriptionGroupConfig(ADDR, config, TIMEOUT));
+
+        verify(mqClientAdmin).updateOrCreateSubscriptionGroup(ADDR, config, TIMEOUT);
+    }
+
+    @Test
+    public void deleteSubscriptionGroupBuildsHeaderTest() throws Exception {
+        when(mqClientAdmin.deleteSubscriptionGroup(eq(ADDR), any(DeleteSubscriptionGroupRequestHeader.class),
+            eq(TIMEOUT))).thenReturn(CompletableFuture.completedFuture(null));
+
+        get(adminService.deleteSubscriptionGroup(ADDR, GROUP, true, TIMEOUT));
+
+        ArgumentCaptor<DeleteSubscriptionGroupRequestHeader> captor =
+            ArgumentCaptor.forClass(DeleteSubscriptionGroupRequestHeader.class);
+        verify(mqClientAdmin).deleteSubscriptionGroup(eq(ADDR), captor.capture(), eq(TIMEOUT));
+        assertEquals(GROUP, captor.getValue().getGroupName());
+        assertTrue(captor.getValue().isCleanOffset());
+    }
+
+    @Test
+    public void queryMessagePassesUniqueKeyAndDecompressFlagsTest() throws Exception {
+        List<MessageExt> messages = Collections.singletonList(new MessageExt());
+        when(mqClientAdmin.queryMessage(eq(ADDR), anyBoolean(), anyBoolean(),
+            any(QueryMessageRequestHeader.class), eq(TIMEOUT)))
+            .thenReturn(CompletableFuture.completedFuture(messages));
+
+        // uniqueKey=true selects the client-generated unique-key index; header fields carried through
+        assertSame(messages,
+            get(adminService.queryMessage(ADDR, TOPIC, "UNIQ-1", 10, 100L, 200L, true, true, TIMEOUT)));
+        ArgumentCaptor<QueryMessageRequestHeader> captor = ArgumentCaptor.forClass(QueryMessageRequestHeader.class);
+        verify(mqClientAdmin).queryMessage(eq(ADDR), eq(true), eq(true), captor.capture(), eq(TIMEOUT));
+        QueryMessageRequestHeader header = captor.getValue();
+        assertEquals(TOPIC, header.getTopic());
+        assertEquals("UNIQ-1", header.getKey());
+        assertEquals(10, header.getMaxNum().intValue());
+        assertEquals(100L, header.getBeginTimestamp().longValue());
+        assertEquals(200L, header.getEndTimestamp().longValue());
+
+        // uniqueKey=false matches against the message keys index instead
+        get(adminService.queryMessage(ADDR, TOPIC, "key-1", 10, 0L, System.currentTimeMillis(), false, true, TIMEOUT));
+        verify(mqClientAdmin).queryMessage(eq(ADDR), eq(false), eq(true),
+            any(QueryMessageRequestHeader.class), eq(TIMEOUT));
+
+        // decompressBody=false: VerifyMessage relays the message, so it wants the body as stored
+        get(adminService.queryMessage(ADDR, TOPIC, "UNIQ-1", 1, 0L, Long.MAX_VALUE, true, false, TIMEOUT));
+        verify(mqClientAdmin).queryMessage(eq(ADDR), eq(true), eq(false),
+            any(QueryMessageRequestHeader.class), eq(TIMEOUT));
+    }
+
+    @Test
+    public void viewMessageBuildsHeaderTest() throws Exception {
+        MessageExt messageExt = new MessageExt();
+        messageExt.setMsgId("m1");
+        when(mqClientAdmin.viewMessage(eq(ADDR), any(ViewMessageRequestHeader.class), eq(TIMEOUT)))
+            .thenReturn(CompletableFuture.completedFuture(messageExt));
+
+        MessageExt result = get(adminService.viewMessage(ADDR, TOPIC, 12345L, TIMEOUT));
+
+        assertEquals("m1", result.getMsgId());
+        ArgumentCaptor<ViewMessageRequestHeader> captor = ArgumentCaptor.forClass(ViewMessageRequestHeader.class);
+        verify(mqClientAdmin).viewMessage(eq(ADDR), captor.capture(), eq(TIMEOUT));
+        assertEquals(TOPIC, captor.getValue().getTopic());
+        assertEquals(12345L, captor.getValue().getOffset().longValue());
+    }
+
+    @Test
+    public void searchOffsetByTimestampRunsBlockingCallOnExecutorTest() throws Exception {
+        MessageQueue messageQueue = new MessageQueue(TOPIC, "broker-a", 0);
+        when(mqClientAPIExt.searchOffset(ADDR, messageQueue, 1234L, TIMEOUT)).thenReturn(42L);
+
+        Long result = get(adminService.searchOffsetByTimestamp(ADDR, messageQueue, 1234L, TIMEOUT));
+
+        assertEquals(42L, result.longValue());
+    }
+
+    @Test
+    public void topicExistReflectsRouteLookupTest() throws Exception {
+        // a resolvable route means the topic exists
+        when(mqClientAPIExt.getTopicRouteInfoFromNameServer(eq(TOPIC), anyLong())).thenReturn(new TopicRouteData());
+        assertTrue(adminService.topicExist(TOPIC));
+
+        // a route lookup failure is treated as "does not exist"
+        when(mqClientAPIExt.getTopicRouteInfoFromNameServer(anyString(), anyLong()))
+            .thenThrow(new MQClientException("No route info of this topic", null));
+        assertFalse(adminService.topicExist(TOPIC));
+    }
+}
diff --git a/rocketmq-apis b/rocketmq-apis
new file mode 160000
index 0000000000..3e60073c6d
--- /dev/null
+++ b/rocketmq-apis
@@ -0,0 +1 @@
+Subproject commit 3e60073c6dab3430feaec443824802e23ecda681
diff --git a/rocketmq-proto/BUILD.bazel b/rocketmq-proto/BUILD.bazel
new file mode 100644
index 0000000000..3d6108fd38
--- /dev/null
+++ b/rocketmq-proto/BUILD.bazel
@@ -0,0 +1,92 @@
+# 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.
+
+# Protocol classes generated at build time from the rocketmq-apis git submodule.
+#
+# The admin contract (admin.proto) is not part of any rocketmq-proto
+# artifact published to Maven Central, so the Java + gRPC classes are generated
+# here from the submodule's .proto sources rather than resolved as a Maven
+# artifact. This mirrors what the rocketmq-proto Maven module does for the Maven
+# build.
+
+genrule(
+    name = "rocketmq_proto_srcjar",
+    srcs = [
+        "@com_google_protobuf_wkt//:wkt_protos",
+        "@rocketmq_apis//:v2_protos",
+    ],
+    tools = [
+        "@com_google_protobuf_protoc_linux_x86_64//file",
+        "@com_google_protobuf_protoc_osx_x86_64//file",
+        "@io_grpc_protoc_gen_grpc_java_linux_x86_64//file",
+        "@io_grpc_protoc_gen_grpc_java_osx_x86_64//file",
+    ],
+    outs = ["rocketmq_proto.srcjar"],
+    cmd = """
+        set -e
+        case "$$(uname -s)" in
+          Linux)
+            PROTOC="$$(pwd)/$(location @com_google_protobuf_protoc_linux_x86_64//file)"
+            PLUGIN="$$(pwd)/$(location @io_grpc_protoc_gen_grpc_java_linux_x86_64//file)"
+            ;;
+          Darwin)
+            PROTOC="$$(pwd)/$(location @com_google_protobuf_protoc_osx_x86_64//file)"
+            PLUGIN="$$(pwd)/$(location @io_grpc_protoc_gen_grpc_java_osx_x86_64//file)"
+            ;;
+          *)
+            echo "unsupported platform for proto codegen: $$(uname -s)" >&2
+            exit 1
+            ;;
+        esac
+        PT="$$(pwd)/$(@D)/proto_tree"
+        JAVA_OUT="$$(pwd)/$(@D)/java_out"
+        OUT="$$(pwd)/$@"
+        rm -rf $$PT $$JAVA_OUT
+        mkdir -p $$PT/apache/rocketmq/v2 $$PT/google/protobuf $$JAVA_OUT
+        for f in $(SRCS); do
+            case "$$f" in
+                */apache/rocketmq/v2/*.proto) cp "$$f" $$PT/apache/rocketmq/v2/ ;;
+                *) cp "$$f" $$PT/google/protobuf/ ;;
+            esac
+        done
+        $$PROTOC \
+            --plugin=protoc-gen-grpc-java=$$PLUGIN \
+            -I$$PT \
+            --java_out=$$JAVA_OUT \
+            --grpc-java_out=$$JAVA_OUT \
+            $$PT/apache/rocketmq/v2/*.proto
+        if command -v jar > /dev/null 2>&1; then
+            (cd $$JAVA_OUT && jar cf $$OUT .)
+        else
+            (cd $$JAVA_OUT && zip -qr $$OUT .)
+        fi
+    """,
+)
+
+java_library(
+    name = "rocketmq-proto",
+    srcs = [":rocketmq_proto_srcjar"],
+    visibility = ["//visibility:public"],
+    deps = [
+        "@maven//:com_google_guava_guava",
+        "@maven//:com_google_protobuf_protobuf_java",
+        "@maven//:io_grpc_grpc_api",
+        "@maven//:io_grpc_grpc_protobuf",
+        "@maven//:io_grpc_grpc_stub",
+        "@maven//:javax_annotation_javax_annotation_api",
+    ],
+)
diff --git a/rocketmq-proto/pom.xml b/rocketmq-proto/pom.xml
new file mode 100644
index 0000000000..cfb094236c
--- /dev/null
+++ b/rocketmq-proto/pom.xml
@@ -0,0 +1,143 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+  ~ 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.
+  -->
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <parent>
+        <artifactId>rocketmq-all</artifactId>
+        <groupId>org.apache.rocketmq</groupId>
+        <version>${revision}</version>
+    </parent>
+
+    <modelVersion>4.0.0</modelVersion>
+    <packaging>jar</packaging>
+
+    <artifactId>rocketmq-proto</artifactId>
+    <name>rocketmq-proto ${project.version}</name>
+    <description>Apache RocketMQ protocol APIs. Generated at build time from the rocketmq-apis
+        git submodule (apache/rocketmq/v2/*.proto), so the admin surface is consumed from
+        source rather than from a published rocketmq-proto artifact.</description>
+
+    <properties>
+        <maven.compiler.source>8</maven.compiler.source>
+        <maven.compiler.target>8</maven.compiler.target>
+        <!-- generated code: skip static analysis / javadoc -->
+        <spotbugs.skip>true</spotbugs.skip>
+        <maven.javadoc.skip>true</maven.javadoc.skip>
+        <checkstyle.skip>true</checkstyle.skip>
+    </properties>
+
+    <dependencies>
+        <dependency>
+            <groupId>com.google.protobuf</groupId>
+            <artifactId>protobuf-java</artifactId>
+            <version>${protobuf.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>io.grpc</groupId>
+            <artifactId>grpc-api</artifactId>
+            <version>${grpc.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>io.grpc</groupId>
+            <artifactId>grpc-core</artifactId>
+            <version>${grpc.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>io.grpc</groupId>
+            <artifactId>grpc-protobuf</artifactId>
+            <version>${grpc.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>io.grpc</groupId>
+            <artifactId>grpc-stub</artifactId>
+            <version>${grpc.version}</version>
+        </dependency>
+        <!-- javax.annotation.Generated is referenced by the generated gRPC code
+             and only exists in the JDK 8 runtime -->
+        <dependency>
+            <groupId>javax.annotation</groupId>
+            <artifactId>javax.annotation-api</artifactId>
+            <version>1.3.2</version>
+            <scope>provided</scope>
+        </dependency>
+        <!-- generation-time only: google/protobuf/{timestamp,duration}.proto imports -->
+        <dependency>
+            <groupId>com.google.api.grpc</groupId>
+            <artifactId>proto-google-common-protos</artifactId>
+            <version>2.9.0</version>
+            <scope>provided</scope>
+            <exclusions>
+                <!-- supplied directly at ${protobuf.version}; avoid convergence conflict -->
+                <exclusion>
+                    <groupId>com.google.protobuf</groupId>
+                    <artifactId>protobuf-java</artifactId>
+                </exclusion>
+            </exclusions>
+        </dependency>
+    </dependencies>
+
+    <build>
+        <extensions>
+            <extension>
+                <groupId>kr.motd.maven</groupId>
+                <artifactId>os-maven-plugin</artifactId>
+                <version>1.7.1</version>
+            </extension>
+        </extensions>
+        <plugins>
+            <!-- Generated at build time from the rocketmq-apis submodule. This module only
+                 participates in the reactor build and is NOT published to Maven Central:
+                 the org.apache.rocketmq:rocketmq-proto coordinate is owned by the
+                 rocketmq-apis repository (2.x versions), and deploying it here under
+                 ${revision} (e.g. 5.5.1) would collide with that coordinate. -->
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-deploy-plugin</artifactId>
+                <configuration>
+                    <skip>true</skip>
+                </configuration>
+            </plugin>
+            <plugin>
+                <groupId>org.xolstice.maven.plugins</groupId>
+                <artifactId>protobuf-maven-plugin</artifactId>
+                <version>0.6.1</version>
+                <configuration>
+                    <!-- proto sources come from the rocketmq-apis git submodule -->
+                    <protoSourceRoot>${project.basedir}/../rocketmq-apis</protoSourceRoot>
+                    <includes>
+                        <include>apache/rocketmq/v2/definition.proto</include>
+                        <include>apache/rocketmq/v2/service.proto</include>
+                        <include>apache/rocketmq/v2/admin.proto</include>
+                    </includes>
+                    <protocArtifact>com.google.protobuf:protoc:${protobuf.version}:exe:${os.detected.classifier}</protocArtifact>
+                    <pluginId>grpc-java</pluginId>
+                    <pluginArtifact>io.grpc:protoc-gen-grpc-java:${grpc.version}:exe:${os.detected.classifier}</pluginArtifact>
+                </configuration>
+                <executions>
+                    <execution>
+                        <goals>
+                            <goal>compile</goal>
+                            <goal>compile-custom</goal>
+                        </goals>
+                    </execution>
+                </executions>
+            </plugin>
+        </plugins>
+    </build>
+</project>
diff --git a/test/BUILD.bazel b/test/BUILD.bazel
index eb3e079596..37510e23a6 100644
--- a/test/BUILD.bazel
+++ b/test/BUILD.bazel
@@ -80,7 +80,7 @@ java_library(
         "@maven//:io_grpc_grpc_testing",
         "@maven//:io_netty_netty_all",
         "@maven//:org_apache_commons_commons_lang3",
-        "@maven//:org_apache_rocketmq_rocketmq_proto",
+        "//rocketmq-proto:rocketmq-proto",
         "@maven//:io_github_aliyunmq_rocketmq_slf4j_api",
     ],
 )