feature: support secret reference for Redis password in MCP Server (#3006)

Co-authored-by: 澄潭 <zty98751@alibaba-inc.com>
This commit is contained in:
SaladDay
2025-10-27 13:33:52 +08:00
committed by GitHub
parent 7c4899ad38
commit 1bcef0c00c
7 changed files with 325 additions and 8 deletions

View File

@@ -96,6 +96,17 @@ Ingress Config 包含 6 个控制器,各自负责不同的功能:
- Http2Rpc Controller监听 Http2Rpc 资源,实现 HTTP 协议到 RPC 协议的转换。用户可以通过配置协议转换,将 RPC 服务以 HTTP 接口的形式暴露,从而使用 HTTP 请求调用 RPC 接口。
- WasmPlugin Controller监听 WasmPlugin 资源,将 Higress WasmPlugin 转化为 Istio WasmPlugin。Higress WasmPlugin 在 Istio WasmPlugin 的基础上进行了扩展,支持全局、路由、域名、服务级别的配置。
- ConfigmapMgr监听 Higress 的全局配置 `higress-config` ConfigMap可以根据 tracing、gzip 等配置构造 EnvoyFilter。
`mcpServer.redis` 支持通过 Secret 引用保存敏感信息,密码字段可以使用 `passwordSecret` 指向 `higress-system` 命名空间下的 Kubernetes Secret避免在 ConfigMap 中保存明文密码,例如:
```yaml
higress: |-
mcpServer:
redis:
address: "redis:6379"
passwordSecret:
name: redis-credentials
key: password
```
#### 2.2.2 Cert Server
@@ -140,4 +151,4 @@ Envoy 核心架构如下图:
- [1] [Istio Pilot 组件介绍](https://www.zhaohuabing.com/post/2019-10-21-pilot-discovery-code-analysis/)
- [2] [Istio 服务注册插件机制代码解析](https://www.zhaohuabing.com/post/2019-02-18-pilot-service-registry-code-analysis/)
- [3] [Istio Pilot代码深度解析](https://www.zhaohuabing.com/post/2019-10-21-pilot-discovery-code-analysis/)
- [4] [Envoy 官方文档](https://www.envoyproxy.io/docs/envoy/latest/intro/intro)
- [4] [Envoy 官方文档](https://www.envoyproxy.io/docs/envoy/latest/intro/intro)

View File

@@ -39,10 +39,22 @@ type RedisConfig struct {
Username string `json:"username,omitempty"`
// The password for Redis authentication
Password string `json:"password,omitempty"`
// Reference to a secret containing the password
PasswordSecret *SecretKeyReference `json:"passwordSecret,omitempty"`
// The database index to use
DB int `json:"db,omitempty"`
}
// SecretKeyReference defines a reference to a key within a Kubernetes secret
type SecretKeyReference struct {
// The namespace of the secret. Defaults to the higress system namespace.
Namespace string `json:"namespace,omitempty"`
// The name of the secret
Name string `json:"name,omitempty"`
// The key within the secret data
Key string `json:"key,omitempty"`
}
// MCPRatelimitConfig defines the configuration for rate limit
type MCPRatelimitConfig struct {
// The limit of the rate limit
@@ -119,6 +131,15 @@ func validMcpServer(m *McpServer) error {
return nil
}
if m.Redis != nil && m.Redis.PasswordSecret != nil {
if m.Redis.PasswordSecret.Name == "" {
return errors.New("redis passwordSecret.name cannot be empty")
}
if m.Redis.PasswordSecret.Key == "" {
return errors.New("redis passwordSecret.key cannot be empty")
}
}
if m.EnableUserLevelServer && m.Redis == nil {
return errors.New("redis config cannot be empty when user level server is enabled")
}
@@ -184,6 +205,13 @@ func deepCopyMcpServer(mcp *McpServer) (*McpServer, error) {
Password: mcp.Redis.Password,
DB: mcp.Redis.DB,
}
if mcp.Redis.PasswordSecret != nil {
newMcp.Redis.PasswordSecret = &SecretKeyReference{
Namespace: mcp.Redis.PasswordSecret.Namespace,
Name: mcp.Redis.PasswordSecret.Name,
Key: mcp.Redis.PasswordSecret.Key,
}
}
}
if mcp.Ratelimit != nil {
newMcp.Ratelimit = &MCPRatelimitConfig{
@@ -504,12 +532,24 @@ func (m *McpServerController) constructMcpSessionStruct(mcp *McpServer) string {
// Build redis configuration
redisConfig := "null"
if mcp.Redis != nil {
passwordValue := mcp.Redis.Password
if mcp.Redis.PasswordSecret != nil && mcp.Redis.PasswordSecret.Name != "" && mcp.Redis.PasswordSecret.Key != "" {
ns := mcp.Redis.PasswordSecret.Namespace
if ns == "" {
ns = m.Namespace
}
if ns != "" {
passwordValue = fmt.Sprintf("${secret.%s/%s.%s}", ns, mcp.Redis.PasswordSecret.Name, mcp.Redis.PasswordSecret.Key)
} else {
passwordValue = fmt.Sprintf("${secret.%s.%s}", mcp.Redis.PasswordSecret.Name, mcp.Redis.PasswordSecret.Key)
}
}
redisConfig = fmt.Sprintf(`{
"address": "%s",
"username": "%s",
"password": "%s",
"db": %d
}`, mcp.Redis.Address, mcp.Redis.Username, mcp.Redis.Password, mcp.Redis.DB)
}`, mcp.Redis.Address, mcp.Redis.Username, passwordValue, mcp.Redis.DB)
}
// Build rate limit configuration

View File

@@ -120,6 +120,30 @@ func Test_validMcpServer(t *testing.T) {
},
wantErr: errors.New("redis config cannot be empty when user level server is enabled"),
},
{
name: "redis config with password secret missing name",
mcp: &McpServer{
Enable: true,
Redis: &RedisConfig{
PasswordSecret: &SecretKeyReference{
Key: "password",
},
},
},
wantErr: errors.New("redis passwordSecret.name cannot be empty"),
},
{
name: "redis config with password secret missing key",
mcp: &McpServer{
Enable: true,
Redis: &RedisConfig{
PasswordSecret: &SecretKeyReference{
Name: "redis-credentials",
},
},
},
wantErr: errors.New("redis passwordSecret.key cannot be empty"),
},
{
name: "valid config with redis",
mcp: &McpServer{
@@ -152,6 +176,20 @@ func Test_validMcpServer(t *testing.T) {
},
wantErr: nil,
},
{
name: "valid config with redis password secret",
mcp: &McpServer{
Enable: true,
Redis: &RedisConfig{
Address: "localhost:6379",
PasswordSecret: &SecretKeyReference{
Name: "redis-credentials",
Key: "password",
},
},
},
wantErr: nil,
},
}
for _, tt := range tests {
@@ -265,7 +303,11 @@ func Test_deepCopyMcpServer(t *testing.T) {
Address: "localhost:6379",
Username: "default",
Password: "password",
DB: 0,
PasswordSecret: &SecretKeyReference{
Name: "redis-credentials",
Key: "password",
},
DB: 0,
},
MatchList: []*MatchRule{},
Servers: []*SSEServer{},
@@ -276,7 +318,11 @@ func Test_deepCopyMcpServer(t *testing.T) {
Address: "localhost:6379",
Username: "default",
Password: "password",
DB: 0,
PasswordSecret: &SecretKeyReference{
Name: "redis-credentials",
Key: "password",
},
DB: 0,
},
MatchList: []*MatchRule{},
Servers: []*SSEServer{},
@@ -291,7 +337,12 @@ func Test_deepCopyMcpServer(t *testing.T) {
Address: "localhost:6379",
Username: "default",
Password: "password",
DB: 0,
PasswordSecret: &SecretKeyReference{
Name: "redis-credentials",
Namespace: "custom-ns",
Key: "password",
},
DB: 0,
},
SSEPathSuffix: "/sse",
MatchList: []*MatchRule{
@@ -318,7 +369,12 @@ func Test_deepCopyMcpServer(t *testing.T) {
Address: "localhost:6379",
Username: "default",
Password: "password",
DB: 0,
PasswordSecret: &SecretKeyReference{
Name: "redis-credentials",
Namespace: "custom-ns",
Key: "password",
},
DB: 0,
},
SSEPathSuffix: "/sse",
MatchList: []*MatchRule{
@@ -706,6 +762,80 @@ func TestMcpServerController_constructMcpSessionStruct(t *testing.T) {
}
}`,
},
{
name: "config with password secret",
mcp: &McpServer{
Enable: true,
Redis: &RedisConfig{
Address: "localhost:6379",
Password: "ignored",
PasswordSecret: &SecretKeyReference{
Name: "redis-credentials",
Key: "password",
},
},
MatchList: []*MatchRule{},
Servers: []*SSEServer{},
},
wantJSON: `{
"@type": "type.googleapis.com/envoy.extensions.filters.http.golang.v3alpha.Config",
"library_id": "mcp-session",
"library_path": "/var/lib/istio/envoy/golang-filter.so",
"plugin_name": "mcp-session",
"plugin_config": {
"@type": "type.googleapis.com/xds.type.v3.TypedStruct",
"value": {
"redis": {
"address": "localhost:6379",
"username": "",
"password": "${secret.test-namespace/redis-credentials.password}",
"db": 0
},
"rate_limit": null,
"sse_path_suffix": "",
"match_list": [],
"enable_user_level_server": false
}
}
}`,
},
{
name: "config with password secret and namespace",
mcp: &McpServer{
Enable: true,
Redis: &RedisConfig{
Address: "localhost:6379",
PasswordSecret: &SecretKeyReference{
Namespace: "other-ns",
Name: "redis-credentials",
Key: "password",
},
},
MatchList: []*MatchRule{},
Servers: []*SSEServer{},
},
wantJSON: `{
"@type": "type.googleapis.com/envoy.extensions.filters.http.golang.v3alpha.Config",
"library_id": "mcp-session",
"library_path": "/var/lib/istio/envoy/golang-filter.so",
"plugin_name": "mcp-session",
"plugin_config": {
"@type": "type.googleapis.com/xds.type.v3.TypedStruct",
"value": {
"redis": {
"address": "localhost:6379",
"username": "",
"password": "${secret.other-ns/redis-credentials.password}",
"db": 0
},
"rate_limit": null,
"sse_path_suffix": "",
"match_list": [],
"enable_user_level_server": false
}
}
}`,
},
}
for _, tt := range tests {

View File

@@ -57,7 +57,11 @@ data:
redis:
address: redis-stack-server.higress-system.svc.cluster.local:6379 # Redis服务地址
username: "" # Redis用户名可选
password: "" # Redis密码可选
password: "" # Redis密码可选,明文方式
passwordSecret: # 从 Secret 引用密码(推荐,优先级高于 password
name: redis-credentials # Secret 名称
key: password # Secret 中的 key
namespace: higress-system # Secret 所在命名空间(可选,默认为 higress-system
db: 0 # Redis数据库可选
match_list: # MCP Server 会话保持路由规则(当匹配下面路径时,将被识别为一个 MCP 会话,通过 SSE 等机制进行会话保持)
- match_rule_domain: "*"

View File

@@ -57,7 +57,11 @@ data:
redis:
address: redis-stack-server.higress-system.svc.cluster.local:6379 # Redis service address
username: "" # Redis username (optional)
password: "" # Redis password (optional)
password: "" # Redis password (optional, plaintext)
passwordSecret: # Reference password from Secret (recommended, higher priority than password)
name: redis-credentials # Secret name
key: password # Key in Secret
namespace: higress-system # Secret namespace (optional, defaults to higress-system)
db: 0 # Redis database (optional)
match_list: # MCP Server session persistence routing rules (when matching the following paths, it will be recognized as an MCP session and maintained through SSE)
- match_rule_domain: "*"

View File

@@ -0,0 +1,87 @@
// Copyright (c) 2025 Alibaba Group Holding Ltd.
//
// Licensed 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 tests
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/alibaba/higress/v2/pkg/ingress/kube/configmap"
"github.com/alibaba/higress/v2/test/e2e/conformance/utils/envoy"
"github.com/alibaba/higress/v2/test/e2e/conformance/utils/kubernetes"
"github.com/alibaba/higress/v2/test/e2e/conformance/utils/suite"
)
func init() {
Register(ConfigMapMcpRedisSecret)
}
var ConfigMapMcpRedisSecret = suite.ConformanceTest{
ShortName: "ConfigMapMcpRedisSecret",
Description: "Envoy MCP session filter should resolve Redis password from Kubernetes secret and react to updates",
Manifests: []string{"tests/configmap-mcp-redis-secret.yaml"},
Features: []suite.SupportedFeature{suite.EnvoyConfigConformanceFeature},
Test: func(t *testing.T, suite *suite.ConformanceTestSuite) {
const (
configMapNamespace = "higress-system"
configMapName = "higress-config"
configMapKey = "higress"
secretNamespace = "higress-system"
secretName = "redis-credentials"
secretKey = "password"
initialSecretValue = "InitialSecretFromSecret123"
updatedSecretValue = "UpdatedSecretFromSecret456"
)
higressCfg := &configmap.HigressConfig{
McpServer: &configmap.McpServer{
Enable: true,
SSEPathSuffix: "/sse",
Redis: &configmap.RedisConfig{
Address: "redis:6379",
PasswordSecret: &configmap.SecretKeyReference{
Name: secretName,
Key: secretKey,
},
},
},
}
err := kubernetes.ApplyConfigmapDataWithYaml(t, suite.Client, configMapNamespace, configMapName, configMapKey, higressCfg)
require.NoErrorf(t, err, "failed to update %s/%s", configMapNamespace, configMapName)
assertRedisPassword := func(password string) {
envoy.AssertEnvoyConfig(t, suite.TimeoutConfig, envoy.Assertion{
Path: `configs.#(@type=="type.googleapis.com/envoy.admin.v3.EcdsConfigDump").` +
`ecds_filters.#(ecds_filter.name=="golang-filter-mcp-session").` +
`ecds_filter.typed_config.plugin_config.value.redis`,
CheckType: envoy.CheckTypeMatch,
TargetNamespace: configMapNamespace,
ExpectEnvoyConfig: map[string]interface{}{
"password": password,
},
})
}
assertRedisPassword(initialSecretValue)
err = kubernetes.ApplySecret(t, suite.Client, secretNamespace, secretName, secretKey, updatedSecretValue)
require.NoErrorf(t, err, "failed to update %s/%s secret", secretNamespace, secretName)
assertRedisPassword(updatedSecretValue)
},
}

View File

@@ -0,0 +1,41 @@
# Copyright (c) 2025 Alibaba Group Holding Ltd.
#
# Licensed 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.
apiVersion: v1
kind: Secret
metadata:
name: redis-credentials
namespace: higress-system
type: Opaque
stringData:
password: InitialSecretFromSecret123
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: higress-conformance-infra-configmap-mcp-redis-secret-test
namespace: higress-conformance-infra
spec:
ingressClassName: higress
rules:
- host: "mcp-redis-secret.example.com"
http:
paths:
- pathType: Prefix
path: "/mcp"
backend:
service:
name: infra-backend-v3
port:
number: 8080