diff --git a/docs/architecture.md b/docs/architecture.md index 54e206afb..4efdc7c2f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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) \ No newline at end of file +- [4] [Envoy 官方文档](https://www.envoyproxy.io/docs/envoy/latest/intro/intro) diff --git a/pkg/ingress/kube/configmap/mcp_server.go b/pkg/ingress/kube/configmap/mcp_server.go index d674df272..aa1f2e13a 100644 --- a/pkg/ingress/kube/configmap/mcp_server.go +++ b/pkg/ingress/kube/configmap/mcp_server.go @@ -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 diff --git a/pkg/ingress/kube/configmap/mcp_server_test.go b/pkg/ingress/kube/configmap/mcp_server_test.go index efc096795..54d75f942 100644 --- a/pkg/ingress/kube/configmap/mcp_server_test.go +++ b/pkg/ingress/kube/configmap/mcp_server_test.go @@ -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 { diff --git a/plugins/golang-filter/mcp-server/servers/higress/higress-api/README.md b/plugins/golang-filter/mcp-server/servers/higress/higress-api/README.md index 690a35a55..9c1960ad5 100644 --- a/plugins/golang-filter/mcp-server/servers/higress/higress-api/README.md +++ b/plugins/golang-filter/mcp-server/servers/higress/higress-api/README.md @@ -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: "*" diff --git a/plugins/golang-filter/mcp-server/servers/higress/higress-api/README_en.md b/plugins/golang-filter/mcp-server/servers/higress/higress-api/README_en.md index d58a48a8a..340dae1bc 100644 --- a/plugins/golang-filter/mcp-server/servers/higress/higress-api/README_en.md +++ b/plugins/golang-filter/mcp-server/servers/higress/higress-api/README_en.md @@ -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: "*" diff --git a/test/e2e/conformance/tests/configmap-mcp-redis-secret.go b/test/e2e/conformance/tests/configmap-mcp-redis-secret.go new file mode 100644 index 000000000..12dfbfd41 --- /dev/null +++ b/test/e2e/conformance/tests/configmap-mcp-redis-secret.go @@ -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) + }, +} diff --git a/test/e2e/conformance/tests/configmap-mcp-redis-secret.yaml b/test/e2e/conformance/tests/configmap-mcp-redis-secret.yaml new file mode 100644 index 000000000..ee4c3a019 --- /dev/null +++ b/test/e2e/conformance/tests/configmap-mcp-redis-secret.yaml @@ -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