Compare commits

...

25 Commits

Author SHA1 Message Date
johnlanni
707061fb68 release 2.1.1-rc.1 2025-04-14 21:05:00 +08:00
zty98751
3255925bf0 update submodule commit 2025-04-14 20:51:10 +08:00
zty98751
a44f7ef76e update submodule commit 2025-04-14 20:48:42 +08:00
Jingze
c7abfb8aff feat: support config store and redis configuration optional in mcp server (#2035) 2025-04-14 20:52:48 +08:00
johnlanni
ed925ddf84 update amap tools mcp server 2025-04-14 19:41:00 +08:00
johnlanni
1301af4638 remove useless log 2025-04-14 19:14:30 +08:00
johnlanni
de6144439f update all-in-one mcp server 2025-04-14 19:10:02 +08:00
澄潭
e37c4dc286 Fix the issue of traps caused by gc in wasm plugins compiled with go 1.24 (#2054) 2025-04-14 14:46:54 +08:00
小小hao
b8e0baa5ab feat:add GetContextId func for HttpContext (#2043) 2025-04-14 14:40:24 +08:00
Kent Dong
4a157e98e9 fix: Escape asterisk characters in ai-proxy documents (#1999) 2025-04-12 11:14:32 +08:00
澄潭
6af8b17216 Update README.md 2025-04-11 20:07:16 +08:00
Xin Luo
4500b10a42 fix: fix param mapping use %v instead of %s (#2046) 2025-04-11 15:07:46 +08:00
澄潭
c5a86b5298 Update README.md 2025-04-11 14:18:10 +08:00
Xin Luo
36806d9e5c support nacos namespace (#2045) 2025-04-11 14:12:51 +08:00
mamba
d1700009e8 [frontend-gray] 重构业务逻辑,对于微前端和多版本支持更加友好 (#2011) 2025-04-11 10:35:18 +08:00
澄潭
2c3188dad7 Update README.md 2025-04-10 17:32:51 +08:00
澄潭
7d423cddbd Update README.md 2025-04-10 17:29:06 +08:00
澄潭
0e94e1a58a mcp: support amap auto ip detection (#2041) 2025-04-10 17:08:01 +08:00
Se7en
b1307ba97e fix: ai statistics doc (#2040) 2025-04-10 15:38:19 +08:00
Xin Luo
8ae810b01a Feat dynamic tool reset (#2031) 2025-04-09 10:46:36 +08:00
johnlanni
83b38b896c update mcp server readme 2025-04-07 21:06:04 +08:00
johnlanni
1385028f01 update mcp server dependency 2025-04-07 21:04:29 +08:00
littlejian
af663b701a polish translate-readme action (#2020) 2025-04-07 20:23:45 +08:00
DefNed
e5c24a10fb feat: update custom-response plugin to returns different contents for different response status (#2002) 2025-04-06 09:04:40 +08:00
澄潭
ea85ccb694 Update README.md 2025-04-04 13:45:08 +08:00
62 changed files with 2254 additions and 896 deletions

View File

@@ -6,7 +6,7 @@ on:
- "*"
paths:
- 'helm/**'
workflow_dispatch: ~
workflow_dispatch: ~
push:
branches: [ main ]
paths:
@@ -39,7 +39,6 @@ jobs:
rm -f ./helm-docs
translate-readme:
if: ${{ ! always() }}
needs: helm
runs-on: ubuntu-latest
@@ -52,7 +51,26 @@ jobs:
sudo apt-get update
sudo apt-get install -y jq
- name: Compare README.md
id: compare_readme
run: |
cd ./helm/higress
BASE_BRANCH=main
UPSTREAM_REPO=https://github.com/alibaba/higress.git
TEMP_DIR=$(mktemp -d)
git clone --depth 1 --branch $BASE_BRANCH $UPSTREAM_REPO $TEMP_DIR
if diff -q "$TEMP_DIR/README.md" README.md > /dev/null; then
echo "README.md has no changes in comparison to base branch. Skipping translation."
echo "skip_translation=true" >> $GITHUB_ENV
else
echo "README.md has changed in comparison to base branch. Proceeding with translation."
echo "skip_translation=false" >> $GITHUB_ENV
fi
- name: Translate README.md to Chinese
if: env.skip_translation == 'false'
env:
API_URL: ${{ secrets.HIGRESS_OPENAI_API_URL }}
API_KEY: ${{ secrets.HIGRESS_OPENAI_API_KEY }}
@@ -79,37 +97,30 @@ jobs:
-H "Authorization: Bearer $API_KEY" \
-d "$PAYLOAD")
echo "response: $RESPONSE"
echo "Response: $RESPONSE"
TRANSLATED_CONTENT=$(echo "$RESPONSE" | jq -r '.choices[0].message.content')
echo "$RESPONSE" | jq -c -r '.choices[] | .message.content' > README.zh.new.md
if [ -z "$TRANSLATED_CONTENT" ]; then
echo "Translation failed! Response: $RESPONSE"
if [ -f "README.zh.new.md" ]; then
echo "Translation completed and saved to README.zh.new.md."
else
echo "Translation failed or no content returned!"
exit 1
fi
echo "$TRANSLATED_CONTENT" > README.zh.new.md
echo "Translation completed and saved to README.zh.new.md."
mv README.zh.new.md README.zh.md
- name: Compare README.zh.md
id: compare
run: |
cd ./helm/higress
NEW_README_ZH="README.zh.new.md"
EXISTING_README_ZH="README.zh.md"
- name: Create Pull Request
if: env.skip_translation == 'false'
uses: peter-evans/create-pull-request@v7
with:
token: ${{ secrets.GITHUB_TOKEN }}
commit-message: "Update helm translated README.zh.md"
branch: update-helm-readme-zh
title: "Update helm translated README.zh.md"
body: |
This PR updates the translated README.zh.md file.
if [ ! -f "$EXISTING_README_ZH" ]; then
echo "Add README.zh.md."
mv "$NEW_README_ZH" "$EXISTING_README_ZH"
echo "updated=true" >> $GITHUB_ENV
exit 0
fi
if ! diff -q "$NEW_README_ZH" "$EXISTING_README_ZH"; then
echo "Files are different. Updating README.zh.md."
mv "$NEW_README_ZH" "$EXISTING_README_ZH"
echo "updated=true" >> $GITHUB_ENV
else
echo "Files are identical. No update needed."
echo "updated=false" >> $GITHUB_ENV
fi
- Automatically generated by GitHub Actions
labels: translation, automated
base: main

View File

@@ -144,7 +144,7 @@ docker-buildx-push: clean-env docker.higress-buildx
export PARENT_GIT_TAG:=$(shell cat VERSION)
export PARENT_GIT_REVISION:=$(TAG)
export ENVOY_PACKAGE_URL_PATTERN?=https://github.com/higress-group/proxy/releases/download/v2.1.3/envoy-symbol-ARCH.tar.gz
export ENVOY_PACKAGE_URL_PATTERN?=https://github.com/higress-group/proxy/releases/download/v2.1.4/envoy-symbol-ARCH.tar.gz
build-envoy: prebuild
./tools/hack/build-envoy.sh
@@ -235,8 +235,7 @@ clean-gateway: clean-istio
rm -rf external/proxy
rm -rf external/go-control-plane
rm -rf external/package/envoy.tar.gz
rm -rf external/package/mcp-server_amd64.so
rm -rf external/package/mcp-server_arm64.so
rm -rf external/package/*.so
clean-env:
rm -rf out/

View File

@@ -14,12 +14,9 @@
<a href="https://trendshift.io/repositories/10918" target="_blank"><img src="https://trendshift.io/api/badge/repositories/10918" alt="alibaba%2Fhigress | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</div>
[**Official Site**](https://higress.io/en-us/) &nbsp; |
&nbsp; [**Docs**](https://higress.io/en-us/docs/overview/what-is-higress) &nbsp; |
&nbsp; [**Blog**](https://higress.io/en-us/blog) &nbsp; |
&nbsp; [**Developer**](https://higress.io/en-us/docs/developers/developers_dev) &nbsp; |
&nbsp; [**Higress in Cloud**](https://www.alibabacloud.com/product/microservices-engine?spm=higress-website.topbar.0.0.0) &nbsp;
[**Official Site**](https://higress.ai/en/) &nbsp; |
&nbsp; [**MCP Server QuickStart**](https://higress.cn/en/ai/mcp-quick-start/) &nbsp; |
&nbsp; [**Wasm Plugin Hub**](https://higress.cn/en/plugin/) &nbsp; |
<p>
English | <a href="README_ZH.md">中文<a/> | <a href="README_JP.md">日本語<a/>
@@ -60,7 +57,8 @@ Port descriptions:
- Port 8080: Gateway HTTP protocol entry
- Port 8443: Gateway HTTPS protocol entry
**All Higress Docker images use their own dedicated repository, unaffected by Docker Hub access restrictions in certain regions**
> All Higress Docker images use Higress's own image repository and are not affected by Docker Hub rate limits.
> In addition, the submission and updates of the images are protected by a security scanning mechanism (powered by Alibaba Cloud ACR), making them very secure for use in production environments.
For other installation methods such as Helm deployment under K8s, please refer to the official [Quick Start documentation](https://higress.io/en-us/docs/user/quickstart).
@@ -86,6 +84,8 @@ For other installation methods such as Helm deployment under K8s, please refer t
- Simplified deployment and management through Higress's plugin mechanism for quickly adding new MCP Servers
- Dynamic updates without disruption: Thanks to Envoy's friendly handling of long connections and Wasm plugin's dynamic update mechanism, MCP Server logic can be updated on-the-fly without any traffic disruption or connection drops
[Learn more...](https://higress.cn/en/ai/mcp-quick-start/?spm=36971b57.7beea2de.0.0.d85f20a94jsWGm)
- **Kubernetes ingress controller**:
Higress can function as a feature-rich ingress controller, which is compatible with many annotations of K8s' nginx ingress controller.

View File

@@ -1 +1 @@
v2.1.0
v2.1.1-rc.1

View File

@@ -1,5 +1,5 @@
apiVersion: v2
appVersion: 2.1.0
appVersion: 2.1.1-rc.1
description: Helm chart for deploying higress gateways
icon: https://higress.io/img/higress_logo_small.png
home: http://higress.io/
@@ -15,4 +15,4 @@ dependencies:
repository: "file://../redis"
version: 0.0.1
type: application
version: 2.1.0
version: 2.1.1-rc.1

View File

@@ -1,9 +1,9 @@
dependencies:
- name: higress-core
repository: file://../core
version: 2.1.0
version: 2.1.1-rc.1
- name: higress-console
repository: https://higress.io/helm-charts/
version: 2.1.0
digest: sha256:820c0342946feedbd0329e13689ec7a10b6152be95d58ed2aef016b0d29d8691
generated: "2025-04-02T17:02:50.812174+08:00"
digest: sha256:a54678c246a6f0873cc4e9411902314f34d3aed439774d054c899574fdcba4cc
generated: "2025-04-14T21:04:25.733949+08:00"

View File

@@ -1,5 +1,5 @@
apiVersion: v2
appVersion: 2.1.0
appVersion: 2.1.1-rc.1
description: Helm chart for deploying Higress gateways
icon: https://higress.io/img/higress_logo_small.png
home: http://higress.io/
@@ -12,9 +12,9 @@ sources:
dependencies:
- name: higress-core
repository: "file://../core"
version: 2.1.0
version: 2.1.1-rc.1
- name: higress-console
repository: "https://higress.io/helm-charts/"
version: 2.1.0
type: application
version: 2.1.0
version: 2.1.1-rc.1

View File

@@ -41,6 +41,16 @@ type RedisConfig struct {
DB int `json:"db,omitempty"`
}
// MCPRatelimitConfig defines the configuration for rate limit
type MCPRatelimitConfig struct {
// The limit of the rate limit
Limit int64 `json:"limit,omitempty"`
// The window of the rate limit
Window int64 `json:"window,omitempty"`
// The white list of the rate limit
WhiteList []string `json:"white_list,omitempty"`
}
// SSEServer defines the configuration for Server-Sent Events (SSE) server
type SSEServer struct {
// The name of the SSE server
@@ -75,13 +85,18 @@ type McpServer struct {
Servers []*SSEServer `json:"servers,omitempty"`
// List of match rules for filtering requests
MatchList []*MatchRule `json:"match_list,omitempty"`
// Flag to control whether user level server is enabled
EnableUserLevelServer bool `json:"enable_user_level_server,omitempty"`
// Rate limit config for MCP server
Ratelimit *MCPRatelimitConfig `json:"rate_limit,omitempty"`
}
func NewDefaultMcpServer() *McpServer {
return &McpServer{
Enable: false,
Servers: make([]*SSEServer, 0),
MatchList: make([]*MatchRule, 0),
Enable: false,
Servers: make([]*SSEServer, 0),
MatchList: make([]*MatchRule, 0),
EnableUserLevelServer: false,
}
}
@@ -94,8 +109,8 @@ func validMcpServer(m *McpServer) error {
return nil
}
if m.Enable && m.Redis == nil {
return errors.New("redis config cannot be empty when mcp server is enabled")
if m.EnableUserLevelServer && m.Redis == nil {
return errors.New("redis config cannot be empty when user level server is enabled")
}
// Validate match rule types
@@ -149,9 +164,17 @@ func deepCopyMcpServer(mcp *McpServer) (*McpServer, error) {
DB: mcp.Redis.DB,
}
}
if mcp.Ratelimit != nil {
newMcp.Ratelimit = &MCPRatelimitConfig{
Limit: mcp.Ratelimit.Limit,
Window: mcp.Ratelimit.Window,
WhiteList: mcp.Ratelimit.WhiteList,
}
}
newMcp.SsePathSuffix = mcp.SsePathSuffix
newMcp.EnableUserLevelServer = mcp.EnableUserLevelServer
if len(mcp.Servers) > 0 {
newMcp.Servers = make([]*SSEServer, len(mcp.Servers))
for i, server := range mcp.Servers {
@@ -352,40 +375,59 @@ func (m *McpServerController) constructMcpServerStruct(mcp *McpServer) string {
matchList = fmt.Sprintf("[%s]", strings.Join(matchConfigs, ","))
}
// 构建 Redis 配置
redisConfig := "null"
if mcp.Redis != nil {
redisConfig = fmt.Sprintf(`{
"address": "%s",
"username": "%s",
"password": "%s",
"db": %d
}`, mcp.Redis.Address, mcp.Redis.Username, mcp.Redis.Password, mcp.Redis.DB)
}
// 构建限流配置
rateLimitConfig := "null"
if mcp.Ratelimit != nil {
whiteList := "[]"
if len(mcp.Ratelimit.WhiteList) > 0 {
whiteList = fmt.Sprintf(`["%s"]`, strings.Join(mcp.Ratelimit.WhiteList, `","`))
}
rateLimitConfig = fmt.Sprintf(`{
"limit": %d,
"window": %d,
"white_list": %s
}`, mcp.Ratelimit.Limit, mcp.Ratelimit.Window, whiteList)
}
// Build complete configuration structure
structFmt := `{
return fmt.Sprintf(`{
"name": "envoy.filters.http.golang",
"typed_config": {
"@type": "type.googleapis.com/udpa.type.v1.TypedStruct",
"type_url": "type.googleapis.com/envoy.extensions.filters.http.golang.v3alpha.Config",
"value": {
"library_id": "mcp-server",
"library_path": "/var/lib/istio/envoy/mcp-server.so",
"plugin_name": "mcp-server",
"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": "%s",
"username": "%s",
"password": "%s",
"db": %d
},
"redis": %s,
"rate_limit": %s,
"sse_path_suffix": "%s",
"match_list": %s,
"servers": %s
"servers": %s,
"enable_user_level_server": %t
}
}
}
}
}`
return fmt.Sprintf(structFmt,
mcp.Redis.Address,
mcp.Redis.Username,
mcp.Redis.Password,
mcp.Redis.DB,
}`,
redisConfig,
rateLimitConfig,
mcp.SsePathSuffix,
matchList,
servers)
servers,
mcp.EnableUserLevelServer)
}

View File

@@ -45,17 +45,30 @@ func Test_validMcpServer(t *testing.T) {
{
name: "enabled but no redis config",
mcp: &McpServer{
Enable: true,
Redis: nil,
MatchList: []*MatchRule{},
Servers: []*SSEServer{},
Enable: true,
EnableUserLevelServer: false,
Redis: nil,
MatchList: []*MatchRule{},
Servers: []*SSEServer{},
},
wantErr: errors.New("redis config cannot be empty when mcp server is enabled"),
wantErr: nil,
},
{
name: "enabled with user level server but no redis config",
mcp: &McpServer{
Enable: true,
EnableUserLevelServer: true,
Redis: nil,
MatchList: []*MatchRule{},
Servers: []*SSEServer{},
},
wantErr: errors.New("redis config cannot be empty when user level server is enabled"),
},
{
name: "valid config with redis",
mcp: &McpServer{
Enable: true,
Enable: true,
EnableUserLevelServer: true,
Redis: &RedisConfig{
Address: "localhost:6379",
Username: "default",

View File

@@ -36,4 +36,4 @@ RUN if [ "$GOARCH" = "arm64" ]; then \
FROM scratch AS output
ARG GO_FILTER_NAME
ARG GOARCH
COPY --from=golang-base /${GO_FILTER_NAME}.so ${GO_FILTER_NAME}_${GOARCH}.so
COPY --from=golang-base /${GO_FILTER_NAME}.so golang-filter_${GOARCH}.so

View File

@@ -3,9 +3,13 @@ package main
import (
"fmt"
"net/http"
_ "net/http/pprof"
xds "github.com/cncf/xds/go/xds/type/v3"
"google.golang.org/protobuf/types/known/anypb"
"github.com/alibaba/higress/plugins/golang-filter/mcp-server/handler"
"github.com/alibaba/higress/plugins/golang-filter/mcp-server/internal"
_ "github.com/alibaba/higress/plugins/golang-filter/mcp-server/registry/nacos"
_ "github.com/alibaba/higress/plugins/golang-filter/mcp-server/servers/gorm"
@@ -13,20 +17,31 @@ import (
envoyHttp "github.com/envoyproxy/envoy/contrib/golang/filters/http/source/go/pkg/http"
)
const Name = "mcp-server"
const Name = "mcp-session"
const Version = "1.0.0"
const DefaultServerName = "defaultServer"
const ConfigPathSuffix = "/config"
func init() {
envoyHttp.RegisterHttpFilterFactoryAndConfigParser(Name, filterFactory, &parser{})
go func() {
defer func() {
if r := recover(); r != nil {
api.LogErrorf("PProf server recovered from panic: %v", r)
}
}()
api.LogError(http.ListenAndServe("localhost:6060", nil).Error())
}()
}
type config struct {
ssePathSuffix string
redisClient *internal.RedisClient
servers []*internal.SSEServer
defaultServer *internal.SSEServer
matchList []internal.MatchRule
ssePathSuffix string
redisClient *internal.RedisClient
servers []*internal.SSEServer
defaultServer *internal.SSEServer
matchList []internal.MatchRule
enableUserLevelServer bool
rateLimitConfig *handler.MCPRatelimitConfig
}
func (c *config) Destroy() {
@@ -71,22 +86,50 @@ func (p *parser) Parse(any *anypb.Any, callbacks api.ConfigCallbackHandler) (int
}
}
redisConfigMap, ok := v.AsMap()["redis"].(map[string]interface{})
// Redis configuration is optional
if redisConfigMap, ok := v.AsMap()["redis"].(map[string]interface{}); ok {
redisConfig, err := internal.ParseRedisConfig(redisConfigMap)
if err != nil {
return nil, fmt.Errorf("failed to parse redis config: %w", err)
}
redisClient, err := internal.NewRedisClient(redisConfig)
if err != nil {
return nil, fmt.Errorf("failed to initialize RedisClient: %w", err)
}
conf.redisClient = redisClient
api.LogDebug("Redis client initialized")
} else {
api.LogDebug("Redis configuration not provided, running without Redis")
}
enableUserLevelServer, ok := v.AsMap()["enable_user_level_server"].(bool)
if !ok {
return nil, fmt.Errorf("redis config is not set")
enableUserLevelServer = false
if conf.redisClient == nil {
return nil, fmt.Errorf("redis configuration is not provided, enable_user_level_server is true")
}
}
conf.enableUserLevelServer = enableUserLevelServer
redisConfig, err := internal.ParseRedisConfig(redisConfigMap)
if err != nil {
return nil, fmt.Errorf("failed to parse redis config: %w", err)
if rateLimit, ok := v.AsMap()["rate_limit"].(map[string]interface{}); ok {
rateLimitConfig := &handler.MCPRatelimitConfig{}
if limit, ok := rateLimit["limit"].(float64); ok {
rateLimitConfig.Limit = int(limit)
}
if window, ok := rateLimit["window"].(float64); ok {
rateLimitConfig.Window = int(window)
}
if whiteList, ok := rateLimit["white_list"].([]interface{}); ok {
for _, item := range whiteList {
if uid, ok := item.(string); ok {
rateLimitConfig.Whitelist = append(rateLimitConfig.Whitelist, uid)
}
}
}
conf.rateLimitConfig = rateLimitConfig
}
redisClient, err := internal.NewRedisClient(redisConfig)
if err != nil {
return nil, fmt.Errorf("failed to initialize RedisClient: %w", err)
}
conf.redisClient = redisClient
ssePathSuffix, ok := v.AsMap()["sse_path_suffix"].(string)
if !ok || ssePathSuffix == "" {
return nil, fmt.Errorf("sse path suffix is not set or empty")
@@ -127,7 +170,7 @@ func (p *parser) Parse(any *anypb.Any, callbacks api.ConfigCallbackHandler) (int
}
api.LogDebug(fmt.Sprintf("Server config: %+v", serverConfig))
err = server.ParseConfig(serverConfig)
err := server.ParseConfig(serverConfig)
if err != nil {
return nil, fmt.Errorf("failed to parse server config: %w", err)
}
@@ -138,7 +181,7 @@ func (p *parser) Parse(any *anypb.Any, callbacks api.ConfigCallbackHandler) (int
}
conf.servers = append(conf.servers, internal.NewSSEServer(serverInstance,
internal.WithRedisClient(redisClient),
internal.WithRedisClient(conf.redisClient),
internal.WithSSEEndpoint(fmt.Sprintf("%s%s", serverPath, ssePathSuffix)),
internal.WithMessageEndpoint(serverPath)))
api.LogDebug(fmt.Sprintf("Registered MCP Server: %s", serverType))
@@ -158,11 +201,14 @@ func (p *parser) Merge(parent interface{}, child interface{}) interface{} {
newConfig.ssePathSuffix = childConfig.ssePathSuffix
}
if childConfig.servers != nil {
newConfig.servers = append(newConfig.servers, childConfig.servers...)
newConfig.servers = childConfig.servers
}
if childConfig.defaultServer != nil {
newConfig.defaultServer = childConfig.defaultServer
}
if childConfig.matchList != nil {
newConfig.matchList = childConfig.matchList
}
return &newConfig
}
@@ -172,9 +218,11 @@ func filterFactory(c interface{}, callbacks api.FilterCallbackHandler) api.Strea
panic("unexpected config type")
}
return &filter{
callbacks: callbacks,
config: conf,
stopChan: make(chan struct{}),
callbacks: callbacks,
config: conf,
stopChan: make(chan struct{}),
mcpConfigHandler: handler.NewMCPConfigHandler(conf.redisClient, callbacks),
mcpRatelimitHandler: handler.NewMCPRatelimitHandler(conf.redisClient, callbacks, conf.rateLimitConfig),
}
}

View File

@@ -5,12 +5,18 @@ import (
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"github.com/alibaba/higress/plugins/golang-filter/mcp-server/handler"
"github.com/alibaba/higress/plugins/golang-filter/mcp-server/internal"
"github.com/envoyproxy/envoy/contrib/golang/common/go/api"
)
const (
RedisNotEnabledResponseBody = "Redis is not enabled, SSE connection is not supported"
)
// The callbacks in the filter, like `DecodeHeaders`, can be implemented on demand.
// Because api.PassThroughStreamFilter provides a default implementation.
type filter struct {
@@ -26,15 +32,20 @@ type filter struct {
message bool
proxyURL *url.URL
skip bool
userLevelConfig bool
mcpConfigHandler *handler.MCPConfigHandler
mcpRatelimitHandler *handler.MCPRatelimitHandler
}
type RequestURL struct {
method string
scheme string
host string
path string
baseURL string
parsedURL *url.URL
method string
scheme string
host string
path string
baseURL string
parsedURL *url.URL
internalIP bool
}
func NewRequestURL(header api.RequestHeaderMap) *RequestURL {
@@ -42,10 +53,11 @@ func NewRequestURL(header api.RequestHeaderMap) *RequestURL {
scheme, _ := header.Get(":scheme")
host, _ := header.Get(":authority")
path, _ := header.Get(":path")
internalIP, _ := header.Get("x-envoy-internal")
baseURL := fmt.Sprintf("%s://%s", scheme, host)
parsedURL, _ := url.Parse(path)
api.LogDebugf("RequestURL: method=%s, scheme=%s, host=%s, path=%s", method, scheme, host, path)
return &RequestURL{method: method, scheme: scheme, host: host, path: path, baseURL: baseURL, parsedURL: parsedURL}
return &RequestURL{method: method, scheme: scheme, host: host, path: path, baseURL: baseURL, parsedURL: parsedURL, internalIP: internalIP == "true"}
}
// Callbacks which are called in request path
@@ -71,11 +83,11 @@ func (f *filter) DecodeHeaders(header api.RequestHeaderMap, endStream bool) api.
f.callbacks.DecoderFilterCallbacks().SendLocalReply(http.StatusOK, body, nil, 0, "")
}
api.LogDebugf("%s SSE connection started", server.GetServerName())
server.SetBaseURL(url.baseURL)
return api.LocalReply
} else if f.path == server.GetMessageEndpoint() {
if url.method != http.MethodPost {
f.callbacks.DecoderFilterCallbacks().SendLocalReply(http.StatusMethodNotAllowed, "Method not allowed", nil, 0, "")
return api.LocalReply
}
// Create a new http.Request object
f.req = &http.Request{
@@ -97,8 +109,57 @@ func (f *filter) DecodeHeaders(header api.RequestHeaderMap, endStream bool) api.
}
}
}
if strings.HasSuffix(f.path, ConfigPathSuffix) && f.config.enableUserLevelServer {
if !url.internalIP {
api.LogWarnf("Access denied: non-internal IP address %s", url.parsedURL.String())
f.callbacks.DecoderFilterCallbacks().SendLocalReply(http.StatusForbidden, "", nil, 0, "")
return api.LocalReply
}
if strings.HasSuffix(f.path, ConfigPathSuffix) && url.method == http.MethodGet {
api.LogDebugf("Handling config request: %s", f.path)
f.mcpConfigHandler.HandleConfigRequest(f.path, url.method, []byte{})
return api.LocalReply
}
f.req = &http.Request{
Method: url.method,
URL: url.parsedURL,
}
f.userLevelConfig = true
if endStream {
return api.Continue
} else {
return api.StopAndBuffer
}
}
if !strings.HasSuffix(url.parsedURL.Path, f.config.ssePathSuffix) {
f.proxyURL = url.parsedURL
if f.config.enableUserLevelServer {
parts := strings.Split(url.parsedURL.Path, "/")
if len(parts) < 3 {
api.LogDebugf("Access denied: missing uid in path %s", url.parsedURL.Path)
f.callbacks.DecoderFilterCallbacks().SendLocalReply(http.StatusForbidden, "Access denied: missing uid", nil, 0, "")
return api.LocalReply
}
serverName := parts[1]
uid := parts[2]
// Get encoded config
encodedConfig, err := f.mcpConfigHandler.GetEncodedConfig(serverName, uid)
if err != nil {
api.LogWarnf("Access denied: no valid config found for uid %s", uid)
f.callbacks.DecoderFilterCallbacks().SendLocalReply(http.StatusForbidden, "", nil, 0, "")
return api.LocalReply
} else if encodedConfig != "" {
header.Set("x-higress-mcpserver-config", encodedConfig)
api.LogDebugf("Set x-higress-mcpserver-config Header for %s:%s", serverName, uid)
} else {
api.LogDebugf("Empty config found for %s:%s", serverName, uid)
if !f.mcpRatelimitHandler.HandleRatelimit(url.parsedURL.Path, url.method, []byte{}) {
return api.LocalReply
}
}
}
return api.Continue
}
@@ -112,7 +173,6 @@ func (f *filter) DecodeHeaders(header api.RequestHeaderMap, endStream bool) api.
f.serverName = f.config.defaultServer.GetServerName()
body := "SSE connection create"
f.callbacks.DecoderFilterCallbacks().SendLocalReply(http.StatusOK, body, nil, 0, "")
f.config.defaultServer.SetBaseURL(url.baseURL)
}
return api.LocalReply
}
@@ -138,6 +198,11 @@ func (f *filter) DecodeData(buffer api.BufferInstance, endStream bool) api.Statu
}
}
return api.StopAndBuffer
} else if f.userLevelConfig {
// Handle config POST request
api.LogDebugf("Handling config request: %s", f.path)
f.mcpConfigHandler.HandleConfigRequest(f.path, f.req.Method, buffer.Bytes())
return api.LocalReply
}
return api.Continue
}
@@ -149,11 +214,15 @@ func (f *filter) EncodeHeaders(header api.ResponseHeaderMap, endStream bool) api
return api.Continue
}
if f.serverName != "" {
header.Set("Content-Type", "text/event-stream")
header.Set("Cache-Control", "no-cache")
header.Set("Connection", "keep-alive")
header.Set("Access-Control-Allow-Origin", "*")
header.Del("Content-Length")
if f.config.redisClient != nil {
header.Set("Content-Type", "text/event-stream")
header.Set("Cache-Control", "no-cache")
header.Set("Connection", "keep-alive")
header.Set("Access-Control-Allow-Origin", "*")
header.Del("Content-Length")
} else {
header.Set("Content-Length", strconv.Itoa(len(RedisNotEnabledResponseBody)))
}
return api.Continue
}
return api.Continue
@@ -168,7 +237,7 @@ func (f *filter) EncodeData(buffer api.BufferInstance, endStream bool) api.Statu
if !endStream {
return api.StopAndBuffer
}
if f.proxyURL != nil {
if f.proxyURL != nil && f.config.redisClient != nil {
sessionID := f.proxyURL.Query().Get("sessionId")
if sessionID != "" {
channel := internal.GetSSEChannelName(sessionID)
@@ -181,21 +250,26 @@ func (f *filter) EncodeData(buffer api.BufferInstance, endStream bool) api.Statu
}
if f.serverName != "" {
// handle specific server
for _, server := range f.config.servers {
if f.serverName == server.GetServerName() {
if f.config.redisClient != nil {
// handle specific server
for _, server := range f.config.servers {
if f.serverName == server.GetServerName() {
buffer.Reset()
server.HandleSSE(f.callbacks, f.stopChan)
return api.Running
}
}
// handle default server
if f.serverName == f.config.defaultServer.GetServerName() {
buffer.Reset()
server.HandleSSE(f.callbacks, f.stopChan)
f.config.defaultServer.HandleSSE(f.callbacks, f.stopChan)
return api.Running
}
return api.Continue
} else {
buffer.SetString(RedisNotEnabledResponseBody)
return api.Continue
}
// handle default server
if f.serverName == f.config.defaultServer.GetServerName() {
buffer.Reset()
f.config.defaultServer.HandleSSE(f.callbacks, f.stopChan)
return api.Running
}
return api.Continue
}
return api.Continue
}

View File

@@ -101,4 +101,4 @@ require (
gopkg.in/yaml.v3 v3.0.1 // indirect
)
replace github.com/nacos-group/nacos-sdk-go/v2 v2.2.9 => github.com/luoxiner/nacos-sdk-go/v2 v2.2.9-30
replace github.com/nacos-group/nacos-sdk-go/v2 v2.2.9 => github.com/luoxiner/nacos-sdk-go/v2 v2.2.9-40

View File

@@ -136,12 +136,8 @@ github.com/deckarep/golang-set v1.7.1 h1:SCQV0S6gTtp6itiFrTqI+pfmJ4LN85S1YzhDf9r
github.com/deckarep/golang-set v1.7.1/go.mod h1:93vsz/8Wt4joVM7c2AVqh+YRMiUSc14yDtF28KmMOgQ=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/envoyproxy/envoy v1.32.3 h1:eftH199KwYfyBTtm4reeEzsWTqraACEaTQ6efl31v0I=
github.com/envoyproxy/envoy v1.32.3/go.mod h1:KGS+IUehDX1mSIdqodPTWskKOo7bZMLLy3GHxvOKcJk=
github.com/envoyproxy/envoy v1.33.1-0.20250325161043-11ab50a29d99 h1:jih/Ieb7BFgVCStgvY5fXQ3mI9ByOt4wfwUF0d7qmqI=
github.com/envoyproxy/envoy v1.33.1-0.20250325161043-11ab50a29d99/go.mod h1:x7d0dNbE0xGuDBUkBg19VGCgnPQ+lJ2k8lDzDzKExow=
github.com/envoyproxy/envoy v1.33.2 h1:k3ChySbVo4HejvbDRxkgRroUnj6TZZpXPJJ0UGaZkXs=
github.com/envoyproxy/envoy v1.33.2/go.mod h1:faFqv1XeNGX/ph6Zto5Culdcpk4Klxp730Q6XhWarV4=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
@@ -285,6 +281,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/luoxiner/nacos-sdk-go/v2 v2.2.9-40 h1:nzRTBplC0riQqQwEHZThw5H4/TH5LgWTQTm6A7t1lpY=
github.com/luoxiner/nacos-sdk-go/v2 v2.2.9-40/go.mod h1:9FKXl6FqOiVmm72i8kADtbeK71egyG9y3uRDBg41tpQ=
github.com/mark3labs/mcp-go v0.12.0 h1:Pue1Tdwqcz77GHq18uzgmLT3wmeDUxXUSAqSwhGLhVo=
github.com/mark3labs/mcp-go v0.12.0/go.mod h1:cjMlBU0cv/cj9kjlgmRhoJ5JREdS7YX83xeIG9Ko/jE=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
@@ -302,8 +300,6 @@ github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjY
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/nacos-group/nacos-sdk-go/v2 v2.2.9 h1:etzCMnB9EBeSKfaDIOe8zH4HO/8fycpc6s0AmXCrmAw=
github.com/nacos-group/nacos-sdk-go/v2 v2.2.9/go.mod h1:9FKXl6FqOiVmm72i8kADtbeK71egyG9y3uRDBg41tpQ=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=

View File

@@ -0,0 +1,153 @@
package handler
import (
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/alibaba/higress/plugins/golang-filter/mcp-server/internal"
"github.com/envoyproxy/envoy/contrib/golang/common/go/api"
)
// MCPConfigHandler handles configuration requests for MCP server
type MCPConfigHandler struct {
configStore ConfigStore
callbacks api.FilterCallbackHandler
}
// NewMCPConfigHandler creates a new instance of MCP configuration handler
func NewMCPConfigHandler(redisClient *internal.RedisClient, callbacks api.FilterCallbackHandler) *MCPConfigHandler {
return &MCPConfigHandler{
configStore: NewRedisConfigStore(redisClient),
callbacks: callbacks,
}
}
// HandleConfigRequest processes configuration requests
func (h *MCPConfigHandler) HandleConfigRequest(path string, method string, body []byte) bool {
// Check if it's a configuration request
if !strings.HasSuffix(path, "/config") {
return false
}
// Extract serverName and uid from path
pathParts := strings.Split(strings.TrimSuffix(path, "/config"), "/")
if len(pathParts) < 2 {
h.sendErrorResponse(http.StatusBadRequest, "INVALID_PATH", "Invalid path format")
return true
}
uid := pathParts[len(pathParts)-1]
serverName := pathParts[len(pathParts)-2]
switch method {
case http.MethodGet:
return h.handleGetConfig(serverName, uid)
case http.MethodPost:
return h.handleStoreConfig(serverName, uid, body)
default:
h.sendErrorResponse(http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "Method not allowed")
return true
}
}
// handleGetConfig handles configuration retrieval requests
func (h *MCPConfigHandler) handleGetConfig(serverName string, uid string) bool {
config, err := h.configStore.GetConfig(serverName, uid)
if err != nil {
api.LogErrorf("Failed to get config for server %s, uid %s: %v", serverName, uid, err)
h.sendErrorResponse(http.StatusInternalServerError, "CONFIG_ERROR", fmt.Sprintf("Failed to get configuration: %s", err.Error()))
return true
}
response := struct {
Success bool `json:"success"`
Config map[string]string `json:"config"`
}{
Success: true,
Config: config,
}
responseBytes, _ := json.Marshal(response)
h.callbacks.DecoderFilterCallbacks().SendLocalReply(
http.StatusOK,
string(responseBytes),
nil, 0, "",
)
return true
}
// handleStoreConfig handles configuration storage requests
func (h *MCPConfigHandler) handleStoreConfig(serverName string, uid string, body []byte) bool {
// Parse request body
var requestBody struct {
Config map[string]string `json:"config"`
}
if err := json.Unmarshal(body, &requestBody); err != nil {
api.LogErrorf("Invalid request format for server %s, uid %s: %v", serverName, uid, err)
h.sendErrorResponse(http.StatusBadRequest, "INVALID_REQUEST", fmt.Sprintf("Invalid request format: %s", err.Error()))
return true
}
if requestBody.Config == nil {
h.sendErrorResponse(http.StatusBadRequest, "INVALID_REQUEST", "Config cannot be null")
return true
}
response, err := h.configStore.StoreConfig(serverName, uid, requestBody.Config)
if err != nil {
api.LogErrorf("Failed to store config for server %s, uid %s: %v", serverName, uid, err)
h.sendErrorResponse(http.StatusInternalServerError, "CONFIG_ERROR", fmt.Sprintf("Failed to store configuration: %s", err.Error()))
return true
}
responseBytes, _ := json.Marshal(response)
h.callbacks.DecoderFilterCallbacks().SendLocalReply(
http.StatusOK,
string(responseBytes),
nil, 0, "",
)
return true
}
// sendErrorResponse sends an error response with the specified status, code and message
func (h *MCPConfigHandler) sendErrorResponse(status int, code string, message string) {
response := &ConfigResponse{
Success: false,
Error: &struct {
Code string `json:"code"`
Message string `json:"message"`
}{
Code: code,
Message: message,
},
}
responseBytes, _ := json.Marshal(response)
h.callbacks.DecoderFilterCallbacks().SendLocalReply(
status,
string(responseBytes),
nil, 0, "",
)
}
// GetEncodedConfig retrieves and encodes the configuration for a given server and uid
func (h *MCPConfigHandler) GetEncodedConfig(serverName string, uid string) (string, error) {
conf, err := h.configStore.GetConfig(serverName, uid)
if err != nil {
return "", fmt.Errorf("failed to get config: %w", err)
}
// Check if config exists and is not empty
if len(conf) > 0 {
// Convert config map to JSON string
configBytes, err := json.Marshal(conf)
if err != nil {
return "", fmt.Errorf("failed to marshal config: %w", err)
}
// Encode JSON string to base64
return base64.StdEncoding.EncodeToString(configBytes), nil
}
return "", nil
}

View File

@@ -0,0 +1,105 @@
package handler
import (
"encoding/json"
"fmt"
"time"
"github.com/alibaba/higress/plugins/golang-filter/mcp-server/internal"
)
const (
configExpiry = 7 * 24 * time.Hour
)
// GetConfigStoreKey returns the Redis channel name for the given session ID
func GetConfigStoreKey(serverName string, uid string) string {
return fmt.Sprintf("mcp-server-config:%s:%s", serverName, uid)
}
// ConfigResponse represents the response structure for configuration operations
type ConfigResponse struct {
Success bool `json:"success"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error,omitempty"`
}
// ConfigStore defines the interface for configuration storage operations
type ConfigStore interface {
// StoreConfig stores user configuration
StoreConfig(serverName string, uid string, config map[string]string) (*ConfigResponse, error)
// GetConfig retrieves user configuration
GetConfig(serverName string, uid string) (map[string]string, error)
}
// RedisConfigStore implements configuration storage using Redis
type RedisConfigStore struct {
redisClient *internal.RedisClient
}
// NewRedisConfigStore creates a new instance of Redis configuration storage
func NewRedisConfigStore(redisClient *internal.RedisClient) ConfigStore {
return &RedisConfigStore{
redisClient: redisClient,
}
}
// StoreConfig stores configuration in Redis
func (s *RedisConfigStore) StoreConfig(serverName string, uid string, config map[string]string) (*ConfigResponse, error) {
key := GetConfigStoreKey(serverName, uid)
// Convert config to JSON
configBytes, err := json.Marshal(config)
if err != nil {
return &ConfigResponse{
Success: false,
Error: &struct {
Code string `json:"code"`
Message string `json:"message"`
}{
Code: "MARSHAL_ERROR",
Message: "Failed to marshal configuration",
},
}, err
}
// Store in Redis with expiry
err = s.redisClient.Set(key, string(configBytes), configExpiry)
if err != nil {
return &ConfigResponse{
Success: false,
Error: &struct {
Code string `json:"code"`
Message string `json:"message"`
}{
Code: "REDIS_ERROR",
Message: "Failed to store configuration in Redis",
},
}, err
}
return &ConfigResponse{
Success: true,
}, nil
}
// GetConfig retrieves configuration from Redis
func (s *RedisConfigStore) GetConfig(serverName string, uid string) (map[string]string, error) {
key := GetConfigStoreKey(serverName, uid)
// Get from Redis
value, err := s.redisClient.Get(key)
if err != nil {
return nil, err
}
// Parse JSON
var config map[string]string
if err := json.Unmarshal([]byte(value), &config); err != nil {
return nil, err
}
return config, nil
}

View File

@@ -0,0 +1,129 @@
package handler
import (
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/alibaba/higress/plugins/golang-filter/mcp-server/internal"
"github.com/envoyproxy/envoy/contrib/golang/common/go/api"
)
type MCPRatelimitHandler struct {
redisClient *internal.RedisClient
callbacks api.FilterCallbackHandler
limit int // Maximum requests allowed per window
window int // Time window in seconds
whitelist []string // Whitelist of UIDs that bypass rate limiting
}
// MCPRatelimitConfig is the configuration for the rate limit handler
type MCPRatelimitConfig struct {
Limit int `json:"limit"`
Window int `json:"window"`
Whitelist []string `json:"white_list"` // List of UIDs that bypass rate limiting
}
// NewMCPRatelimitHandler creates a new rate limit handler
func NewMCPRatelimitHandler(redisClient *internal.RedisClient, callbacks api.FilterCallbackHandler, conf *MCPRatelimitConfig) *MCPRatelimitHandler {
if conf == nil {
conf = &MCPRatelimitConfig{
Limit: 100,
Window: int(24 * time.Hour / time.Second), // 24 hours in seconds
Whitelist: []string{},
}
}
return &MCPRatelimitHandler{
redisClient: redisClient,
callbacks: callbacks,
limit: conf.Limit,
window: conf.Window,
whitelist: conf.Whitelist,
}
}
const (
// Lua script for rate limiting
LimitScript = `
local ttl = redis.call('ttl', KEYS[1])
if ttl < 0 then
redis.call('set', KEYS[1], ARGV[1] - 1, 'EX', ARGV[2])
return {ARGV[1], ARGV[1] - 1, ARGV[2]}
end
return {ARGV[1], redis.call('incrby', KEYS[1], -1), ttl}
`
)
type LimitContext struct {
Count int // Current request count
Remaining int // Remaining requests allowed
Reset int // Time until reset in seconds
}
func (h *MCPRatelimitHandler) HandleRatelimit(path string, method string, body []byte) bool {
parts := strings.Split(path, "/")
if len(parts) < 3 {
h.callbacks.DecoderFilterCallbacks().SendLocalReply(http.StatusForbidden, "", nil, 0, "")
return false
}
serverName := parts[1]
uid := parts[2]
// Check if the UID is in whitelist
for _, whitelistedUID := range h.whitelist {
if whitelistedUID == uid {
return true // Bypass rate limiting for whitelisted UIDs
}
}
// Build rate limit key using serverName, uid, window and limit
limitKey := fmt.Sprintf("mcp-server-limit:%s:%s:%d:%d", serverName, uid, h.window, h.limit)
keys := []string{limitKey}
args := []interface{}{h.limit, h.window}
result, err := h.redisClient.Eval(LimitScript, 1, keys, args)
if err != nil {
api.LogErrorf("Failed to check rate limit: %v", err)
h.callbacks.DecoderFilterCallbacks().SendLocalReply(http.StatusInternalServerError, "", nil, 0, "")
return false
}
// Process response
resultArray, ok := result.([]interface{})
if !ok || len(resultArray) != 3 {
api.LogErrorf("Invalid response format: %v", result)
h.callbacks.DecoderFilterCallbacks().SendLocalReply(http.StatusInternalServerError, "", nil, 0, "")
return false
}
context := LimitContext{
Count: parseRedisValue(resultArray[0]),
Remaining: parseRedisValue(resultArray[1]),
Reset: parseRedisValue(resultArray[2]),
}
if context.Remaining < 0 {
h.callbacks.DecoderFilterCallbacks().SendLocalReply(http.StatusTooManyRequests, "", nil, 0, "")
return false
}
return true
}
// parseRedisValue converts the value from Redis to an int
func parseRedisValue(value interface{}) int {
switch v := value.(type) {
case int:
return v
case int64:
return int(v)
case string:
if i, err := strconv.Atoi(v); err == nil {
return i
}
}
return 0
}

View File

@@ -0,0 +1,76 @@
package internal
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"fmt"
"io"
)
// Crypto handles encryption and decryption operations using AES-GCM
type Crypto struct {
gcm cipher.AEAD
}
func NewCrypto(secret string) (*Crypto, error) {
if secret == "" {
return nil, fmt.Errorf("secret cannot be empty")
}
// Generate a 32-byte key using SHA-256
hash := sha256.Sum256([]byte(secret))
block, err := aes.NewCipher(hash[:])
if err != nil {
return nil, fmt.Errorf("failed to create cipher: %v", err)
}
// Create GCM mode
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("failed to create GCM: %v", err)
}
return &Crypto{gcm: gcm}, nil
}
// Encrypt encrypts the plaintext data using AES-GCM
func (c *Crypto) Encrypt(plaintext []byte) (string, error) {
// Generate random nonce
nonce := make([]byte, c.gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", fmt.Errorf("failed to generate nonce: %v", err)
}
// Encrypt and authenticate data
ciphertext := c.gcm.Seal(nonce, nonce, plaintext, nil)
return base64.StdEncoding.EncodeToString(ciphertext), nil
}
// Decrypt decrypts the encrypted string using AES-GCM
func (c *Crypto) Decrypt(encryptedStr string) ([]byte, error) {
// Decode base64
ciphertext, err := base64.StdEncoding.DecodeString(encryptedStr)
if err != nil {
return nil, fmt.Errorf("invalid encrypted data format")
}
// Check if the ciphertext is too short
if len(ciphertext) < c.gcm.NonceSize() {
return nil, fmt.Errorf("invalid encrypted data length")
}
// Extract nonce and ciphertext
nonce := ciphertext[:c.gcm.NonceSize()]
ciphertext = ciphertext[c.gcm.NonceSize():]
// Decrypt and verify data
plaintext, err := c.gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return nil, fmt.Errorf("decryption failed")
}
return plaintext, nil
}

View File

@@ -10,35 +10,42 @@ import (
)
type RedisConfig struct {
Address string
Username string
Password string
DB int
address string
username string
password string
db int
secret string // Encryption key
}
func ParseRedisConfig(config map[string]any) (*RedisConfig, error) {
// ParseRedisConfig parses Redis configuration from a map
func ParseRedisConfig(config map[string]interface{}) (*RedisConfig, error) {
c := &RedisConfig{}
// address is required
addr, ok := config["address"].(string)
if !ok {
return nil, fmt.Errorf("address is required and must be a string")
if addr, ok := config["address"].(string); ok && addr != "" {
c.address = addr
} else {
return nil, fmt.Errorf("address is required and must be a non-empty string")
}
c.Address = addr
// username is optional
if username, ok := config["username"].(string); ok {
c.Username = username
c.username = username
}
// password is optional
if password, ok := config["password"].(string); ok {
c.Password = password
c.password = password
}
// db is optional, default to 0
if db, ok := config["db"].(int); ok {
c.DB = db
c.db = db
}
// secret is optional
if secret, ok := config["secret"].(string); ok {
c.secret = secret
}
return c, nil
@@ -50,15 +57,16 @@ type RedisClient struct {
ctx context.Context
cancel context.CancelFunc
config *RedisConfig
crypto *Crypto
}
// NewRedisClient creates a new RedisClient instance and establishes a connection to the Redis server
func NewRedisClient(config *RedisConfig) (*RedisClient, error) {
client := redis.NewClient(&redis.Options{
Addr: config.Address,
Username: config.Username,
Password: config.Password,
DB: config.DB,
Addr: config.address,
Username: config.username,
Password: config.password,
DB: config.db,
})
// Ping the Redis server to check the connection
@@ -69,11 +77,22 @@ func NewRedisClient(config *RedisConfig) (*RedisClient, error) {
api.LogDebugf("Connected to Redis: %s", pong)
ctx, cancel := context.WithCancel(context.Background())
var crypto *Crypto
if config.secret != "" {
crypto, err = NewCrypto(config.secret)
if err != nil {
cancel()
return nil, err
}
}
redisClient := &RedisClient{
client: client,
ctx: ctx,
cancel: cancel,
config: config,
crypto: crypto,
}
// Start keep-alive check
@@ -117,10 +136,10 @@ func (r *RedisClient) reconnect() error {
// Create new client
r.client = redis.NewClient(&redis.Options{
Addr: r.config.Address,
Username: r.config.Username,
Password: r.config.Password,
DB: r.config.DB,
Addr: r.config.address,
Username: r.config.username,
Password: r.config.password,
DB: r.config.db,
})
// Test the new connection
@@ -150,6 +169,12 @@ func (r *RedisClient) Subscribe(channel string, stopChan chan struct{}, callback
}
go func() {
defer func() {
if r := recover(); r != nil {
api.LogErrorf("Redis Subscribe recovered from panic: %v", r)
}
}()
defer func() {
pubsub.Close()
api.LogDebugf("Closed subscription to channel %s", channel)
@@ -184,7 +209,19 @@ func (r *RedisClient) Subscribe(channel string, stopChan chan struct{}, callback
// Set sets the value of a key in Redis
func (r *RedisClient) Set(key string, value string, expiration time.Duration) error {
err := r.client.Set(r.ctx, key, value, expiration).Err()
var finalValue string
if r.crypto != nil {
// Encrypt the data
encryptedValue, err := r.crypto.Encrypt([]byte(value))
if err != nil {
return fmt.Errorf("failed to encrypt value: %w", err)
}
finalValue = encryptedValue
} else {
finalValue = value
}
err := r.client.Set(r.ctx, key, finalValue, expiration).Err()
if err != nil {
return fmt.Errorf("failed to set key: %w", err)
}
@@ -193,13 +230,23 @@ func (r *RedisClient) Set(key string, value string, expiration time.Duration) er
// Get retrieves the value of a key from Redis
func (r *RedisClient) Get(key string) (string, error) {
val, err := r.client.Get(r.ctx, key).Result()
value, err := r.client.Get(r.ctx, key).Result()
if err == redis.Nil {
return "", fmt.Errorf("key does not exist")
} else if err != nil {
return "", fmt.Errorf("failed to get key: %w", err)
}
return val, nil
if r.crypto != nil {
// Decrypt the data
decryptedValue, err := r.crypto.Decrypt(value)
if err != nil {
return "", fmt.Errorf("failed to decrypt value: %w", err)
}
return string(decryptedValue), nil
}
return value, nil
}
// Close closes the Redis client and stops the keepalive goroutine
@@ -207,3 +254,13 @@ func (r *RedisClient) Close() error {
r.cancel()
return r.client.Close()
}
// Eval executes a Lua script
func (r *RedisClient) Eval(script string, numKeys int, keys []string, args []interface{}) (interface{}, error) {
result, err := r.client.Eval(r.ctx, script, keys, args...).Result()
if err != nil {
return nil, fmt.Errorf("failed to execute Lua script: %w", err)
}
return result, nil
}

View File

@@ -419,6 +419,16 @@ func (s *MCPServer) HandleMessage(
)
}
return s.handleToolCall(ctx, baseMessage.ID, request)
case "":
var response mcp.JSONRPCResponse
if err := json.Unmarshal(message, &response); err != nil {
return createErrorResponse(
baseMessage.ID,
mcp.INVALID_REQUEST,
"Invalid message format",
)
}
return nil
default:
return createErrorResponse(
baseMessage.ID,

View File

@@ -28,10 +28,6 @@ type SSEServer struct {
redisClient *RedisClient // Redis client for pub/sub
}
func (s *SSEServer) SetBaseURL(baseURL string) {
s.baseURL = baseURL
}
func (s *SSEServer) GetMessageEndpoint() string {
return s.messageEndpoint
}
@@ -148,6 +144,12 @@ func (s *SSEServer) HandleSSE(cb api.FilterCallbackHandler, stopChan chan struct
// Start health check handler
go func() {
defer func() {
if r := recover(); r != nil {
api.LogErrorf("Health check handler recovered from panic: %v", r)
}
}()
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
@@ -158,7 +160,15 @@ func (s *SSEServer) HandleSSE(cb api.FilterCallbackHandler, stopChan chan struct
case <-ticker.C:
// Send health check message
currentTime := time.Now().Format(time.RFC3339)
healthCheckEvent := fmt.Sprintf(": ping - %s\n\n", currentTime)
pingRequest := mcp.JSONRPCRequest{
JSONRPC: mcp.JSONRPC_VERSION,
ID: currentTime,
Request: mcp.Request{
Method: "ping",
},
}
pingData, _ := json.Marshal(pingRequest)
healthCheckEvent := fmt.Sprintf("event: message\ndata: %s\n\n", pingData)
if err := s.redisClient.Publish(channel, healthCheckEvent); err != nil {
api.LogErrorf("Failed to send health check: %v", err)
}
@@ -202,7 +212,7 @@ func (s *SSEServer) HandleMessage(w http.ResponseWriter, r *http.Request, body j
if response != nil {
eventData, _ := json.Marshal(response)
if sessionID != "" {
if sessionID != "" && s.redisClient != nil {
channel := GetSSEChannelName(sessionID)
publishErr := s.redisClient.Publish(channel, fmt.Sprintf("event: message\ndata: %s\n\n", eventData))

View File

@@ -40,6 +40,9 @@ func (n *NacosMcpRegsitry) ListToolsDesciption() []*registry.ToolDescription {
}
func (n *NacosMcpRegsitry) GetToolRpcContext(toolName string) (*registry.RpcContext, bool) {
if n.toolsRpcContext == nil {
n.refreshToolsList()
}
tool, ok := n.toolsRpcContext[toolName]
return tool, ok
}
@@ -87,9 +90,11 @@ func (n *NacosMcpRegsitry) refreshToolsListForGroup(group string, serviceMatcher
formatServiceName := getFormatServiceName(group, service)
if _, ok := n.currentServiceSet[formatServiceName]; !ok {
changed = true
n.refreshToolsListForService(group, service)
refreshed := n.refreshToolsListForService(group, service)
n.listenToService(group, service)
if refreshed {
changed = true
}
}
currentServiceList[formatServiceName] = true
@@ -129,7 +134,23 @@ func getFormatServiceName(group string, service string) string {
return fmt.Sprintf("%s_%s", group, service)
}
func (n *NacosMcpRegsitry) refreshToolsListForServiceWithContent(group string, service string, newConfig *string, instances *[]model.Instance) {
func (n *NacosMcpRegsitry) deleteToolForService(group string, service string) {
toolsNeedReset := []string{}
formatServiceName := getFormatServiceName(group, service)
for tool, _ := range n.toolsDescription {
if strings.HasPrefix(tool, formatServiceName) {
toolsNeedReset = append(toolsNeedReset, tool)
}
}
for _, tool := range toolsNeedReset {
delete(n.toolsDescription, tool)
delete(n.toolsRpcContext, tool)
}
}
func (n *NacosMcpRegsitry) refreshToolsListForServiceWithContent(group string, service string, newConfig *string, instances *[]model.Instance) bool {
if newConfig == nil {
dataId := makeToolsConfigId(service)
@@ -140,7 +161,7 @@ func (n *NacosMcpRegsitry) refreshToolsListForServiceWithContent(group string, s
if err != nil {
api.LogError(fmt.Sprintf("Get tools config for sercice %s:%s error %s", group, service, err))
return
return false
}
newConfig = &content
@@ -155,17 +176,27 @@ func (n *NacosMcpRegsitry) refreshToolsListForServiceWithContent(group string, s
if err != nil {
api.LogError(fmt.Sprintf("List instance for sercice %s:%s error %s", group, service, err))
return
return false
}
instances = &instancesFromNacos
}
var applicationDescription registry.McpApplicationDescription
if newConfig == nil {
return false
}
// config deleted, tools should be removed
if len(*newConfig) == 0 {
n.deleteToolForService(group, service)
return true
}
err := json.Unmarshal([]byte(*newConfig), &applicationDescription)
if err != nil {
api.LogError(fmt.Sprintf("Parse tools config for sercice %s:%s error, config is %s, error is %s", group, service, *newConfig, err))
return
return false
}
wrappedInstances := []registry.Instance{}
@@ -186,6 +217,8 @@ func (n *NacosMcpRegsitry) refreshToolsListForServiceWithContent(group string, s
n.toolsRpcContext = map[string]*registry.RpcContext{}
}
n.deleteToolForService(group, service)
for _, tool := range applicationDescription.ToolsDescription {
meta := applicationDescription.ToolsMeta[tool.Name]
@@ -207,6 +240,7 @@ func (n *NacosMcpRegsitry) refreshToolsListForServiceWithContent(group string, s
n.toolsRpcContext[tool.Name] = &context
}
n.currentServiceSet[getFormatServiceName(group, service)] = true
return true
}
func (n *NacosMcpRegsitry) GetCredential(name string, group string) *registry.CredentialInfo {
@@ -231,8 +265,8 @@ func (n *NacosMcpRegsitry) GetCredential(name string, group string) *registry.Cr
return &credential
}
func (n *NacosMcpRegsitry) refreshToolsListForService(group string, service string) {
n.refreshToolsListForServiceWithContent(group, service, nil, nil)
func (n *NacosMcpRegsitry) refreshToolsListForService(group string, service string) bool {
return n.refreshToolsListForServiceWithContent(group, service, nil, nil)
}
func (n *NacosMcpRegsitry) listenToService(group string, service string) {

View File

@@ -112,6 +112,10 @@ func (c *NacosConfig) ParseConfig(config map[string]any) error {
return errors.New("missing serviceMatcher")
}
if namespace, ok := config["namespace"].(string); ok {
c.Namespace = &namespace
}
matchers := map[string]string{}
for key, value := range serviceMatcher {
matchers[key] = value.(string)
@@ -150,6 +154,12 @@ func (c *NacosConfig) NewServer(serverName string) (*internal.MCPServer, error)
nacosRegistry.RegisterToolChangeEventListener(&listener)
go func() {
defer func() {
if r := recover(); r != nil {
api.LogErrorf("NacosToolsListRefresh recovered from panic: %v", r)
}
}()
for {
if nacosRegistry.refreshToolsList() {
resetToolsToMcpServer(mcpServer, nacosRegistry)

View File

@@ -50,8 +50,11 @@ func FixedQueryToken(cred *CredentialInfo, h *HttpRemoteCallHandle) {
h.Query[key.(string)] = value.(string)
}
func newHttpRemoteCallHandle(ctx *RpcContext) *HttpRemoteCallHandle {
instance := selectOneInstance(ctx)
func newHttpRemoteCallHandle(ctx *RpcContext) (*HttpRemoteCallHandle, error) {
instance, err := selectOneInstance(ctx)
if err != nil {
return nil, err
}
method, ok := ctx.ToolMeta.InvokeContext["method"]
if !ok {
method = DEFAULT_HTTP_METHOD
@@ -64,7 +67,7 @@ func newHttpRemoteCallHandle(ctx *RpcContext) *HttpRemoteCallHandle {
return &HttpRemoteCallHandle{
CommonRemoteCallHandle: CommonRemoteCallHandle{
Instance: &instance,
Instance: instance,
},
Protocol: ctx.Protocol,
Headers: http.Header{},
@@ -72,7 +75,7 @@ func newHttpRemoteCallHandle(ctx *RpcContext) *HttpRemoteCallHandle {
Query: map[string]string{},
Path: path,
Method: method,
}
}, nil
}
// http remote handle implementation
@@ -119,14 +122,14 @@ func (h *HttpRemoteCallHandle) handleParamMapping(mapInfo *map[string]ParameterM
for param, value := range params {
if info, ok := paramMapInfo[param]; ok {
if info.Position == "Query" {
h.Query[info.BackendName] = fmt.Sprintf("%s", value)
h.Query[info.BackendName] = fmt.Sprintf("%v", value)
} else if info.Position == "Header" {
h.Headers[info.BackendName] = []string{fmt.Sprintf("%s", value)}
h.Headers[info.BackendName] = []string{fmt.Sprintf("%v", value)}
} else {
return fmt.Errorf("Unsupport position for args %s, pos is %s", param, info.Position)
}
} else {
h.Query[param] = fmt.Sprintf("%s", value)
h.Query[param] = fmt.Sprintf("%v", value)
}
}
return nil
@@ -160,20 +163,25 @@ func (h *HttpRemoteCallHandle) doHttpCall() (*http.Response, error) {
return http.DefaultClient.Do(&request)
}
func selectOneInstance(ctx *RpcContext) Instance {
func selectOneInstance(ctx *RpcContext) (*Instance, error) {
instanceId := 0
if ctx.Instances == nil || len(*ctx.Instances) == 0 {
return nil, fmt.Errorf("No instance")
}
instances := *ctx.Instances
if len(instances) != 1 {
if len(instances) > 1 {
instanceId = rand.Intn(len(instances) - 1)
}
return instances[instanceId]
select_instance := instances[instanceId]
return &select_instance, nil
}
func getRemoteCallhandle(ctx *RpcContext) RemoteCallHandle {
func getRemoteCallhandle(ctx *RpcContext) (RemoteCallHandle, error) {
if ctx.Protocol == PROTOCOL_HTTP || ctx.Protocol == PROTOCOL_HTTPS {
return newHttpRemoteCallHandle(ctx)
} else {
return nil
return nil, nil
}
}
@@ -184,11 +192,15 @@ func CommonRemoteCall(reg McpServerRegistry, toolName string, parameters map[str
return nil, fmt.Errorf("Unknown tool %s", toolName)
}
remoteHandle := getRemoteCallhandle(ctx)
remoteHandle, err := getRemoteCallhandle(ctx)
if remoteHandle == nil {
return nil, fmt.Errorf("Unknown backend protocol %s", ctx.Protocol)
}
if err != nil {
return nil, fmt.Errorf("Call backend server error: %w", err)
}
return remoteHandle.HandleToolCall(ctx, parameters)
}

View File

@@ -36,7 +36,7 @@ description: AI 代理插件配置参考
| `type` | string | 必填 | - | AI 服务提供商名称 |
| `apiTokens` | array of string | 非必填 | - | 用于在访问 AI 服务时进行认证的令牌。如果配置了多个 token插件会在请求时随机进行选择。部分服务提供商只支持配置一个 token。 |
| `timeout` | number | 非必填 | - | 访问 AI 服务的超时时间。单位为毫秒。默认值为 120000即 2 分钟。此项配置目前仅用于获取上下文信息,并不影响实际转发大模型请求。 |
| `modelMapping` | map of string | 非必填 | - | AI 模型映射表,用于将请求中的模型名称映射为服务提供商支持模型名称。<br/>1. 支持前缀匹配。例如用 "gpt-3-*" 匹配所有名称以“gpt-3-”开头的模型;<br/>2. 支持使用 "*" 为键来配置通用兜底映射关系;<br/>3. 如果映射的目标名称为空字符串 "",则表示保留原模型名称。 |
| `modelMapping` | map of string | 非必填 | - | AI 模型映射表,用于将请求中的模型名称映射为服务提供商支持模型名称。<br/>1. 支持前缀匹配。例如用 "gpt-3-\*" 匹配所有名称以“gpt-3-”开头的模型;<br/>2. 支持使用 "\*" 为键来配置通用兜底映射关系;<br/>3. 如果映射的目标名称为空字符串 "",则表示保留原模型名称。 |
| `protocol` | string | 非必填 | - | 插件对外提供的 API 接口契约。目前支持以下取值openai默认值使用 OpenAI 的接口契约、original使用目标服务提供商的原始接口契约 |
| `context` | object | 非必填 | - | 配置 AI 对话上下文信息 |
| `customSettings` | array of customSetting | 非必填 | - | 为AI请求指定覆盖或者填充参数 |

View File

@@ -34,7 +34,7 @@ Plugin execution priority: `100`
| `type` | string | Required | - | Name of the AI service provider |
| `apiTokens` | array of string | Optional | - | Tokens used for authentication when accessing AI services. If multiple tokens are configured, the plugin randomly selects one for each request. Some service providers only support configuring a single token. |
| `timeout` | number | Optional | - | Timeout for accessing AI services, in milliseconds. The default value is 120000, which equals 2 minutes. Only used when retrieving context data. Won't affect the request forwarded to the LLM upstream. |
| `modelMapping` | map of string | Optional | - | Mapping table for AI models, used to map model names in requests to names supported by the service provider.<br/>1. Supports prefix matching. For example, "gpt-3-*" matches all model names starting with “gpt-3-”;<br/>2. Supports using "*" as a key for a general fallback mapping;<br/>3. If the mapped target name is an empty string "", the original model name is preserved. |
| `modelMapping` | map of string | Optional | - | Mapping table for AI models, used to map model names in requests to names supported by the service provider.<br/>1. Supports prefix matching. For example, "gpt-3-\*" matches all model names starting with “gpt-3-”;<br/>2. Supports using "\*" as a key for a general fallback mapping;<br/>3. If the mapped target name is an empty string "", the original model name is preserved. |
| `protocol` | string | Optional | - | API contract provided by the plugin. Currently supports the following values: openai (default, uses OpenAI's interface contract), original (uses the raw interface contract of the target service provider) |
| `context` | object | Optional | - | Configuration for AI conversation context information |
| `customSettings` | array of customSetting | Optional | - | Specifies overrides or fills parameters for AI requests |

View File

@@ -61,46 +61,6 @@ Attribute 配置说明:
### 空配置
#### 监控
```
route_upstream_model_metric_input_token{ai_route="llm",ai_cluster="outbound|443||qwen.dns",ai_model="qwen-turbo"} 10
route_upstream_model_metric_llm_duration_count{ai_route="llm",ai_cluster="outbound|443||qwen.dns",ai_model="qwen-turbo"} 1
route_upstream_model_metric_llm_first_token_duration{ai_route="llm",ai_cluster="outbound|443||qwen.dns",ai_model="qwen-turbo"} 309
route_upstream_model_metric_llm_service_duration{ai_route="llm",ai_cluster="outbound|443||qwen.dns",ai_model="qwen-turbo"} 1955
route_upstream_model_metric_output_token{ai_route="llm",ai_cluster="outbound|443||qwen.dns",ai_model="qwen-turbo"} 69
```
#### 日志
```json
{
"ai_log":"{\"model\":\"qwen-turbo\",\"input_token\":\"10\",\"output_token\":\"69\",\"llm_first_token_duration\":\"309\",\"llm_service_duration\":\"1955\"}"
}
```
#### 链路追踪
配置为空时不会在span中添加额外的attribute
### 从非openai协议提取token使用信息
在ai-proxy中设置协议为original时以百炼为例可作如下配置指定如何提取model, input_token, output_token
```yaml
attributes:
- key: model
value_source: response_body
value: usage.models.0.model_id
apply_to_log: true
apply_to_span: false
- key: input_token
value_source: response_body
value: usage.models.0.input_tokens
apply_to_log: true
apply_to_span: false
- key: output_token
value_source: response_body
value: usage.models.0.output_tokens
apply_to_log: true
apply_to_span: false
```
#### 监控
```
# counter 类型,输入 token 数量的累加值
@@ -140,11 +100,51 @@ irate(route_upstream_model_consumer_metric_llm_service_duration[2m])
irate(route_upstream_model_consumer_metric_llm_duration_count[2m])
```
#### 日志
```json
{
"ai_log":"{\"model\":\"qwen-turbo\",\"input_token\":\"10\",\"output_token\":\"69\",\"llm_first_token_duration\":\"309\",\"llm_service_duration\":\"1955\"}"
}
```
#### 链路追踪
配置为空时不会在span中添加额外的attribute
### 从非openai协议提取token使用信息
在ai-proxy中设置协议为original时以百炼为例可作如下配置指定如何提取model, input_token, output_token
```yaml
attributes:
- key: model
value_source: response_body
value: usage.models.0.model_id
apply_to_log: true
apply_to_span: false
- key: input_token
value_source: response_body
value: usage.models.0.input_tokens
apply_to_log: true
apply_to_span: false
- key: output_token
value_source: response_body
value: usage.models.0.output_tokens
apply_to_log: true
apply_to_span: false
```
#### 监控
```
route_upstream_model_consumer_metric_input_token{ai_route="bailian",ai_cluster="qwen",ai_model="qwen-max"} 343
route_upstream_model_consumer_metric_output_token{ai_route="bailian",ai_cluster="qwen",ai_model="qwen-max"} 153
route_upstream_model_consumer_metric_llm_service_duration{ai_route="bailian",ai_cluster="qwen",ai_model="qwen-max"} 3725
route_upstream_model_consumer_metric_llm_duration_count{ai_route="bailian",ai_cluster="qwen",ai_model="qwen-max"} 1
```
#### 日志
此配置下日志效果如下:
```json
{
"ai_log": "{\"model\":\"qwen-max\",\"input_token\":\"343\",\"output_token\":\"153\",\"llm_service_duration\":\"19110\"}"
"ai_log": "{\"model\":\"qwen-max\",\"input_token\":\"343\",\"output_token\":\"153\",\"llm_service_duration\":\"19110\"}"
}
```
@@ -152,7 +152,7 @@ irate(route_upstream_model_consumer_metric_llm_duration_count[2m])
链路追踪的 span 中可以看到 model, input_token, output_token 三个额外的 attribute
### 配合认证鉴权记录consumer
举例如下:
举例如下:
```yaml
attributes:
- key: consumer # 配合认证鉴权记录consumer

View File

@@ -48,12 +48,12 @@ The meanings of various values for `value_source` are as follows:
When `value_source` is `response_streaming_body`, `rule` should be configured to specify how to obtain the specified value from the streaming body. The meaning of the value is as follows:
- `first`: extract value from the first valid chunk
- `replace`: extract value from the last valid chunk
- `first`: extract value from the first valid chunk
- `replace`: extract value from the last valid chunk
- `append`: join value pieces from all valid chunks
## Configuration example
If you want to record ai-statistic related statistical values in the gateway access log, you need to modify log_format and add a new field based on the original log_format. The example is as follows:
If you want to record ai-statistic related statistical values in the gateway access log, you need to modify log_format and add a new field based on the original log_format. The example is as follows:
```yaml
'{"ai_log":"%FILTER_STATE(wasm.ai_log:PLAIN)%"}'
@@ -61,48 +61,6 @@ If you want to record ai-statistic related statistical values in the
### Empty
#### Metric
```
route_upstream_model_metric_input_token{ai_route="llm",ai_cluster="outbound|443||qwen.dns",ai_model="qwen-turbo"} 10
route_upstream_model_metric_llm_duration_count{ai_route="llm",ai_cluster="outbound|443||qwen.dns",ai_model="qwen-turbo"} 1
route_upstream_model_metric_llm_first_token_duration{ai_route="llm",ai_cluster="outbound|443||qwen.dns",ai_model="qwen-turbo"} 309
route_upstream_model_metric_llm_service_duration{ai_route="llm",ai_cluster="outbound|443||qwen.dns",ai_model="qwen-turbo"} 1955
route_upstream_model_metric_output_token{ai_route="llm",ai_cluster="outbound|443||qwen.dns",ai_model="qwen-turbo"} 69
```
#### Log
```json
{
"ai_log":"{\"model\":\"qwen-turbo\",\"input_token\":\"10\",\"output_token\":\"69\",\"llm_first_token_duration\":\"309\",\"llm_service_duration\":\"1955\"}"
}
```
#### Trace
When the configuration is empty, no additional attributes will be added to the span.
### Extract token usage information from non-openai protocols
When setting the protocol to original in ai-proxy, taking Alibaba Cloud Bailian as an example, you can make the following configuration to specify how to extract `model`, `input_token`, `output_token`
```yaml
attributes:
- key: model
value_source: response_body
value: usage.models.0.model_id
apply_to_log: true
apply_to_span: false
- key: input_token
value_source: response_body
value: usage.models.0.input_tokens
apply_to_log: true
apply_to_span: false
- key: output_token
value_source: response_body
value: usage.models.0.output_tokens
apply_to_log: true
apply_to_span: false
```
#### Metric
Here is the English translation:
```
# counter, cumulative count of input tokens
@@ -145,7 +103,47 @@ irate(route_upstream_model_consumer_metric_llm_duration_count[2m])
#### Log
```json
{
"ai_log": "{\"model\":\"qwen-max\",\"input_token\":\"343\",\"output_token\":\"153\",\"llm_service_duration\":\"19110\"}"
"ai_log":"{\"model\":\"qwen-turbo\",\"input_token\":\"10\",\"output_token\":\"69\",\"llm_first_token_duration\":\"309\",\"llm_service_duration\":\"1955\"}"
}
```
#### Trace
When the configuration is empty, no additional attributes will be added to the span.
### Extract token usage information from non-openai protocols
When setting the protocol to original in ai-proxy, taking Alibaba Cloud Bailian as an example, you can make the following configuration to specify how to extract `model`, `input_token`, `output_token`
```yaml
attributes:
- key: model
value_source: response_body
value: usage.models.0.model_id
apply_to_log: true
apply_to_span: false
- key: input_token
value_source: response_body
value: usage.models.0.input_tokens
apply_to_log: true
apply_to_span: false
- key: output_token
value_source: response_body
value: usage.models.0.output_tokens
apply_to_log: true
apply_to_span: false
```
#### Metric
```
route_upstream_model_consumer_metric_input_token{ai_route="bailian",ai_cluster="qwen",ai_model="qwen-max"} 343
route_upstream_model_consumer_metric_output_token{ai_route="bailian",ai_cluster="qwen",ai_model="qwen-max"} 153
route_upstream_model_consumer_metric_llm_service_duration{ai_route="bailian",ai_cluster="qwen",ai_model="qwen-max"} 3725
route_upstream_model_consumer_metric_llm_duration_count{ai_route="bailian",ai_cluster="qwen",ai_model="qwen-max"} 1
```
#### Log
```json
{
"ai_log": "{\"model\":\"qwen-max\",\"input_token\":\"343\",\"output_token\":\"153\",\"llm_service_duration\":\"19110\"}"
}
```
@@ -164,7 +162,7 @@ attributes:
### Record questions and answers
```yaml
attributes:
- key: question
- key: question
value_source: request_body
value: messages.@reverse.0.content
apply_to_log: true

View File

@@ -14,34 +14,152 @@ description: 自定义应答插件配置参考
插件执行优先级:`910`
## 配置字段
### 新版本-支持多种返回
| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
|-------|------------------|------|-----|-------------------------------------|
| rules | array of object | 必填 | - | 规则组 |
| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
| -------- | -------- | -------- | -------- | -------- |
| status_code | number | 选填 | 200 | 自定义 HTTP 应答状态码 |
| headers | array of string | 选填 | - | 自定义 HTTP 应答头key 和 value 用`=`分隔 |
| body | string | 选填 | - | 自定义 HTTP 应答 Body |
| enable_on_status | array of number | 选填 | - | 匹配原始状态码,生成自定义响应,不填写时,不判断原始状态码 |
`rules`的配置字段说明如下:
| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
|--------------------|---------------------------|------|-----|------------------------------------------------------------------------------------------------------------------------------------------------------|
| `status_code` | number | 选填 | 200 | 自定义 HTTP 应答状态码 |
| `headers` | array of string | 选填 | - | 自定义 HTTP 应答头key 和 value 用`=`分隔 |
| `body` | string | 选填 | - | 自定义 HTTP 应答 Body |
| `enable_on_status` | array of string or number | 选填 | - | 匹配原始状态码,生成自定义响应。可填写精确值如:`200`,`404`等,也可以模糊匹配例如:`2xx`来匹配200-299之间的状态码`20x`来匹配200-209之间的状态码x代表任意一位数字。不填写时不判断原始状态码,取第一个`enable_on_status`为空的规则作为默认规则 |
#### 模糊匹配规则:
* 长度为3
* 至少一位数字
* 至少一位x(不区分大小写)
| 规则 | 匹配内容 |
|-----|------------------------------------------------------------------------------------------|
| 40x | 400-409前两位为40的情况 |
| 1x4 | 104,114,124,134,144,154,164,174,184,194第一位和第三位分别为1和4的情况 |
| x23 | 023,123,223,323,423,523,623,723,823,923第二位和第三位为23的情况 |
| 4xx | 400-499第一位为4的情况 |
| x4x | 040-049,140-149,240-249,340-349,440-449,540-549,640-649,740-749,840-849,940-949第二位为4的情况 |
| xx4 | 尾数为4的情况 |
### 老版本-只支持一种返回
| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
| -------- | -------- |------| -------- |---------------------------------|
| `status_code` | number | 选填 | 200 | 自定义 HTTP 应答状态码 |
| `headers` | array of string | 选填 | - | 自定义 HTTP 应答头key 和 value 用`=`分隔 |
| `body` | string | 选填 | - | 自定义 HTTP 应答 Body |
| `enable_on_status` | array of number | 选填 | - | 匹配原始状态码,生成自定义响应,不填写时,不判断原始状态码 |
匹配优先级:精确匹配 > 模糊匹配 > 默认配置(第一个enable_on_status为空的配置)
## 配置示例
### Mock 应答场景
### 新版本-不同状态码不同应答场景
```yaml
status_code: 200
headers:
- Content-Type=application/json
- Hello=World
body: "{\"hello\":\"world\"}"
rules:
- body: '{"hello":"world 200"}'
enable_on_status:
- 200
- 201
headers:
- key1=value1
- key2=value2
status_code: 200
- body: '{"hello":"world 404"}'
enable_on_status:
- 404
headers:
- key1=value1
- key2=value2
status_code: 200
```
根据该配置,请求将返回自定义应答如下:
根据该配置,200、201请求将返回自定义应答如下:
```text
HTTP/1.1 200 OK
Content-Type: application/json
Hello: World
Content-Length: 17
key1: value1
key2: value2
Content-Length: 21
{"hello":"world 200"}
```
根据该配置404请求将返回自定义应答如下
```text
HTTP/1.1 200 OK
Content-Type: application/json
key1: value1
key2: value2
Content-Length: 21
{"hello":"world 400"}
```
### 新版本-模糊匹配场景
```yaml
rules:
- body: '{"hello":"world 200"}'
enable_on_status:
- 200
headers:
- key1=value1
- key2=value2
status_code: 200
- body: '{"hello":"world 40x"}'
enable_on_status:
- '40x'
headers:
- key1=value1
- key2=value2
status_code: 200
```
根据该配置200状态码将返回自定义应答如下
```text
HTTP/1.1 200 OK
Content-Type: application/json
key1: value1
key2: value2
Content-Length: 21
{"hello":"world 200"}
```
根据该配置401-409之间的状态码将返回自定义应答如下
```text
HTTP/1.1 200 OK
Content-Type: application/json
key1: value1
key2: value2
Content-Length: 21
{"hello":"world 40x"}
```
### 老版本-不同状态码相同应答场景
```yaml
enable_on_status:
- 200
status_code: 200
headers:
- Content-Type=application/json
- Hello=World
body: "{\"hello\":\"world\"}"
```
根据该配置200请求将返回自定义应答如下
```text
HTTP/1.1 200 OK
Content-Type: application/json
key1: value1
key2: value2
Content-Length: 21
{"hello":"world"}
```

View File

@@ -12,30 +12,165 @@ Plugin Execution Phase: `Authentication Phase`
Plugin Execution Priority: `910`
## Configuration Fields
| Name | Data Type | Requirements | Default Value | Description |
| -------- | -------- | -------- | -------- | -------- |
| status_code | number | Optional | 200 | Custom HTTP response status code |
| headers | array of string | Optional | - | Custom HTTP response headers, keys and values separated by `=` |
| body | string | Optional | - | Custom HTTP response body |
| enable_on_status | array of number | Optional | - | Match original status codes to generate custom responses; if not specified, the original status code is not checked |
### New version - Supports multiple returns
| Name | Data Type | Requirements | Default Value | Description |
|---------------------|-----------------|----------|-----|------------|
| rules | array of object | Required | - | rule array |
The configuration field description of `rules` is as follows
| Name | Data Type | Requirements | Default Value | Description |
|--------------------|---------------------------|--------------|-----|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `status_code` | number | Optional | 200 | Custom HTTP response status code |
| `headers` | array of string | Optional | - | Custom HTTP response headers, keys and values separated by `=` |
| `body` | string | Optional | - | Custom HTTP response body |
| `enable_on_status` | array of string or number | Optional | - | Match the original status code to generate a custom response. You can fill in the exact value such as :`200`,`404`, etc., you can also fuzzy match such as: `2xx` to match the status code between 200-299, `20x` to match the status code between 200-209, x represents any digit. If enable_on_status is not specified, the original status code is not determined and the first rule with ENABLE_ON_status left blank is used as the default rule |
#### Fuzzy matching rule
* Length is 3
* At least one digit
* At least one x(case insensitive)
| rule | Matching content |
|------|------------------------------------------------------------------------------------------|
| 40x | 400-409; If the first two digits are 40 |
| 1x4 | 104,114,124,134,144,154,164,174,184,194The first and third positions are 1 and 4 respectively |
| x23 | 023,123,223,323,423,523,623,723,823,923The second and third positions are 23 |
| 4xx | 400-499The first digit is 4 |
| x4x | 040-049,140-149,240-249,340-349,440-449,540-549,640-649,740-749,840-849,940-949The second digit is 4 |
| xx4 | When the mantissa is 4 |
Matching priority: Exact Match > Fuzzy Match > Default configuration (the first enable_on_status parameter is null)
## Old version - Only one return is supported
| Name | Data Type | Requirements | Default Value | Description |
| -------- | -------- | -------- | -------- |----------------------------------------------------------------------------------------------------|
| `status_code` | number | Optional | 200 | Custom HTTP response status code |
| `headers` | array of string | Optional | - | Custom HTTP response headers, keys and values separated by `=` |
| `body` | string | Optional | - | Custom HTTP response body |
| `enable_on_status` | array of number | Optional | - | Match original status codes to generate custom responses; if not specified, the original status code is not checked |
## Configuration Example
### Mock Response Scenario
### Different status codes for different response scenarios
```yaml
status_code: 200
headers:
- Content-Type=application/json
- Hello=World
body: "{\"hello\":\"world\"}"
rules:
- body: '{"hello":"world 200"}'
enable_on_status:
- '200'
- '201'
headers:
- key1=value1
- key2=value2
status_code: 200
- body: '{"hello":"world 404"}'
enable_on_status:
- '404'
headers:
- key1=value1
- key2=value2
status_code: 200
```
With this configuration, the request will return the following custom response:
According to this configuration 200 201 requests will return a custom response as follows
```text
HTTP/1.1 200 OK
Content-Type: application/json
Hello: World
Content-Length: 17
key1: value1
key2: value2
Content-Length: 21
{"hello":"world 200"}
```
According to this configuration 404 requests will return a custom response as follows
```text
HTTP/1.1 200 OK
Content-Type: application/json
key1: value1
key2: value2
Content-Length: 21
{"hello":"world 400"}
```
With this configuration, 404 response will return the following custom response:
```text
HTTP/1.1 200 OK
Content-Type: application/json
key1: value1
key2: value2
Content-Length: 21
{"hello":"world 404"}
```
### Fuzzy matching scene
```yaml
rules:
- body: '{"hello":"world 200"}'
enable_on_status:
- 200
headers:
- key1=value1
- key2=value2
status_code: 200
- body: '{"hello":"world 40x"}'
enable_on_status:
- '40x'
headers:
- key1=value1
- key2=value2
status_code: 200
```
According to this configuration, the status 200 will return a custom reply as follows
```text
HTTP/1.1 200 OK
Content-Type: application/json
key1: value1
key2: value2
Content-Length: 21
{"hello":"world 200"}
```
According to this configuration, the status code between 401-409 will return a custom reply as follows
```text
HTTP/1.1 200 OK
Content-Type: application/json
key1: value1
key2: value2
Content-Length: 21
{"hello":"world 40x"}
```
### Mock Response Scenario
```yaml
enable_on_status:
- 200
status_code: 200
headers:
- Content-Type=application/json
- Hello=World
body: "{\"hello\":\"world\"}"
```
With this configuration, 200/201 response will return the following custom response:
```text
HTTP/1.1 200 OK
Content-Type: application/json
key1: value1
key2: value2
Content-Length: 21
{"hello":"world"}
```
### Custom Response on Rate Limiting
```yaml
enable_on_status:

View File

@@ -1 +1 @@
1.0.0
1.1.0

View File

@@ -0,0 +1,25 @@
services:
envoy:
image: higress-registry.cn-hangzhou.cr.aliyuncs.com/higress/gateway:v2.0.7
entrypoint: /usr/local/bin/envoy
# 注意这里对 Wasm 开启了 debug 级别日志,在生产环境部署时请使用默认的 info 级别
# 如果需要将 Envoy 的日志级别调整为 debug将 --log-level 参数设置为 debug
command: -c /etc/envoy/envoy.yaml --log-level info --component-log-level wasm:debug
depends_on:
- echo-server
networks:
- wasmtest
ports:
- "10000:10000"
- "9901:9901"
volumes:
- ./envoy.yaml:/etc/envoy/envoy.yaml
- ./plugin.wasm:/etc/envoy/plugin.wasm
echo-server:
image: higress-registry.cn-hangzhou.cr.aliyuncs.com/higress/echo-server:1.3.0
networks:
- wasmtest
ports:
- "3000:3000"
networks:
wasmtest: {}

View File

@@ -1,9 +1,3 @@
admin:
address:
socket_address:
protocol: TCP
address: 0.0.0.0
port_value: 9901
static_resources:
listeners:
- name: listener_0
@@ -27,9 +21,9 @@ static_resources:
domains: ["*"]
routes:
- match:
prefix: "/"
prefix: "/echo"
route:
cluster: httpbin
cluster: echo-server
http_filters:
- name: wasmdemo
typed_config:
@@ -42,30 +36,100 @@ static_resources:
runtime: envoy.wasm.runtime.v8
code:
local:
filename: /etc/envoy/main.wasm
filename: /etc/envoy/plugin.wasm
configuration:
"@type": "type.googleapis.com/google.protobuf.StringValue"
value: |
# value: |-
# {
# "rules": [
# {
# "headers": [
# "key1=value1",
# "key2=value2"
# ],
# "status_code": 200,
# "enable_on_status": [
# 200,
# 201
# ],
# "body": "{\"hello\":\"world 200\"}"
# },
# {
# "headers": [
# "key1=value1",
# "key2=value2"
# ],
# "status_code": 200,
# "enable_on_status": [
# 404
# ],
# "body": "{\"hello\":\"world 404\"}"
# }
# ]
# }
value: |-
{
"headers": ["key1=value1", "key2=value2"],
"status_code": 200,
"enable_on_status": [200, 201],
"body": "{\"hello\":\"world\"}"
"rules": [
{
"headers": [
"key1=value1",
"key2=value2"
],
"status_code": 200,
"enable_on_status": [
200
],
"body": "{\"hello\":\"world 200\"}"
},
{
"headers": [
"key1=value1",
"key2=value2"
],
"status_code": 200,
"enable_on_status": [
"40x"
],
"body": "{\"hello\":\"world 40x\"}"
}
]
}
# value: |-
# {
# "headers": [
# "key1=value1",
# "key2=value2"
# ],
# "status_code": 200,
# "body": "{\"hello\":\"world 200\"}",
# "enable_on_status": [
# 200
# ]
# }
# value: |-
# {
# "headers": [
# "key1=value1",
# "key2=value2"
# ],
# "status_code": 200,
# "body": "{\"hello\":\"world 200\"}"
# }
- name: envoy.filters.http.router
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
clusters:
- name: httpbin
- name: echo-server
connect_timeout: 30s
type: LOGICAL_DNS
# Comment out the following line to test on v6 networks
dns_lookup_family: V4_ONLY
lb_policy: ROUND_ROBIN
load_assignment:
cluster_name: httpbin
cluster_name: echo-server
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: httpbin
port_value: 80
address: echo-server
port_value: 3000

View File

@@ -16,10 +16,12 @@ package main
import (
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"github.com/alibaba/higress/plugins/wasm-go/pkg/log"
"github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper"
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm"
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm/types"
@@ -29,78 +31,160 @@ import (
func main() {
wrapper.SetCtx(
"custom-response",
wrapper.ParseConfigBy(parseConfig),
wrapper.ProcessRequestHeadersBy(onHttpRequestHeaders),
wrapper.ProcessResponseHeadersBy(onHttpResponseHeaders),
wrapper.ParseConfig(parseConfig),
wrapper.ProcessRequestHeaders(onHttpRequestHeaders),
wrapper.ProcessResponseHeaders(onHttpResponseHeaders),
)
}
type CustomResponseConfig struct {
rules []CustomResponseRule
defaultRule *CustomResponseRule
enableOnStatusRuleMap map[string]*CustomResponseRule
}
type CustomResponseRule struct {
statusCode uint32
headers [][2]string
body string
enableOnStatus []uint32
enableOnStatus []string
contentType string
}
func parseConfig(gjson gjson.Result, config *CustomResponseConfig, log wrapper.Log) error {
func parseConfig(gjson gjson.Result, config *CustomResponseConfig) error {
rules := gjson.Get("rules")
rulesVersion := rules.Exists() && rules.IsArray()
if rulesVersion {
for _, cf := range gjson.Get("rules").Array() {
item := new(CustomResponseRule)
if err := parseRuleItem(cf, item); err != nil {
return err
}
// the first rule item which enableOnStatus is empty to be set default
if len(item.enableOnStatus) == 0 && config.defaultRule == nil {
config.defaultRule = item
}
config.rules = append(config.rules, *item)
}
} else {
rule := new(CustomResponseRule)
if err := parseRuleItem(gjson, rule); err != nil {
return err
}
config.rules = append(config.rules, *rule)
config.defaultRule = rule
}
config.enableOnStatusRuleMap = make(map[string]*CustomResponseRule)
for i, configItem := range config.rules {
for _, statusCode := range configItem.enableOnStatus {
if v, ok := config.enableOnStatusRuleMap[statusCode]; ok {
log.Errorf("enable_on_status code used in %v, want to add %v", v, statusCode)
return errors.New("enableOnStatus can only use once")
}
config.enableOnStatusRuleMap[statusCode] = &config.rules[i]
}
}
if rulesVersion && config.defaultRule == nil && len(config.enableOnStatusRuleMap) == 0 {
return errors.New("no valid config is found")
}
return nil
}
func parseRuleItem(gjson gjson.Result, rule *CustomResponseRule) error {
headersArray := gjson.Get("headers").Array()
config.headers = make([][2]string, 0, len(headersArray))
rule.headers = make([][2]string, 0, len(headersArray))
for _, v := range headersArray {
kv := strings.SplitN(v.String(), "=", 2)
if len(kv) == 2 {
key := strings.TrimSpace(kv[0])
value := strings.TrimSpace(kv[1])
if strings.EqualFold(key, "content-type") {
config.contentType = value
rule.contentType = value
} else if strings.EqualFold(key, "content-length") {
continue
} else {
config.headers = append(config.headers, [2]string{key, value})
rule.headers = append(rule.headers, [2]string{key, value})
}
} else {
return fmt.Errorf("invalid header pair format: %s", v.String())
}
}
config.body = gjson.Get("body").String()
if config.contentType == "" && config.body != "" {
if json.Valid([]byte(config.body)) {
config.contentType = "application/json; charset=utf-8"
rule.body = gjson.Get("body").String()
if rule.contentType == "" && rule.body != "" {
if json.Valid([]byte(rule.body)) {
rule.contentType = "application/json; charset=utf-8"
} else {
config.contentType = "text/plain; charset=utf-8"
rule.contentType = "text/plain; charset=utf-8"
}
}
config.headers = append(config.headers, [2]string{"content-type", config.contentType})
rule.headers = append(rule.headers, [2]string{"content-type", rule.contentType})
config.statusCode = 200
rule.statusCode = 200
if gjson.Get("status_code").Exists() {
statusCode := gjson.Get("status_code")
parsedStatusCode, err := strconv.Atoi(statusCode.String())
if err != nil {
return fmt.Errorf("invalid status code value: %s", statusCode.String())
}
config.statusCode = uint32(parsedStatusCode)
rule.statusCode = uint32(parsedStatusCode)
}
enableOnStatusArray := gjson.Get("enable_on_status").Array()
config.enableOnStatus = make([]uint32, 0, len(enableOnStatusArray))
rule.enableOnStatus = make([]string, 0, len(enableOnStatusArray))
for _, v := range enableOnStatusArray {
parsedEnableOnStatus, err := strconv.Atoi(v.String())
s := v.String()
_, err := strconv.Atoi(s)
if err != nil {
return fmt.Errorf("invalid enable_on_status value: %s", v.String())
matchString, err := isValidFuzzyMatchString(s)
if err != nil {
return err
}
rule.enableOnStatus = append(rule.enableOnStatus, matchString)
continue
}
config.enableOnStatus = append(config.enableOnStatus, uint32(parsedEnableOnStatus))
rule.enableOnStatus = append(rule.enableOnStatus, s)
}
return nil
}
func onHttpRequestHeaders(ctx wrapper.HttpContext, config CustomResponseConfig, log wrapper.Log) types.Action {
if len(config.enableOnStatus) != 0 {
func isValidFuzzyMatchString(s string) (string, error) {
const requiredLength = 3
if len(s) != requiredLength {
return "", fmt.Errorf("invalid enable_on_status %q: length must be %d", s, requiredLength)
}
lower := strings.ToLower(s)
hasX := false
hasDigit := false
for _, c := range lower {
switch {
case c == 'x':
hasX = true
case c >= '0' && c <= '9':
hasDigit = true
default:
return "", fmt.Errorf("invalid enable_on_status %q: must contain only digits and x/X", s)
}
}
if !hasX {
return "", fmt.Errorf("invalid enable_on_status %q: fuzzy match must contain x/X (use enable_on_status for exact statusCode matching)", s)
}
if !hasDigit {
return "", fmt.Errorf("invalid enable_on_status %q: must contain at least one digit", s)
}
return lower, nil
}
func onHttpRequestHeaders(_ wrapper.HttpContext, config CustomResponseConfig) types.Action {
if len(config.enableOnStatusRuleMap) != 0 {
return types.ActionContinue
}
err := proxywasm.SendHttpResponseWithDetail(config.statusCode, "custom-response", config.headers, []byte(config.body), -1)
log.Infof("use default rule %+v", config.defaultRule)
err := proxywasm.SendHttpResponseWithDetail(config.defaultRule.statusCode, "custom-response", config.defaultRule.headers, []byte(config.defaultRule.body), -1)
if err != nil {
log.Errorf("send http response failed: %v", err)
}
@@ -108,28 +192,62 @@ func onHttpRequestHeaders(ctx wrapper.HttpContext, config CustomResponseConfig,
return types.ActionPause
}
func onHttpResponseHeaders(ctx wrapper.HttpContext, config CustomResponseConfig, log wrapper.Log) types.Action {
// enableOnStatus is not empty, compare the status code.
func onHttpResponseHeaders(_ wrapper.HttpContext, config CustomResponseConfig) types.Action {
// enableOnStatusRuleMap is not empty, compare the status code.
// if match the status code, mock the response.
statusCodeStr, err := proxywasm.GetHttpResponseHeader(":status")
if err != nil {
log.Errorf("get http response status code failed: %v", err)
return types.ActionContinue
}
statusCode, err := strconv.ParseUint(statusCodeStr, 10, 32)
if err != nil {
log.Errorf("parse http response status code failed: %v", err)
if rule, ok := config.enableOnStatusRuleMap[statusCodeStr]; ok {
err = proxywasm.SendHttpResponseWithDetail(rule.statusCode, "custom-response", rule.headers, []byte(rule.body), -1)
if err != nil {
log.Errorf("send http response failed: %v", err)
}
return types.ActionContinue
}
for _, v := range config.enableOnStatus {
if uint32(statusCode) == v {
err = proxywasm.SendHttpResponseWithDetail(config.statusCode, "custom-response", config.headers, []byte(config.body), -1)
if err != nil {
log.Errorf("send http response failed: %v", err)
}
if rule, match := fuzzyMatchCode(config.enableOnStatusRuleMap, statusCodeStr); match {
err = proxywasm.SendHttpResponseWithDetail(rule.statusCode, "custom-response", rule.headers, []byte(rule.body), -1)
if err != nil {
log.Errorf("send http response failed: %v", err)
}
return types.ActionContinue
}
return types.ActionContinue
}
func fuzzyMatchCode(statusRuleMap map[string]*CustomResponseRule, statusCode string) (*CustomResponseRule, bool) {
if len(statusRuleMap) == 0 || statusCode == "" {
return nil, false
}
codeLen := len(statusCode)
for pattern, rule := range statusRuleMap {
// 规则1模式长度必须与状态码一致
if len(pattern) != codeLen {
continue
}
// 纯数字的enableOnStatus已经判断过跳过
if !strings.Contains(pattern, "x") {
continue
}
// 规则2所有数字位必须精确匹配
match := true
for i, c := range pattern {
// 如果是数字位需要校验
if c >= '0' && c <= '9' {
// 边界检查防止panic
if i >= codeLen || statusCode[i] != byte(c) {
match = false
break
}
}
// 非数字位如x自动匹配
}
if match {
return rule, true
}
}
return nil, false
}

View File

@@ -0,0 +1,80 @@
package main
import (
"testing"
)
func Test_prefixMatchCode(t *testing.T) {
rules := map[string]*CustomResponseRule{
"x01": {},
"2x3": {},
"45x": {},
"6xx": {},
"x7x": {},
"xx8": {},
}
tests := []struct {
code string
expectHit bool
}{
{"101", true}, // 匹配x01
{"201", true}, // 匹配x01
{"111", false}, // 不匹配
{"203", true}, // 匹配2x3
{"213", true}, // 匹配2x3
{"450", true}, // 匹配45x
{"451", true}, // 匹配45x
{"600", true}, // 匹配6xx
{"611", true}, // 匹配6xx
{"612", true}, // 匹配6xx
{"171", true}, // 匹配x7x
{"161", false}, // 不匹配
{"228", true}, // 匹配xx8
{"229", false}, // 不匹配
{"123", false}, // 不匹配
}
for _, tt := range tests {
_, found := fuzzyMatchCode(rules, tt.code)
if found != tt.expectHit {
t.Errorf("code:%s expect:%v got:%v", tt.code, tt.expectHit, found)
}
}
}
func TestIsValidPrefixString(t *testing.T) {
tests := []struct {
input string
expected string
hasError bool
}{
{"x1x", "x1x", false},
{"X2X", "x2x", false},
{"xx1", "xx1", false},
{"x12", "x12", false},
{"1x2", "1x2", false},
{"12x", "12x", false},
{"123", "", true}, // 缺少x
{"xxx", "", true}, // 缺少数字
{"xYx", "", true}, // 非法字符
{"x1", "", true}, // 长度不足
{"x123", "", true}, // 长度超限
}
for _, tt := range tests {
result, err := isValidFuzzyMatchString(tt.input)
if tt.hasError {
if err == nil {
t.Errorf("%q: expected error but got none", tt.input)
}
} else {
if err != nil {
t.Errorf("%q: unexpected error: %v", tt.input, err)
}
if result != tt.expected {
t.Errorf("%q: expected %q, got %q", tt.input, tt.expected, result)
}
}
}
}

View File

@@ -0,0 +1,11 @@
.PHONY: reload
build:
tinygo build -o main.wasm -scheduler=none -target=wasi -gc=custom -tags='custommalloc nottinygc_finalizer' ./main.go
reload:
tinygo build -o main.wasm -scheduler=none -target=wasi -gc=custom -tags='custommalloc nottinygc_finalizer' ./main.go
./envoy -c envoy.yaml --concurrency 0 --log-level info --component-log-level wasm:debug
start:
./envoy -c envoy.yaml --concurrency 0 --log-level info --component-log-level wasm:debug

View File

@@ -9,8 +9,8 @@ description: 前端灰度插件配置参考
## 运行属性
插件执行阶段:`认阶段`
插件执行优先级:`450`
插件执行阶段:`认阶段`
插件执行优先级:`1000`
## 配置字段
@@ -19,16 +19,17 @@ description: 前端灰度插件配置参考
| `grayKey` | string | 非必填 | - | 用户ID的唯一标识可以来自Cookie或者Header中比如 userid如果没有填写则使用`rules[].grayTagKey``rules[].grayTagValue`过滤灰度规则 |
| `localStorageGrayKey` | string | 非必填 | - | 使用JWT鉴权方式用户ID的唯一标识来自`localStorage`中,如果配置了当前参数,则`grayKey`失效 |
| `graySubKey` | string | 非必填 | - | 用户身份信息可能以JSON形式透出比如`userInfo:{ userCode:"001" }`,当前例子`graySubKey`取值为`userCode` |
| `userStickyMaxAge` | int | 非必填 | 172800 | 用户粘滞的时长:单位为秒,默认为`172800`2天时间 |
| `includePathPrefixes` | array of strings | 非必填 | - | 强制处理的路径。例如,在 微前端 场景下XHR 接口如: `/resource/xxx`本质是一个资源请求,需要走插件转发逻辑。 |
| `skippedPathPrefixes` | array of strings | 非必填 | - | 用于排除特定路径,避免当前插件处理这些请求。例如,在 rewrite 场景下XHR 接口请求 `/api/xxx` 如果经过插件转发逻辑,可能会导致非预期的结果。 |
| `storeMaxAge` | int | 非必填 | 60 * 60 * 24 * 365 | 网关设置Cookie最大存储时长单位为秒默认为1年 |
| `indexPaths` | array of strings | 非必填 | - | 强制处理的路径,支持 `Glob` 模式匹配。例如:在 微前端场景下XHR 接口如: `/resource/**/manifest-main.json`本质是一个资源请求,需要走插件转发逻辑。 |
| `skippedPaths` | array of strings | 非必填 | - | 用于排除特定路径,避免当前插件处理这些请求,支持 `Glob` 模式匹配。例如,在 rewrite 场景下XHR 接口请求 `/api/**` 如果经过插件转发逻辑,可能会导致非预期的结果。 |
| `skippedByHeaders` | map of string to string | 非必填 | - | 用于通过请求头过滤,指定哪些请求不被当前插件
处理。`skippedPathPrefixes` 的优先级高于当前配置且页面HTML请求不受本配置的影响。若本配置为空,默认会判断`sec-fetch-mode=cors`以及`upgrade=websocket`两个header头进行过滤 |
处理。`skippedPaths` 的优先级高于当前配置且页面HTML请求不受本配置的影响。 |
| `rules` | array of object | 必填 | - | 用户定义不同的灰度规则,适配不同的灰度场景 |
| `rewrite` | object | 必填 | - | 重写配置一般用于OSS/CDN前端部署的重写配置 |
| `baseDeployment` | object | 非必填 | - | 配置Base基线规则的配置 |
| `grayDeployments` | array of object | 非必填 | - | 配置Gray灰度的生效规则以及生效版本 |
| `backendGrayTag` | string | 非必填 | `x-mse-tag` | 后端灰度版本Tag如果配置了cookie中将携带值为`${backendGrayTag}:${grayDeployments[].backendVersion}` |
| `uniqueGrayTag` | string | 非必填 | `x-higress-uid` | 开启按照比例灰度时候,网关会生成一个唯一标识存在`cookie`一方面用于session黏贴另一方面后端也可以使用这个值用于全链路的灰度串联 |
| `injection` | object | 非必填 | - | 往首页HTML中注入全局信息比如`<script>window.global = {...}</script>` |
@@ -50,7 +51,6 @@ description: 前端灰度插件配置参考
| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
|------------|--------------|------|-----|------------------------------|
| `host` | string | 非必填 | - | host地址如果是OSS则设置为 VPC 内网访问地址 |
| `notFoundUri` | string | 非必填 | - | 404 页面配置 |
| `indexRouting` | map of string to string | 非必填 | - | 用于定义首页重写路由规则。每个键 (Key) 表示首页的路由路径,值 (Value) 则指向重定向的目标文件。例如,键为 `/app1` 对应的值为 `/mfe/app1/{version}/index.html`。生效version为`0.0.1` 访问路径为 `/app1`,则重定向到 `/mfe/app1/0.0.1/index.html`。 |
| `fileRouting` | map of string to string | 非必填 | - | 用于定义资源文件重写路由规则。每个键 (Key) 表示资源访问路径,值 (Value) 则指向重定向的目标文件。例如,键为 `/app1/` 对应的值为 `/mfe/app1/{version}`。生效version为`0.0.1`,访问路径为 `/app1/js/a.js`,则重定向到 `/mfe/app1/0.0.1/js/a.js`。 |
@@ -59,6 +59,7 @@ description: 前端灰度插件配置参考
| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
|----------------|--------------|------|-----|-----------------------------------------------------------------------------------|
| `version` | string | 必填 | - | Base版本的版本号作为兜底的版本 |
| `backendVersion` | string | 必填 | - | 后端灰度版本,配合`key``${backendGrayTag}`写入cookie中 |
| `versionPredicates` | string | 必填 | - | 和`version`含义相同,但是满足多版本的需求:根据不同路由映射不同的`Version`版本。一般用于微前端的场景:一个主应用需要管理多个微应用 |
`grayDeployments`字段配置说明:
@@ -70,17 +71,28 @@ description: 前端灰度插件配置参考
| `backendVersion` | string | 必填 | - | 后端灰度版本,配合`key``${backendGrayTag}`写入cookie中 |
| `name` | string | 必填 | - | 规则名称和`rules[].name`关联 |
| `enabled` | boolean | 必填 | - | 是否启动当前灰度规则 |
| `weight` | int | 非必填 | - | 按照比例灰度,比如`50`。注意灰度规则权重总和不能超过100如果同时配置了`grayKey`以及`grayDeployments[0].weight`按照比例灰度优先生效 |
> 为了实现按比例weight 进行灰度发布,并确保用户粘滞,我们需要确认客户端的唯一性。如果配置了 grayKey则将其用作唯一标识如果未配置 grayKey则使用客户端的访问 IP 地址作为唯一标识。
| `weight` | int | 非必填 | - | 按照比例灰度,比如50。 |
>按照比例灰度注意下面几点:
> 1. 如果同时配置了`按用户灰度`以及`按比例灰度`,按`比例灰度`优先生效
> 2. 采用客户端设备标识符的哈希摘要机制实现流量比例控制其唯一性判定逻辑遵循以下原则自动生成全局唯一标识符UUID作为设备指纹可以通过`uniqueGrayTag`配置`cookie`的key值并通过SHA-256哈希算法生成对应灰度判定基准值。
`injection`字段配置说明:
| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
|--------|--------|------|-----|-------------------------------------------------|
| `globalConfig` | object | 非必填 | - | 注入到HTML首页的全局变量 |
| `head` | array of string | 非必填 | - | 注入head信息比如`<link rel="stylesheet" href="https://cdn.example.com/styles.css">` |
| `body` | object | 非必填 | - | 注入Body |
`injection.globalConfig`字段配置说明:
| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
|--------|--------|------|-----|-------------------------------------------------|
| `key` | string | 非必填 | HIGRESS_CONSOLE_CONFIG | 注入到window全局变量的key值 |
| `featureKey` | string | 非必填 | FEATURE_STATUS | 关于`rules`相关规则的命中情况,返回实例`{"beta-user":true,"inner-user":false}` |
| `value` | string | 非必填 | - | 自定义的全局变量 |
| `enabled` | boolean | 非必填 | false | 是否开启注入全局变量 |
`injection.body`字段配置说明:
| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
|--------|--------|------|-----|-------------------------------------------------|
@@ -139,8 +151,7 @@ grayDeployments:
enabled: true
weight: 80
```
总的灰度规则为100%,其中灰度版本的权重为`80%`,基线版本为`20%`。一旦用户命中了灰度规则会根据IP固定这个用户的灰度版本否则会在下次请求时随机选择一个灰度版本
总的灰度规则为100%其中灰度版本的权重为80%基线版本为20%。
### 用户信息存在JSON中
```yml
@@ -218,7 +229,6 @@ rules:
- level5
rewrite:
host: frontend-gray.oss-cn-shanghai-internal.aliyuncs.com
notFoundUri: /mfe/app1/dev/404.html
indexRouting:
/app1: '/mfe/app1/{version}/index.html'
/: '/mfe/app1/{version}/index.html',
@@ -260,7 +270,6 @@ grayDeployments:
- name: beta-user
version: gray
enabled: true
weight: 80
injection:
head:
- <script>console.log('Header')</script>

View File

@@ -1,59 +1,95 @@
---
title: Frontend Gray
keywords: [higress, frontend gray]
description: Frontend gray plugin configuration reference
---
## Function Description
The `frontend-gray` plugin implements the functionality of user gray release on the frontend. Through this plugin, it can be used for business `A/B testing`, while the `gradual release` combined with `monitorable` and `rollback` strategies ensures the stability of system release operations.
description: Frontend Gray Plugin Configuration Reference
## Runtime Attributes
Plugin execution phase: `Authentication Phase`
Plugin execution priority: `450`
## Feature Description
The `frontend-gray` plugin implements frontend user grayscale capabilities. This plugin can be used for business `A/B testing` while ensuring system release stability through `grayscale`, `monitoring`, and `rollback` strategies.
## Runtime Properties
Execution Stage: `Default Stage`
Execution Priority: `1000`
## Configuration Fields
| Name | Data Type | Requirements | Default Value | Description |
|-----------------|-------------------|---------------|---------------|-------------------------------------------------------------------------------------------------------------|
| `grayKey` | string | Optional | - | The unique identifier of the user ID, which can be from Cookie or Header, such as userid. If not provided, uses `rules[].grayTagKey` and `rules[].grayTagValue` to filter gray release rules. |
| `graySubKey` | string | Optional | - | User identity information may be output in JSON format, for example: `userInfo:{ userCode:"001" }`, in the current example, `graySubKey` is `userCode`. |
| `rules` | array of object | Required | - | User-defined different gray release rules, adapted to different gray release scenarios. |
| `rewrite` | object | Required | - | Rewrite configuration, generally used for OSS/CDN frontend deployment rewrite configurations. |
| `baseDeployment`| object | Optional | - | Configuration of the Base baseline rules. |
| `grayDeployments` | array of object | Optional | - | Configuration of the effective rules for gray release, as well as the effective versions. |
| Name | Data Type | Required | Default | Description |
|------|-----------|----------|---------|-------------|
| `grayKey` | string | Optional | - | Unique user identifier from Cookie/Header (e.g., userid). If empty, uses `rules[].grayTagKey` and `rules[].grayTagValue` to filter rules. |
| `localStorageGrayKey` | string | Optional | - | When using JWT authentication, user ID comes from `localStorage`. Overrides `grayKey` if configured. |
| `graySubKey` | string | Optional | - | Used when user info is in JSON format (e.g., `userInfo:{ userCode:"001" }`). In this example, `graySubKey` would be `userCode`. |
| `storeMaxAge` | int | Optional | 31536000 | Max cookie storage duration in seconds (default: 1 year). |
| `indexPaths` | string[] | Optional | - | Paths requiring mandatory processing (supports Glob patterns). Example: `/resource/**/manifest-main.json` in micro-frontend scenarios. |
| `skippedPaths` | string[] | Optional | - | Excluded paths (supports Glob patterns). Example: `/api/**` XHR requests in rewrite scenarios. |
| `skippedByHeaders` | map<string, string> | Optional | - | Filter requests via headers. `skippedPaths` has higher priority. HTML page requests are unaffected. |
| `rules` | object[] | Required | - | User-defined grayscale rules for different scenarios. |
| `rewrite` | object | Required | - | Rewrite configuration for OSS/CDN deployments. |
| `baseDeployment` | object | Optional | - | Baseline configuration. |
| `grayDeployments` | object[] | Optional | - | Gray deployment rules and versions. |
| `backendGrayTag` | string | Optional | `x-mse-tag` | Backend grayscale tag. Cookies will carry `${backendGrayTag}:${grayDeployments[].backendVersion}` if configured. |
| `uniqueGrayTag` | string | Optional | `x-higress-uid` | UUID stored in cookies for percentage-based grayscale session stickiness and backend tracking. |
| `injection` | object | Optional | - | Inject global info into HTML (e.g., `<script>window.global = {...}</script>`). |
`rules` field configuration description:
| Name | Data Type | Requirements | Default Value | Description |
|------------------|-------------------|---------------|---------------|--------------------------------------------------------------------------------------------|
| `name` | string | Required | - | Unique identifier for the rule name, associated with `deploy.gray[].name` for effectiveness. |
| `grayKeyValue` | array of string | Optional | - | Whitelist of user IDs. |
| `grayTagKey` | string | Optional | - | Label key for user classification tagging, derived from Cookie. |
| `grayTagValue` | array of string | Optional | - | Label value for user classification tagging, derived from Cookie. |
### `rules` Field
| Name | Data Type | Required | Default | Description |
|------|-----------|----------|---------|-------------|
| `name` | string | Required | - | Unique rule name linked to `grayDeployments[].name`. |
| `grayKeyValue` | string[] | Optional | - | User ID whitelist. |
| `grayTagKey` | string | Optional | - | User tag key from cookies. |
| `grayTagValue` | string[] | Optional | - | User tag values from cookies. |
`rewrite` field configuration description:
> `indexRouting` homepage rewrite and `fileRouting` file rewrite essentially use prefix matching, for example, `/app1`: `/mfe/app1/{version}/index.html` represents requests with the prefix /app1 routed to `/mfe/app1/{version}/index.html` page, where `{version}` represents the version number, which will be dynamically replaced by `baseDeployment.version` or `grayDeployments[].version` during execution.
> `{version}` will be replaced dynamically during execution by the frontend version from `baseDeployment.version` or `grayDeployments[].version`.
### `rewrite` Field
> Both `indexRouting` and `fileRouting` use prefix matching. The `{version}` placeholder will be dynamically replaced by `baseDeployment.version` or `grayDeployments[].version`.
| Name | Data Type | Requirements | Default Value | Description |
|------------------|-------------------|---------------|---------------|---------------------------------------|
| `host` | string | Optional | - | Host address, if OSS set to the VPC internal access address. |
| `notFoundUri` | string | Optional | - | 404 page configuration. |
| `indexRouting` | map of string to string | Optional | - | Defines the homepage rewrite routing rules. Each key represents the homepage routing path, and the value points to the redirect target file. For example, the key `/app1` corresponds to the value `/mfe/app1/{version}/index.html`. If the effective version is `0.0.1`, the access path is `/app1`, it redirects to `/mfe/app1/0.0.1/index.html`. |
| `fileRouting` | map of string to string | Optional | - | Defines resource file rewrite routing rules. Each key represents the resource access path, and the value points to the redirect target file. For example, the key `/app1/` corresponds to the value `/mfe/app1/{version}`. If the effective version is `0.0.1`, the access path is `/app1/js/a.js`, it redirects to `/mfe/app1/0.0.1/js/a.js`. |
| Name | Data Type | Required | Default | Description |
|------|-----------|----------|---------|-------------|
| `host` | string | Optional | - | Host address (use VPC endpoint for OSS). |
| `indexRouting` | map<string, string> | Optional | - | Homepage rewrite rules. Key: route path, Value: target file. Example: `/app1``/mfe/app1/{version}/index.html`. |
| `fileRouting` | map<string, string> | Optional | - | Resource rewrite rules. Key: resource path, Value: target path. Example: `/app1/` `/mfe/app1/{version}`. |
`baseDeployment` field configuration description:
| Name | Data Type | Requirements | Default Value | Description |
|------------------|-------------------|---------------|---------------|-------------------------------------------------------------------------------------------|
| `version` | string | Required | - | The version number of the Base version, as a fallback version. |
### `baseDeployment` Field
| Name | Data Type | Required | Default | Description |
|------|-----------|----------|---------|-------------|
| `version` | string | Required | - | Baseline version as fallback. |
| `backendVersion` | string | Required | - | Backend grayscale version written to cookies via `${backendGrayTag}`. |
| `versionPredicates` | string | Required | - | Supports multi-version mapping for micro-frontend scenarios. |
`grayDeployments` field configuration description:
| Name | Data Type | Requirements | Default Value | Description |
|------------------|-------------------|---------------|---------------|----------------------------------------------------------------------------------------------|
| `version` | string | Required | - | Version number of the Gray version, if the gray rules are hit, this version will be used. If it is a non-CDN deployment, add `x-higress-tag` to the header. |
| `backendVersion` | string | Required | - | Gray version for the backend, which will add `x-mse-tag` to the header of `XHR/Fetch` requests. |
| `name` | string | Required | - | Rule name associated with `rules[].name`. |
| `enabled` | boolean | Required | - | Whether to activate the current gray release rule. |
### `grayDeployments` Field
| Name | Data Type | Required | Default | Description |
|------|-----------|----------|---------|-------------|
| `version` | string | Required | - | Gray version used when rules match. Adds `x-higress-tag` header for non-CDN deployments. |
| `versionPredicates` | string | Required | - | Multi-version support for micro-frontends. |
| `backendVersion` | string | Required | - | Backend grayscale version for cookies. |
| `name` | string | Required | - | Linked to `rules[].name`. |
| `enabled` | boolean | Required | - | Enable/disable rule. |
| `weight` | int | Optional | - | Traffic percentage (e.g., 50). |
## Configuration Example
### Basic Configuration
> **Percentage-based Grayscale Notes**:
> 1. Percentage rules override user-based rules when both exist.
> 2. Uses UUID fingerprint hashed via SHA-256 for traffic distribution.
### `injection` Field
| Name | Data Type | Required | Default | Description |
|------|-----------|----------|---------|-------------|
| `globalConfig` | object | Optional | - | Global variables injected into HTML. |
| `head` | string[] | Optional | - | Inject elements into `<head>`. |
| `body` | object | Optional | - | Inject elements into `<body>`. |
#### `globalConfig` Sub-field
| Name | Data Type | Required | Default | Description |
|------|-----------|----------|---------|-------------|
| `key` | string | Optional | `HIGRESS_CONSOLE_CONFIG` | Window global variable key. |
| `featureKey` | string | Optional | `FEATURE_STATUS` | Rule hit status (e.g., `{"beta-user":true}`). |
| `value` | string | Optional | - | Custom global value. |
| `enabled` | boolean | Optional | `false` | Enable global injection. |
#### `body` Sub-field
| Name | Data Type | Required | Default | Description |
|------|-----------|----------|---------|-------------|
| `first` | string[] | Optional | - | Inject at body start. |
| `after` | string[] | Optional | - | Inject at body end. |
## Configuration Examples
### Basic Configuration (User-based)
```yml
grayKey: userid
rules:
@@ -75,88 +111,3 @@ grayDeployments:
- name: beta-user
version: gray
enabled: true
```
The unique identifier of the user in the cookie is `userid`, and the current gray release rule has configured the `beta-user` rule.
When the following conditions are met, the version `version: gray` will be used:
- `userid` in the cookie equals `00000002` or `00000003`
- Users whose `level` in the cookie equals `level3` or `level5`
Otherwise, use version `version: base`.
### User Information Exists in JSON
```yml
grayKey: appInfo
graySubKey: userId
rules:
- name: inner-user
grayKeyValue:
- '00000001'
- '00000005'
- name: beta-user
grayKeyValue:
- '00000002'
- '00000003'
grayTagKey: level
grayTagValue:
- level3
- level5
baseDeployment:
version: base
grayDeployments:
- name: beta-user
version: gray
enabled: true
```
The cookie contains JSON data for `appInfo`, which includes the field `userId` as the current unique identifier.
The current gray release rule has configured the `beta-user` rule.
When the following conditions are met, the version `version: gray` will be used:
- `userid` in the cookie equals `00000002` or `00000003`
- Users whose `level` in the cookie equals `level3` or `level5`
Otherwise, use version `version: base`.
### Rewrite Configuration
> Generally used in CDN deployment scenarios.
```yml
grayKey: userid
rules:
- name: inner-user
grayKeyValue:
- '00000001'
- '00000005'
- name: beta-user
grayKeyValue:
- '00000002'
- '00000003'
grayTagKey: level
grayTagValue:
- level3
- level5
rewrite:
host: frontend-gray.oss-cn-shanghai-internal.aliyuncs.com
notFoundUri: /mfe/app1/dev/404.html
indexRouting:
/app1: '/mfe/app1/{version}/index.html'
/: '/mfe/app1/{version}/index.html',
fileRouting:
/: '/mfe/app1/{version}'
/app1/: '/mfe/app1/{version}'
baseDeployment:
version: base
grayDeployments:
- name: beta-user
version: gray
enabled: true
```
`{version}` will be dynamically replaced with the actual version during execution.
#### indexRouting: Homepage Route Configuration
Accessing `/app1`, `/app123`, `/app1/index.html`, `/app1/xxx`, `/xxxx` will route to '/mfe/app1/{version}/index.html'.
#### fileRouting: File Route Configuration
The following file mappings are effective:
- `/js/a.js` => `/mfe/app1/v1.0.0/js/a.js`
- `/js/template/a.js` => `/mfe/app1/v1.0.0/js/template/a.js`
- `/app1/js/a.js` => `/mfe/app1/v1.0.0/js/a.js`
- `/app1/js/template/a.js` => `/mfe/app1/v1.0.0/js/template/a.js`

View File

@@ -1,23 +1,23 @@
package config
import (
"errors"
"path/filepath"
"regexp"
"strconv"
"strings"
"github.com/tidwall/gjson"
)
const (
XHigressTag = "x-higress-tag"
XUniqueClientId = "x-unique-client"
XPreHigressTag = "x-pre-higress-tag"
IsPageRequest = "is-page-request"
IsNotFound = "is-not-found"
EnabledGray = "enabled-gray"
SecFetchMode = "sec-fetch-mode"
XHigressTag = "x-higress-tag"
PreHigressVersion = "pre-higress-version"
IsHtmlRequest = "is-html-request"
IsIndexRequest = "is-index-request"
EnabledGray = "enabled-gray"
)
type LogInfo func(format string, args ...interface{})
type GrayRule struct {
Name string
GrayKeyValue []string
@@ -35,15 +35,22 @@ type Deployment struct {
}
type Rewrite struct {
Host string
NotFound string
Index map[string]string
File map[string]string
Host string
Index map[string]string
File map[string]string
}
type Injection struct {
Head []string
Body *BodyInjection
GlobalConfig *GlobalConfig
Head []string
Body *BodyInjection
}
type GlobalConfig struct {
Key string
FeatureKey string
Value string
Enabled bool
}
type BodyInjection struct {
@@ -52,8 +59,7 @@ type BodyInjection struct {
}
type GrayConfig struct {
UserStickyMaxAge string
TotalGrayWeight int
StoreMaxAge int
GrayKey string
LocalStorageGrayKey string
GraySubKey string
@@ -63,10 +69,26 @@ type GrayConfig struct {
BaseDeployment *Deployment
GrayDeployments []*Deployment
BackendGrayTag string
UniqueGrayTag string
Injection *Injection
SkippedPathPrefixes []string
IncludePathPrefixes []string
SkippedPaths []string
SkippedByHeaders map[string]string
IndexPaths []string
GrayWeight int
}
func isValidName(s string) bool {
// 定义一个正则表达式,匹配字母、数字和下划线
re := regexp.MustCompile(`^[a-zA-Z0-9_]+$`)
return re.MatchString(s)
}
func GetWithDefault(json gjson.Result, path, defaultValue string) string {
res := json.Get(path)
if res.Exists() {
return res.String()
}
return defaultValue
}
func convertToStringList(results []gjson.Result) []string {
@@ -77,6 +99,22 @@ func convertToStringList(results []gjson.Result) []string {
return interfaces
}
func compatibleConvertToStringList(results []gjson.Result, compatibleResults []gjson.Result) []string {
// 优先使用兼容模式的数据
if len(compatibleResults) == 0 {
interfaces := make([]string, len(results)) // 预分配切片容量
for i, result := range results {
interfaces[i] = result.String() // 使用 String() 方法直接获取字符串
}
return interfaces
}
compatibleInterfaces := make([]string, len(compatibleResults)) // 预分配切片容量
for i, result := range compatibleResults {
compatibleInterfaces[i] = filepath.Join(result.String(), "**")
}
return compatibleInterfaces
}
func convertToStringMap(result gjson.Result) map[string]string {
m := make(map[string]string)
result.ForEach(func(key, value gjson.Result) bool {
@@ -86,7 +124,7 @@ func convertToStringMap(result gjson.Result) map[string]string {
return m
}
func JsonToGrayConfig(json gjson.Result, grayConfig *GrayConfig) {
func JsonToGrayConfig(json gjson.Result, grayConfig *GrayConfig) error {
// 解析 GrayKey
grayConfig.LocalStorageGrayKey = json.Get("localStorageGrayKey").String()
grayConfig.GrayKey = json.Get("grayKey").String()
@@ -94,22 +132,18 @@ func JsonToGrayConfig(json gjson.Result, grayConfig *GrayConfig) {
grayConfig.GrayKey = grayConfig.LocalStorageGrayKey
}
grayConfig.GraySubKey = json.Get("graySubKey").String()
grayConfig.BackendGrayTag = json.Get("backendGrayTag").String()
grayConfig.UserStickyMaxAge = json.Get("userStickyMaxAge").String()
grayConfig.BackendGrayTag = GetWithDefault(json, "backendGrayTag", "x-mse-tag")
grayConfig.UniqueGrayTag = GetWithDefault(json, "uniqueGrayTag", "x-higress-uid")
grayConfig.StoreMaxAge = 60 * 60 * 24 * 365 // 默认一年
storeMaxAge, err := strconv.Atoi(GetWithDefault(json, "StoreMaxAge", strconv.Itoa(grayConfig.StoreMaxAge)))
if err != nil {
grayConfig.StoreMaxAge = storeMaxAge
}
grayConfig.Html = json.Get("html").String()
grayConfig.SkippedPathPrefixes = convertToStringList(json.Get("skippedPathPrefixes").Array())
grayConfig.SkippedPaths = compatibleConvertToStringList(json.Get("skippedPaths").Array(), json.Get("skippedPathPrefixes").Array())
grayConfig.IndexPaths = compatibleConvertToStringList(json.Get("indexPaths").Array(), json.Get("includePathPrefixes").Array())
grayConfig.SkippedByHeaders = convertToStringMap(json.Get("skippedByHeaders"))
grayConfig.IncludePathPrefixes = convertToStringList(json.Get("includePathPrefixes").Array())
if grayConfig.UserStickyMaxAge == "" {
// 默认值2天
grayConfig.UserStickyMaxAge = "172800"
}
if grayConfig.BackendGrayTag == "" {
grayConfig.BackendGrayTag = "x-mse-tag"
}
// 解析 Rules
rules := json.Get("rules").Array()
for _, rule := range rules {
@@ -122,10 +156,9 @@ func JsonToGrayConfig(json gjson.Result, grayConfig *GrayConfig) {
grayConfig.Rules = append(grayConfig.Rules, &grayRule)
}
grayConfig.Rewrite = &Rewrite{
Host: json.Get("rewrite.host").String(),
NotFound: json.Get("rewrite.notFoundUri").String(),
Index: convertToStringMap(json.Get("rewrite.indexRouting")),
File: convertToStringMap(json.Get("rewrite.fileRouting")),
Host: json.Get("rewrite.host").String(),
Index: convertToStringMap(json.Get("rewrite.indexRouting")),
File: convertToStringMap(json.Get("rewrite.fileRouting")),
}
// 解析 deployment
@@ -134,6 +167,7 @@ func JsonToGrayConfig(json gjson.Result, grayConfig *GrayConfig) {
grayConfig.BaseDeployment = &Deployment{
Name: baseDeployment.Get("name").String(),
BackendVersion: baseDeployment.Get("backendVersion").String(),
Version: strings.Trim(baseDeployment.Get("version").String(), " "),
VersionPredicates: convertToStringMap(baseDeployment.Get("versionPredicates")),
}
@@ -141,16 +175,28 @@ func JsonToGrayConfig(json gjson.Result, grayConfig *GrayConfig) {
if !item.Get("enabled").Bool() {
continue
}
grayWeight := int(item.Get("weight").Int())
weight := int(item.Get("weight").Int())
grayConfig.GrayDeployments = append(grayConfig.GrayDeployments, &Deployment{
Name: item.Get("name").String(),
Enabled: item.Get("enabled").Bool(),
Version: strings.Trim(item.Get("version").String(), " "),
BackendVersion: item.Get("backendVersion").String(),
Weight: grayWeight,
Weight: weight,
VersionPredicates: convertToStringMap(item.Get("versionPredicates")),
})
grayConfig.TotalGrayWeight += grayWeight
if weight > 0 {
grayConfig.GrayWeight = weight
break
}
}
injectGlobalFeatureKey := GetWithDefault(json, "injection.globalConfig.featureKey", "FEATURE_STATUS")
injectGlobalKey := GetWithDefault(json, "injection.globalConfig.key", "HIGRESS_CONSOLE_CONFIG")
if !isValidName(injectGlobalFeatureKey) {
return errors.New("injection.globalConfig.featureKey is invalid")
}
if !isValidName(injectGlobalKey) {
return errors.New("injection.globalConfig.featureKey is invalid")
}
grayConfig.Injection = &Injection{
@@ -159,5 +205,12 @@ func JsonToGrayConfig(json gjson.Result, grayConfig *GrayConfig) {
First: convertToStringList(json.Get("injection.body.first").Array()),
Last: convertToStringList(json.Get("injection.body.last").Array()),
},
GlobalConfig: &GlobalConfig{
FeatureKey: injectGlobalFeatureKey,
Key: injectGlobalKey,
Value: json.Get("injection.globalConfig.value").String(),
Enabled: json.Get("injection.globalConfig.enabled").Bool(),
},
}
return nil
}

View File

@@ -48,8 +48,8 @@ static_resources:
value: |
{
"grayKey": "userId",
"backendGrayTag": "x-mse-tag",
"userStickyMaxAge": 172800,
"backendGrayTag": "env",
"uniqueGrayTag": "uuid",
"rules": [
{
"name": "inner-user",
@@ -72,30 +72,44 @@ static_resources:
}
],
"rewrite": {
"host": "frontend-gray-cn-shanghai.oss-cn-shanghai-internal.aliyuncs.com",
"host": "apig-oss-integration.oss-cn-hangzhou.aliyuncs.com",
"indexRouting": {
"/app1": "/mfe/app1/{version}/index.html",
"/": "/mfe/app1/{version}/index.html"
"/": "/mfe/{version}/index.html"
},
"fileRouting": {
"/": "/mfe/app1/{version}",
"/app1": "/mfe/app1/{version}"
"/": "/mfe/{version}",
"/mfe": "/mfe/{version}"
}
},
"skippedPathPrefixes": [
"/api/"
"skippedPaths": [
"/api/**",
"/v2/**"
],
"indexPaths": [
"/mfe/**/mf-manifest-main.json"
],
"baseDeployment": {
"version": "dev"
"version": "v1"
},
"grayDeployments": [
{
"weight": 90,
"name": "beta-user",
"version": "0.0.1",
"enabled": true
"version": "v2",
"enabled": true,
"backendVersion":"gray",
"versionPredicates": {
"/mfe": "v1"
}
}
],
"injection": {
"globalConfig": {
"key": "HIGRESS_CONSOLE_CONFIG",
"featureKey": "FEATURE_STATUS",
"value": "{CONSOLE_GLOBAL: {'gray':'2.0.15','main':'2.0.15'}}",
"enabled": true
},
"head": [
"<script>console.log('Header')</script>"
],
@@ -127,5 +141,5 @@ static_resources:
- endpoint:
address:
socket_address:
address: frontend-gray-cn-shanghai.oss-cn-shanghai.aliyuncs.com
address: apig-oss-integration.oss-cn-hangzhou.aliyuncs.com
port_value: 80

View File

@@ -6,6 +6,7 @@ replace github.com/alibaba/higress/plugins/wasm-go => ../..
require (
github.com/alibaba/higress/plugins/wasm-go v1.4.3-0.20240727022514-bccfbde62188
github.com/bmatcuk/doublestar/v4 v4.6.1
github.com/higress-group/proxy-wasm-go-sdk v1.0.0
github.com/stretchr/testify v1.9.0
github.com/tidwall/gjson v1.17.3

View File

@@ -1,6 +1,8 @@
github.com/davecgh/go-spew v1.1.1+incompatible/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/bmatcuk/doublestar/v4 v4.6.1 h1:FH9SifrbvJhnlQpztAx++wlkk70QBf0iBWDwNy7PA4I=
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1+incompatible/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/higress-group/nottinygc v0.0.0-20231101025119-e93c4c2f8520 h1:IHDghbGQ2DTIXHBHxWfqCYQW1fKjyJ/I7W1pMyUDeEA=
@@ -11,9 +13,9 @@ github.com/higress-group/proxy-wasm-go-sdk v1.0.0 h1:BZRNf4R7jr9hwRivg/E29nkVaKE
github.com/higress-group/proxy-wasm-go-sdk v1.0.0/go.mod h1:iiSyFbo+rAtbtGt/bsefv8GU57h9CCLYGJA74/tF5/0=
github.com/magefile/mage v1.14.0 h1:6QDX3g6z1YvJ4olPhT1wksUcSa/V0a1B+pJb73fBjyo=
github.com/magefile/mage v1.14.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
github.com/pmezard/go-difflib v1.0.0+incompatible/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.0+incompatible/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=

View File

@@ -2,13 +2,11 @@ package main
import (
"fmt"
"net/http"
"net/url"
"path"
"strings"
"github.com/alibaba/higress/plugins/wasm-go/extensions/frontend-gray/config"
"github.com/alibaba/higress/plugins/wasm-go/extensions/frontend-gray/util"
"github.com/alibaba/higress/plugins/wasm-go/pkg/log"
"github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper"
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm"
@@ -19,44 +17,42 @@ import (
func main() {
wrapper.SetCtx(
"frontend-gray",
wrapper.ParseConfigBy(parseConfig),
wrapper.ProcessRequestHeadersBy(onHttpRequestHeaders),
wrapper.ProcessResponseHeadersBy(onHttpResponseHeader),
wrapper.ProcessResponseBodyBy(onHttpResponseBody),
wrapper.ParseConfig(parseConfig),
wrapper.ProcessRequestHeaders(onHttpRequestHeaders),
wrapper.ProcessResponseHeaders(onHttpResponseHeader),
wrapper.ProcessResponseBody(onHttpResponseBody),
)
}
func parseConfig(json gjson.Result, grayConfig *config.GrayConfig, log wrapper.Log) error {
func parseConfig(json gjson.Result, grayConfig *config.GrayConfig) error {
// 解析json 为GrayConfig
config.JsonToGrayConfig(json, grayConfig)
log.Infof("Rewrite: %v, GrayDeployments: %v", json.Get("rewrite"), json.Get("grayDeployments"))
if err := config.JsonToGrayConfig(json, grayConfig); err != nil {
log.Errorf("failed to parse config: %v", err)
return err
}
return nil
}
func onHttpRequestHeaders(ctx wrapper.HttpContext, grayConfig config.GrayConfig, log wrapper.Log) types.Action {
requestPath, _ := proxywasm.GetHttpRequestHeader(":path")
requestPath = path.Clean(requestPath)
parsedURL, err := url.Parse(requestPath)
if err == nil {
requestPath = parsedURL.Path
} else {
log.Errorf("parse request path %s failed: %v", requestPath, err)
}
enabledGray := util.IsGrayEnabled(grayConfig, requestPath)
func onHttpRequestHeaders(ctx wrapper.HttpContext, grayConfig config.GrayConfig) types.Action {
requestPath := util.GetRequestPath()
enabledGray := util.IsGrayEnabled(requestPath, &grayConfig)
ctx.SetContext(config.EnabledGray, enabledGray)
secFetchMode, _ := proxywasm.GetHttpRequestHeader("sec-fetch-mode")
ctx.SetContext(config.SecFetchMode, secFetchMode)
route, _ := util.GetRouteName()
if !enabledGray {
log.Infof("gray not enabled")
log.Infof("route: %s, gray not enabled, requestPath: %v", route, requestPath)
ctx.DontReadRequestBody()
return types.ActionContinue
}
cookies, _ := proxywasm.GetHttpRequestHeader("cookie")
isPageRequest := util.IsPageRequest(requestPath)
cookie, _ := proxywasm.GetHttpRequestHeader("cookie")
isHtmlRequest := util.CheckIsHtmlRequest(requestPath)
ctx.SetContext(config.IsHtmlRequest, isHtmlRequest)
isIndexRequest := util.IsIndexRequest(requestPath, grayConfig.IndexPaths)
ctx.SetContext(config.IsIndexRequest, isIndexRequest)
hasRewrite := len(grayConfig.Rewrite.File) > 0 || len(grayConfig.Rewrite.Index) > 0
grayKeyValueByCookie := util.ExtractCookieValueByKey(cookies, grayConfig.GrayKey)
grayKeyValueByCookie := util.GetCookieValue(cookie, grayConfig.GrayKey)
grayKeyValueByHeader, _ := proxywasm.GetHttpRequestHeader(grayConfig.GrayKey)
// 优先从cookie中获取否则从header中获取
grayKeyValue := util.GetGrayKey(grayKeyValueByCookie, grayKeyValueByHeader, grayConfig.GraySubKey)
@@ -65,93 +61,92 @@ func onHttpRequestHeaders(ctx wrapper.HttpContext, grayConfig config.GrayConfig,
// 禁止重新路由要在更改Header之前操作否则会失效
ctx.DisableReroute()
}
frontendVersion := util.GetCookieValue(cookie, config.XHigressTag)
if grayConfig.GrayWeight > 0 {
ctx.SetContext(grayConfig.UniqueGrayTag, util.GetGrayWeightUniqueId(cookie, grayConfig.UniqueGrayTag))
}
// 删除Accept-Encoding避免压缩 如果是压缩的内容,后续插件就没法处理了
_ = proxywasm.RemoveHttpRequestHeader("Accept-Encoding")
_ = proxywasm.RemoveHttpRequestHeader("Content-Length")
deployment := &config.Deployment{}
preVersion, preUniqueClientId := util.GetXPreHigressVersion(cookies)
// 客户端唯一ID用于在按照比率灰度时候 客户访问黏贴
uniqueClientId := grayKeyValue
if uniqueClientId == "" {
xForwardedFor, _ := proxywasm.GetHttpRequestHeader("X-Forwarded-For")
uniqueClientId = util.GetRealIpFromXff(xForwardedFor)
globalConfig := grayConfig.Injection.GlobalConfig
if globalConfig.Enabled {
conditionRule := util.GetConditionRules(grayConfig.Rules, grayKeyValue, cookie)
trimmedValue := strings.TrimSuffix(strings.TrimPrefix(strings.TrimSpace(globalConfig.Value), "{"), "}")
ctx.SetContext(globalConfig.Key, fmt.Sprintf("<script>var %s = {\n%s:%s,\n %s \n}\n</script>", globalConfig.Key, globalConfig.FeatureKey, conditionRule, trimmedValue))
}
// 如果没有配置比例,则进行灰度规则匹配
if util.IsSupportMultiVersion(grayConfig) {
deployment = util.FilterMultiVersionGrayRule(&grayConfig, grayKeyValue, requestPath)
log.Infof("multi version %v", deployment)
} else {
if isPageRequest {
if grayConfig.TotalGrayWeight > 0 {
log.Infof("grayConfig.TotalGrayWeight: %v", grayConfig.TotalGrayWeight)
deployment = util.FilterGrayWeight(&grayConfig, preVersion, preUniqueClientId, uniqueClientId)
} else {
deployment = util.FilterGrayRule(&grayConfig, grayKeyValue)
}
log.Infof("index deployment: %v, path: %v, backend: %v, xPreHigressVersion: %s,%s", deployment, requestPath, deployment.BackendVersion, preVersion, preUniqueClientId)
} else {
grayDeployment := util.FilterGrayRule(&grayConfig, grayKeyValue)
deployment = util.GetVersion(grayConfig, grayDeployment, preVersion, isPageRequest)
}
ctx.SetContext(config.XPreHigressTag, deployment.Version)
if isHtmlRequest {
// index首页请求每次都会进度灰度规则判断
deployment = util.FilterGrayRule(&grayConfig, grayKeyValue, cookie)
log.Infof("route: %s, index html request: %v, backend: %v, xPreHigressVersion: %s", route, requestPath, deployment.BackendVersion, frontendVersion)
ctx.SetContext(config.PreHigressVersion, deployment.Version)
ctx.SetContext(grayConfig.BackendGrayTag, deployment.BackendVersion)
} else {
if util.IsSupportMultiVersion(grayConfig) {
deployment = util.FilterMultiVersionGrayRule(&grayConfig, grayKeyValue, cookie, requestPath)
log.Infof("route: %s, multi version %v", route, deployment)
} else {
grayDeployment := util.FilterGrayRule(&grayConfig, grayKeyValue, cookie)
if isIndexRequest {
deployment = grayDeployment
} else {
deployment = util.GetVersion(grayConfig, grayDeployment, frontendVersion)
}
}
}
proxywasm.AddHttpRequestHeader(config.XHigressTag, deployment.Version)
ctx.SetContext(config.IsPageRequest, isPageRequest)
ctx.SetContext(config.XUniqueClientId, uniqueClientId)
rewrite := grayConfig.Rewrite
if rewrite.Host != "" {
err := proxywasm.ReplaceHttpRequestHeader(":authority", rewrite.Host)
if err != nil {
log.Errorf("host rewrite failed: %v", err)
log.Errorf("route: %s, host rewrite failed: %v", route, err)
}
}
if hasRewrite {
rewritePath := requestPath
if isPageRequest {
if isHtmlRequest {
rewritePath = util.IndexRewrite(requestPath, deployment.Version, grayConfig.Rewrite.Index)
} else {
rewritePath = util.PrefixFileRewrite(requestPath, deployment.Version, grayConfig.Rewrite.File)
}
if requestPath != rewritePath {
log.Infof("rewrite path:%s, rewritePath:%s, Version:%v", requestPath, rewritePath, deployment.Version)
log.Infof("route: %s, rewrite path:%s, rewritePath:%s, Version:%v", route, requestPath, rewritePath, deployment.Version)
proxywasm.ReplaceHttpRequestHeader(":path", rewritePath)
}
}
log.Infof("request path:%s, has rewrited:%v, rewrite config:%+v", requestPath, hasRewrite, rewrite)
return types.ActionContinue
}
func onHttpResponseHeader(ctx wrapper.HttpContext, grayConfig config.GrayConfig, log wrapper.Log) types.Action {
func onHttpResponseHeader(ctx wrapper.HttpContext, grayConfig config.GrayConfig) types.Action {
enabledGray, _ := ctx.GetContext(config.EnabledGray).(bool)
if !enabledGray {
ctx.DontReadResponseBody()
return types.ActionContinue
}
secFetchMode, isSecFetchModeOk := ctx.GetContext(config.SecFetchMode).(string)
if isSecFetchModeOk && secFetchMode == "cors" {
isIndexRequest, indexOk := ctx.GetContext(config.IsIndexRequest).(bool)
if indexOk && isIndexRequest {
// 首页请求强制不缓存
proxywasm.ReplaceHttpResponseHeader("cache-control", "no-cache, no-store, max-age=0, must-revalidate")
ctx.DontReadResponseBody()
return types.ActionContinue
}
isPageRequest, ok := ctx.GetContext(config.IsPageRequest).(bool)
if !ok {
isPageRequest = false // 默认值
}
isHtmlRequest, htmlOk := ctx.GetContext(config.IsHtmlRequest).(bool)
// response 不处理非首页的请求
if !isPageRequest {
if !htmlOk || !isHtmlRequest {
ctx.DontReadResponseBody()
return types.ActionContinue
} else {
// 不会进去Streaming 的Body处理
ctx.BufferResponseBody()
}
// 处理HTML的首页
status, err := proxywasm.GetHttpResponseHeader(":status")
if grayConfig.Rewrite != nil && grayConfig.Rewrite.Host != "" {
// 删除Content-Disposition避免自动下载文件
@@ -163,117 +158,81 @@ func onHttpResponseHeader(ctx wrapper.HttpContext, grayConfig config.GrayConfig,
// 处理code为 200的情况
if err != nil || status != "200" {
if status == "404" {
if grayConfig.Rewrite.NotFound != "" && isPageRequest {
ctx.SetContext(config.IsNotFound, true)
responseHeaders, _ := proxywasm.GetHttpResponseHeaders()
headersMap := util.ConvertHeaders(responseHeaders)
if _, ok := headersMap[":status"]; !ok {
headersMap[":status"] = []string{"200"} // 如果没有初始化,设定默认值
} else {
headersMap[":status"][0] = "200" // 修改现有值
}
if _, ok := headersMap["content-type"]; !ok {
headersMap["content-type"] = []string{"text/html"} // 如果没有初始化,设定默认值
} else {
headersMap["content-type"][0] = "text/html" // 修改现有值
}
// 删除 content-length 键
delete(headersMap, "content-length")
proxywasm.ReplaceHttpResponseHeaders(util.ReconvertHeaders(headersMap))
ctx.BufferResponseBody()
return types.ActionContinue
} else {
// 直接返回400
ctx.DontReadResponseBody()
}
// 如果找不到HTML但配置了HTML页面
if status == "404" && grayConfig.Html != "" {
responseHeaders, _ := proxywasm.GetHttpResponseHeaders()
headersMap := util.ConvertHeaders(responseHeaders)
delete(headersMap, "content-length")
headersMap[":status"][0] = "200"
headersMap["content-type"][0] = "text/html"
ctx.BufferResponseBody()
proxywasm.ReplaceHttpResponseHeaders(util.ReconvertHeaders(headersMap))
} else {
route, _ := util.GetRouteName()
log.Errorf("route: %s, request error code: %s, message: %v", route, status, err)
ctx.DontReadResponseBody()
return types.ActionContinue
}
log.Errorf("error status: %s, error message: %v", status, err)
return types.ActionContinue
}
cacheControl, _ := proxywasm.GetHttpResponseHeader("cache-control")
if !strings.Contains(cacheControl, "no-cache") {
proxywasm.ReplaceHttpResponseHeader("cache-control", "no-cache, no-store, max-age=0, must-revalidate")
proxywasm.ReplaceHttpResponseHeader("cache-control", "no-cache, no-store, max-age=0, must-revalidate")
// 前端版本
frontendVersion, isFrontendVersionOk := ctx.GetContext(config.PreHigressVersion).(string)
if isFrontendVersionOk {
proxywasm.AddHttpResponseHeader("Set-Cookie", fmt.Sprintf("%s=%s; Max-Age=%d; Path=/;", config.XHigressTag, frontendVersion, grayConfig.StoreMaxAge))
}
frontendVersion, isFeVersionOk := ctx.GetContext(config.XPreHigressTag).(string)
xUniqueClient, isUniqClientOk := ctx.GetContext(config.XUniqueClientId).(string)
// 设置前端的版本
if isFeVersionOk && isUniqClientOk && frontendVersion != "" {
proxywasm.AddHttpResponseHeader("Set-Cookie", fmt.Sprintf("%s=%s,%s; Max-Age=%s; Path=/;", config.XPreHigressTag, frontendVersion, xUniqueClient, grayConfig.UserStickyMaxAge))
// 设置GrayWeight 唯一值
if grayConfig.GrayWeight > 0 {
uniqueId, isUniqueIdOk := ctx.GetContext(grayConfig.UniqueGrayTag).(string)
if isUniqueIdOk {
proxywasm.AddHttpResponseHeader("Set-Cookie", fmt.Sprintf("%s=%s; Max-Age=%d; Path=/;", grayConfig.UniqueGrayTag, uniqueId, grayConfig.StoreMaxAge))
}
}
// 设置后端的版本
if util.IsBackendGrayEnabled(grayConfig) {
backendVersion, isBackVersionOk := ctx.GetContext(grayConfig.BackendGrayTag).(string)
if isBackVersionOk && backendVersion != "" {
proxywasm.AddHttpResponseHeader("Set-Cookie", fmt.Sprintf("%s=%s; Max-Age=%s; Path=/;", grayConfig.BackendGrayTag, backendVersion, grayConfig.UserStickyMaxAge))
if isBackVersionOk {
if backendVersion == "" {
// 删除后端灰度版本
proxywasm.AddHttpResponseHeader("Set-Cookie", fmt.Sprintf("%s=%s; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=/;", grayConfig.BackendGrayTag, backendVersion))
} else {
proxywasm.AddHttpResponseHeader("Set-Cookie", fmt.Sprintf("%s=%s; Max-Age=%d; Path=/;", grayConfig.BackendGrayTag, backendVersion, grayConfig.StoreMaxAge))
}
}
}
return types.ActionContinue
}
func onHttpResponseBody(ctx wrapper.HttpContext, grayConfig config.GrayConfig, body []byte, log wrapper.Log) types.Action {
func onHttpResponseBody(ctx wrapper.HttpContext, grayConfig config.GrayConfig, body []byte) types.Action {
enabledGray, _ := ctx.GetContext(config.EnabledGray).(bool)
if !enabledGray {
return types.ActionContinue
}
isPageRequest, isPageRequestOk := ctx.GetContext(config.IsPageRequest).(bool)
frontendVersion, isFeVersionOk := ctx.GetContext(config.XPreHigressTag).(string)
isHtmlRequest, isHtmlRequestOk := ctx.GetContext(config.IsHtmlRequest).(bool)
frontendVersion, isFeVersionOk := ctx.GetContext(config.PreHigressVersion).(string)
// 只处理首页相关请求
if !isFeVersionOk || !isPageRequestOk || !isPageRequest {
if !isFeVersionOk || !isHtmlRequestOk || !isHtmlRequest {
return types.ActionContinue
}
isNotFound, ok := ctx.GetContext(config.IsNotFound).(bool)
if !ok {
isNotFound = false // 默认值
globalConfig := grayConfig.Injection.GlobalConfig
globalConfigValue, isGobalConfigOk := ctx.GetContext(globalConfig.Key).(string)
if !isGobalConfigOk {
globalConfigValue = ""
}
// 检查是否存在自定义 HTML 如有则省略 rewrite.indexRouting 的内容
newHtml := string(body)
if grayConfig.Html != "" {
log.Debugf("Returning custom HTML from config.")
// 替换响应体为 config.Html 内容
if err := proxywasm.ReplaceHttpResponseBody([]byte(grayConfig.Html)); err != nil {
log.Errorf("Error replacing response body: %v", err)
return types.ActionContinue
}
newHtml := util.InjectContent(grayConfig.Html, grayConfig.Injection)
// 替换当前html加载的动态文件版本
newHtml = strings.ReplaceAll(newHtml, "{version}", frontendVersion)
// 最终替换响应体
if err := proxywasm.ReplaceHttpResponseBody([]byte(newHtml)); err != nil {
log.Errorf("Error replacing injected response body: %v", err)
return types.ActionContinue
}
return types.ActionContinue
newHtml = grayConfig.Html
}
newHtml = util.InjectContent(newHtml, grayConfig.Injection, globalConfigValue)
// 替换当前html加载的动态文件版本
newHtml = strings.ReplaceAll(newHtml, "{version}", frontendVersion)
newHtml = util.FixLocalStorageKey(newHtml, grayConfig.LocalStorageGrayKey)
// 针对404页面处理
if isNotFound && grayConfig.Rewrite.Host != "" && grayConfig.Rewrite.NotFound != "" {
client := wrapper.NewClusterClient(wrapper.RouteCluster{Host: grayConfig.Rewrite.Host})
client.Get(strings.Replace(grayConfig.Rewrite.NotFound, "{version}", frontendVersion, -1), nil, func(statusCode int, responseHeaders http.Header, responseBody []byte) {
proxywasm.ReplaceHttpResponseBody(responseBody)
proxywasm.ResumeHttpResponse()
}, 1500)
return types.ActionPause
}
// 处理响应体HTML
newBody := string(body)
newBody = util.InjectContent(newBody, grayConfig.Injection)
if grayConfig.LocalStorageGrayKey != "" {
localStr := strings.ReplaceAll(`<script>
!function(){var o,e,n="@@X_GRAY_KEY",t=document.cookie.split("; ").filter(function(o){return 0===o.indexOf(n+"=")});try{"undefined"!=typeof localStorage&&null!==localStorage&&(o=localStorage.getItem(n),e=0<t.length?decodeURIComponent(t[0].split("=")[1]):null,o)&&o.indexOf("=")<0&&e&&e!==o&&(document.cookie=n+"="+encodeURIComponent(o)+"; path=/;",window.location.reload())}catch(o){}}();
</script>
`, "@@X_GRAY_KEY", grayConfig.LocalStorageGrayKey)
newBody = strings.ReplaceAll(newBody, "<body>", "<body>\n"+localStr)
}
if err := proxywasm.ReplaceHttpResponseBody([]byte(newBody)); err != nil {
// 最终替换响应体
if err := proxywasm.ReplaceHttpResponseBody([]byte(newHtml)); err != nil {
route, _ := util.GetRouteName()
log.Errorf("route: %s, Failed to replace response body: %v", route, err)
return types.ActionContinue
}
return types.ActionContinue

View File

@@ -1,15 +1,16 @@
package util
import (
"fmt"
"math/rand"
"encoding/json"
"hash/crc32"
"net/url"
"path"
"path/filepath"
"sort"
"strings"
"time"
"github.com/bmatcuk/doublestar/v4"
"github.com/google/uuid"
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm"
"github.com/alibaba/higress/plugins/wasm-go/extensions/frontend-gray/config"
@@ -17,43 +18,26 @@ import (
"github.com/tidwall/gjson"
)
func LogInfof(format string, args ...interface{}) {
format = fmt.Sprintf("[%s] %s", "frontend-gray", format)
proxywasm.LogInfof(format, args...)
func GetRequestPath() string {
requestPath, _ := proxywasm.GetHttpRequestHeader(":path")
requestPath = path.Clean(requestPath)
parsedURL, err := url.Parse(requestPath)
if err == nil {
requestPath = parsedURL.Path
} else {
return ""
}
return requestPath
}
func GetXPreHigressVersion(cookies string) (string, string) {
xPreHigressVersion := ExtractCookieValueByKey(cookies, config.XPreHigressTag)
preVersions := strings.Split(xPreHigressVersion, ",")
if len(preVersions) == 0 {
return "", ""
func GetRouteName() (string, error) {
if raw, err := proxywasm.GetProperty([]string{"route_name"}); err != nil {
return "-", err
} else {
return string(raw), nil
}
if len(preVersions) == 1 {
return preVersions[0], ""
}
return strings.TrimSpace(preVersions[0]), strings.TrimSpace(preVersions[1])
}
// 从xff中获取真实的IP
func GetRealIpFromXff(xff string) string {
if xff != "" {
// 通常客户端的真实 IP 是 XFF 头中的第一个 IP
ips := strings.Split(xff, ",")
if len(ips) > 0 {
return strings.TrimSpace(ips[0])
}
}
return ""
}
func IsRequestSkippedByHeaders(grayConfig config.GrayConfig) bool {
secFetchMode, _ := proxywasm.GetHttpRequestHeader("sec-fetch-mode")
upgrade, _ := proxywasm.GetHttpRequestHeader("upgrade")
if len(grayConfig.SkippedByHeaders) == 0 {
// 默认不走插件逻辑的header
return secFetchMode == "cors" || upgrade == "websocket"
}
func IsRequestSkippedByHeaders(grayConfig *config.GrayConfig) bool {
for headerKey, headerValue := range grayConfig.SkippedByHeaders {
requestHeader, _ := proxywasm.GetHttpRequestHeader(headerKey)
if requestHeader == headerValue {
@@ -63,34 +47,40 @@ func IsRequestSkippedByHeaders(grayConfig config.GrayConfig) bool {
return false
}
func IsGrayEnabled(grayConfig config.GrayConfig, requestPath string) bool {
for _, prefix := range grayConfig.IncludePathPrefixes {
if strings.HasPrefix(requestPath, prefix) {
func IsIndexRequest(requestPath string, indexPaths []string) bool {
for _, prefix := range indexPaths {
matchResult, err := doublestar.Match(prefix, requestPath)
if err == nil && matchResult {
return true
}
}
return false
}
// 当前路径中前缀为 SkippedPathPrefixes则不走插件逻辑
for _, prefix := range grayConfig.SkippedPathPrefixes {
if strings.HasPrefix(requestPath, prefix) {
func IsGrayEnabled(requestPath string, grayConfig *config.GrayConfig) bool {
if IsIndexRequest(requestPath, grayConfig.IndexPaths) {
return true
}
// 当前路径中前缀为 SkippedPaths则不走插件逻辑
for _, prefix := range grayConfig.SkippedPaths {
matchResult, err := doublestar.Match(prefix, requestPath)
if err == nil && matchResult {
return false
}
}
// 如果是首页,进入插件逻辑
if IsPageRequest(requestPath) {
if CheckIsHtmlRequest(requestPath) {
return true
}
// 检查是否存在重写主机
if grayConfig.Rewrite != nil && grayConfig.Rewrite.Host != "" {
return true
}
// 检查header标识判断是否需要跳过
if IsRequestSkippedByHeaders(grayConfig) {
return false
}
// 检查是否存在重写主机
if grayConfig.Rewrite != nil && grayConfig.Rewrite.Host != "" {
return true
}
// 检查是否存在灰度版本配置
return len(grayConfig.GrayDeployments) > 0
}
@@ -105,8 +95,8 @@ func IsBackendGrayEnabled(grayConfig config.GrayConfig) bool {
return false
}
// ExtractCookieValueByKey 根据 cookie 和 key 获取 cookie 值
func ExtractCookieValueByKey(cookie string, key string) string {
// GetCookieValue 根据 cookie 和 key 获取 cookie 值
func GetCookieValue(cookie string, key string) string {
if cookie == "" {
return ""
}
@@ -170,7 +160,7 @@ var indexSuffixes = []string{
".html", ".htm", ".jsp", ".php", ".asp", ".aspx", ".erb", ".ejs", ".twig",
}
func IsPageRequest(requestPath string) bool {
func CheckIsHtmlRequest(requestPath string) bool {
if requestPath == "/" || requestPath == "" {
return true
}
@@ -227,10 +217,7 @@ func PrefixFileRewrite(path, version string, matchRules map[string]string) strin
return path
}
func GetVersion(grayConfig config.GrayConfig, deployment *config.Deployment, xPreHigressVersion string, isPageRequest bool) *config.Deployment {
if isPageRequest {
return deployment
}
func GetVersion(grayConfig config.GrayConfig, deployment *config.Deployment, xPreHigressVersion string) *config.Deployment {
// cookie 中为空,返回当前版本
if xPreHigressVersion == "" {
return deployment
@@ -295,10 +282,75 @@ func IsSupportMultiVersion(grayConfig config.GrayConfig) bool {
return false
}
func GetConditionRules(rules []*config.GrayRule, grayKeyValue string, cookie string) string {
ruleMaps := map[string]bool{}
for _, grayRule := range rules {
if grayRule.GrayKeyValue != nil && len(grayRule.GrayKeyValue) > 0 && grayKeyValue != "" {
ruleMaps[grayRule.Name] = ContainsValue(grayRule.GrayKeyValue, grayKeyValue)
continue
} else if grayRule.GrayTagKey != "" && grayRule.GrayTagValue != nil && len(grayRule.GrayTagValue) > 0 {
grayTagValue := GetCookieValue(cookie, grayRule.GrayTagKey)
ruleMaps[grayRule.Name] = ContainsValue(grayRule.GrayTagValue, grayTagValue)
continue
} else {
ruleMaps[grayRule.Name] = false
}
}
jsonBytes, err := json.Marshal(ruleMaps)
if err != nil {
return ""
}
return string(jsonBytes)
}
func GetGrayWeightUniqueId(cookie string, uniqueGrayTag string) string {
uniqueId := GetCookieValue(cookie, uniqueGrayTag)
if uniqueId == "" {
uniqueId = strings.ReplaceAll(uuid.NewString(), "-", "")
}
return uniqueId
}
// FilterGrayRule 过滤灰度规则
func FilterGrayRule(grayConfig *config.GrayConfig, grayKeyValue string, cookie string) *config.Deployment {
if grayConfig.GrayWeight > 0 {
uniqueId := GetGrayWeightUniqueId(cookie, grayConfig.UniqueGrayTag)
// 计算哈希后取模
mod := crc32.ChecksumIEEE([]byte(uniqueId)) % 100
isGray := mod < uint32(grayConfig.GrayWeight)
if isGray {
for _, deployment := range grayConfig.GrayDeployments {
if deployment.Enabled && deployment.Weight > 0 {
return deployment
}
}
}
return grayConfig.BaseDeployment
}
for _, deployment := range grayConfig.GrayDeployments {
grayRule := GetRule(grayConfig.Rules, deployment.Name)
// 首先先校验用户名单ID
if grayRule.GrayKeyValue != nil && len(grayRule.GrayKeyValue) > 0 && grayKeyValue != "" {
if ContainsValue(grayRule.GrayKeyValue, grayKeyValue) {
return deployment
}
}
// 第二校验Cookie中的 GrayTagKey
if grayRule.GrayTagKey != "" && grayRule.GrayTagValue != nil && len(grayRule.GrayTagValue) > 0 {
grayTagValue := GetCookieValue(cookie, grayRule.GrayTagKey)
if ContainsValue(grayRule.GrayTagValue, grayTagValue) {
return deployment
}
}
}
return grayConfig.BaseDeployment
}
// FilterMultiVersionGrayRule 过滤多版本灰度规则
func FilterMultiVersionGrayRule(grayConfig *config.GrayConfig, grayKeyValue string, requestPath string) *config.Deployment {
func FilterMultiVersionGrayRule(grayConfig *config.GrayConfig, grayKeyValue string, cookie string, requestPath string) *config.Deployment {
// 首先根据灰度键值获取当前部署
currentDeployment := FilterGrayRule(grayConfig, grayKeyValue)
currentDeployment := FilterGrayRule(grayConfig, grayKeyValue, cookie)
// 创建一个新的部署对象,初始化版本为当前部署的版本
deployment := &config.Deployment{
@@ -319,68 +371,13 @@ func FilterMultiVersionGrayRule(grayConfig *config.GrayConfig, grayKeyValue stri
return deployment
}
// FilterGrayRule 过滤灰度规则
func FilterGrayRule(grayConfig *config.GrayConfig, grayKeyValue string) *config.Deployment {
for _, deployment := range grayConfig.GrayDeployments {
grayRule := GetRule(grayConfig.Rules, deployment.Name)
// 首先先校验用户名单ID
if grayRule.GrayKeyValue != nil && len(grayRule.GrayKeyValue) > 0 && grayKeyValue != "" {
if ContainsValue(grayRule.GrayKeyValue, grayKeyValue) {
return deployment
}
}
// 第二校验Cookie中的 GrayTagKey
if grayRule.GrayTagKey != "" && grayRule.GrayTagValue != nil && len(grayRule.GrayTagValue) > 0 {
cookieStr, _ := proxywasm.GetHttpRequestHeader("cookie")
grayTagValue := ExtractCookieValueByKey(cookieStr, grayRule.GrayTagKey)
if ContainsValue(grayRule.GrayTagValue, grayTagValue) {
return deployment
}
}
}
return grayConfig.BaseDeployment
}
func FilterGrayWeight(grayConfig *config.GrayConfig, preVersion string, preUniqueClientId string, uniqueClientId string) *config.Deployment {
// 如果没有灰度权重,直接返回基础版本
if grayConfig.TotalGrayWeight == 0 {
return grayConfig.BaseDeployment
}
deployments := append(grayConfig.GrayDeployments, grayConfig.BaseDeployment)
LogInfof("preVersion: %s, preUniqueClientId: %s, uniqueClientId: %s", preVersion, preUniqueClientId, uniqueClientId)
// 用户粘滞,确保每个用户每次访问的都是走同一版本
if preVersion != "" && uniqueClientId == preUniqueClientId {
for _, deployment := range deployments {
if deployment.Version == preVersion {
return deployment
}
}
}
totalWeight := 100
// 如果总权重小于100则将基础版本也加入到总版本列表中
if grayConfig.TotalGrayWeight <= totalWeight {
grayConfig.BaseDeployment.Weight = 100 - grayConfig.TotalGrayWeight
} else {
totalWeight = grayConfig.TotalGrayWeight
}
rand.Seed(time.Now().UnixNano())
randWeight := rand.Intn(totalWeight)
sumWeight := 0
for _, deployment := range deployments {
sumWeight += deployment.Weight
if randWeight < sumWeight {
return deployment
}
}
return nil
}
// InjectContent 用于将内容注入到 HTML 文档的指定位置
func InjectContent(originalHtml string, injectionConfig *config.Injection) string {
headInjection := strings.Join(injectionConfig.Head, "\n")
func InjectContent(originalHtml string, injectionConfig *config.Injection, globalConfigValue string) string {
heads := injectionConfig.Head
if globalConfigValue != "" {
heads = append([]string{globalConfigValue}, injectionConfig.Head...)
}
headInjection := strings.Join(heads, "\n")
bodyFirstInjection := strings.Join(injectionConfig.Body.First, "\n")
bodyLastInjection := strings.Join(injectionConfig.Body.Last, "\n")
@@ -401,3 +398,14 @@ func InjectContent(originalHtml string, injectionConfig *config.Injection) strin
return modifiedHtml
}
func FixLocalStorageKey(newHtml string, localStorageGrayKey string) string {
if localStorageGrayKey != "" {
localStr := strings.ReplaceAll(`<script>
!function(){var o,e,n="@@X_GRAY_KEY",t=document.cookie.split("; ").filter(function(o){return 0===o.indexOf(n+"=")});try{"undefined"!=typeof localStorage&&null!==localStorage&&(o=localStorage.getItem(n),e=0<t.length?decodeURIComponent(t[0].split("=")[1]):null,o)&&o.indexOf("=")<0&&e&&e!==o&&(document.cookie=n+"="+encodeURIComponent(o)+"; path=/;",window.location.reload())}catch(o){}}();
</script>
`, "@@X_GRAY_KEY", localStorageGrayKey)
newHtml = strings.ReplaceAll(newHtml, "<body>", "<body>\n"+localStr)
}
return newHtml
}

View File

@@ -3,12 +3,14 @@ package util
import (
"testing"
"github.com/bmatcuk/doublestar/v4"
"github.com/alibaba/higress/plugins/wasm-go/extensions/frontend-gray/config"
"github.com/stretchr/testify/assert"
"github.com/tidwall/gjson"
)
func TestExtractCookieValueByKey(t *testing.T) {
func TestGetCookieValue(t *testing.T) {
var tests = []struct {
cookie, cookieKey, output string
}{
@@ -21,7 +23,7 @@ func TestExtractCookieValueByKey(t *testing.T) {
for _, test := range tests {
testName := test.cookie
t.Run(testName, func(t *testing.T) {
output := ExtractCookieValueByKey(test.cookie, test.cookieKey)
output := GetCookieValue(test.cookie, test.cookieKey)
assert.Equal(t, test.output, output)
})
}
@@ -106,7 +108,7 @@ func TestPrefixFileRewrite(t *testing.T) {
}
}
func TestIsPageRequest(t *testing.T) {
func TestCheckIsHtmlRequest(t *testing.T) {
var tests = []struct {
p string
output bool
@@ -121,30 +123,11 @@ func TestIsPageRequest(t *testing.T) {
for _, test := range tests {
testPath := test.p
t.Run(testPath, func(t *testing.T) {
output := IsPageRequest(testPath)
output := CheckIsHtmlRequest(testPath)
assert.Equal(t, test.output, output)
})
}
}
func TestFilterGrayWeight(t *testing.T) {
var tests = []struct {
name string
input string
}{
{"demo", `{"grayKey":"userId","rules":[{"name":"inner-user","grayKeyValue":["00000001","00000005"]},{"name":"beta-user","grayKeyValue":["noah","00000003"],"grayTagKey":"level","grayTagValue":["level3","level5"]}],"rewrite":{"host":"frontend-gray-cn-shanghai.oss-cn-shanghai-internal.aliyuncs.com","notFoundUri":"/mfe/app1/dev/404.html","indexRouting":{"/app1":"/mfe/app1/{version}/index.html","/":"/mfe/app1/{version}/index.html"},"fileRouting":{"/":"/mfe/app1/{version}","/app1":"/mfe/app1/{version}"}},"baseDeployment":{"version":"dev"},"grayDeployments":[{"name":"beta-user","version":"0.0.1","backendVersion":"beta","enabled":true,"weight":50}]}`},
}
for _, test := range tests {
testName := test.name
t.Run(testName, func(t *testing.T) {
grayConfig := &config.GrayConfig{}
config.JsonToGrayConfig(gjson.Parse(test.input), grayConfig)
result := FilterGrayWeight(grayConfig, "base", "1.0.1", "192.168.1.1")
t.Logf("result-----: %v", result)
})
}
}
func TestReplaceHtml(t *testing.T) {
var tests = []struct {
name string
@@ -158,8 +141,26 @@ func TestReplaceHtml(t *testing.T) {
t.Run(testName, func(t *testing.T) {
grayConfig := &config.GrayConfig{}
config.JsonToGrayConfig(gjson.Parse(test.input), grayConfig)
result := InjectContent(grayConfig.Html, grayConfig.Injection)
result := InjectContent(grayConfig.Html, grayConfig.Injection, "")
t.Logf("result-----: %v", result)
})
}
}
func TestIsIndexRequest(t *testing.T) {
var tests = []struct {
name string
input string
output bool
}{
{"/api/user.json", "/api/**", true},
{"/api/blade-auth/oauth/captcha", "/api/**", true},
}
for _, test := range tests {
testName := test.name
t.Run(testName, func(t *testing.T) {
matchResult, _ := doublestar.Match(test.input, testName)
assert.Equal(t, test.output, matchResult)
})
}
}

View File

@@ -15,6 +15,10 @@
This guide explains how to implement a Model Context Protocol (MCP) server using the Higress WASM Go SDK. MCP servers provide tools and resources that extend the capabilities of AI assistants.
[**MCP Server QuickStart**](https://higress.cn/en/ai/mcp-quick-start/)
[**Wasm Plugin Hub**](https://higress.cn/en/plugin/)
## Overview
An MCP server is a standalone application that communicates with AI assistants through the Model Context Protocol. It can provide:

View File

@@ -15,6 +15,11 @@
下面介绍如何使用 Higress WASM Go SDK 实现 Model Context Protocol (MCP) 服务器。MCP 服务器提供工具和资源,扩展 AI 助手的能力。
[**MCP Server QuickStart**](https://higress.cn/en/ai/mcp-quick-start/)
[**Wasm Plugin Hub**](https://higress.cn/en/plugin/)
## 概述
MCP 服务器是一个独立的应用程序,通过 Model Context Protocol 与 AI 助手通信。它可以提供:

View File

@@ -8,7 +8,7 @@ replace amap-tools => ../amap-tools
require (
amap-tools v0.0.0-00010101000000-000000000000
github.com/alibaba/higress/plugins/wasm-go v1.4.4-0.20250402072030-ae5d1c59f256
github.com/alibaba/higress/plugins/wasm-go v1.4.4-0.20250413143111-22e90c61ae13
quark-search v0.0.0-00010101000000-000000000000
)
@@ -20,7 +20,7 @@ require (
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/higress-group/gjson_template v0.0.0-20250331062947-760bb2f96985 // indirect
github.com/higress-group/gjson_template v0.0.0-20250413075336-4c4161ed428b // indirect
github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20250402062734-d50d98c305f0 // indirect
github.com/huandu/xstrings v1.5.0 // indirect
github.com/invopop/jsonschema v0.13.0 // indirect

View File

@@ -6,14 +6,8 @@ github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+
github.com/Masterminds/semver/v3 v3.3.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs=
github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0=
github.com/alibaba/higress/plugins/wasm-go v1.4.4-0.20250402062842-06623068b150 h1:FsX18TO0CzG5Ao8L3lToIG4Vl+c+NqOw7U++t4Rb7RE=
github.com/alibaba/higress/plugins/wasm-go v1.4.4-0.20250402062842-06623068b150/go.mod h1:nAmuA22tHQhn8to3y980Ut7FFv/Ayjj/B7n/F8Wf5JY=
github.com/alibaba/higress/plugins/wasm-go v1.4.4-0.20250402071250-98a6d18b0641 h1:6Lu8YkpNyJrMrlSmdU7G9YLoQb773xFkhEaKIfJMb7s=
github.com/alibaba/higress/plugins/wasm-go v1.4.4-0.20250402071250-98a6d18b0641/go.mod h1:nAmuA22tHQhn8to3y980Ut7FFv/Ayjj/B7n/F8Wf5JY=
github.com/alibaba/higress/plugins/wasm-go v1.4.4-0.20250402071748-c2375419dfdc h1:nqMORPwTUkI1ZzETxiZUGnbO8MJFwuepHIamM3xfsCU=
github.com/alibaba/higress/plugins/wasm-go v1.4.4-0.20250402071748-c2375419dfdc/go.mod h1:nAmuA22tHQhn8to3y980Ut7FFv/Ayjj/B7n/F8Wf5JY=
github.com/alibaba/higress/plugins/wasm-go v1.4.4-0.20250402072030-ae5d1c59f256 h1:tvCFhodIs+Fy2sk6Ng1f/az/dCKntfZbF6B+sVqctVs=
github.com/alibaba/higress/plugins/wasm-go v1.4.4-0.20250402072030-ae5d1c59f256/go.mod h1:nAmuA22tHQhn8to3y980Ut7FFv/Ayjj/B7n/F8Wf5JY=
github.com/alibaba/higress/plugins/wasm-go v1.4.4-0.20250413143111-22e90c61ae13 h1:o1XeVR4EPPcdUrogcCcgJbh1cgK80x1ecYRPFypWkfw=
github.com/alibaba/higress/plugins/wasm-go v1.4.4-0.20250413143111-22e90c61ae13/go.mod h1:VsSgC7oa1dhUArj6COnzo2AWSMCuNQAFo5HjZdo5Rks=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
@@ -26,8 +20,8 @@ github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/higress-group/gjson_template v0.0.0-20250331062947-760bb2f96985 h1:rOxn1GyVZGphQ1GeE1bxSCtRNxtNLzE9KpA5Zyq5Ui0=
github.com/higress-group/gjson_template v0.0.0-20250331062947-760bb2f96985/go.mod h1:rU3M+Tq5VrQOo0dxpKHGb03Ty0sdWIZfAH+YCOACx/Y=
github.com/higress-group/gjson_template v0.0.0-20250413075336-4c4161ed428b h1:rRI9+ThQbe+nw4jUiYEyOFaREkXCMMW9k1X2gy2d6pE=
github.com/higress-group/gjson_template v0.0.0-20250413075336-4c4161ed428b/go.mod h1:rU3M+Tq5VrQOo0dxpKHGb03Ty0sdWIZfAH+YCOACx/Y=
github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20250402062734-d50d98c305f0 h1:Ta+RBsZYML3hjoenbGJoS2L6aWJN+hqlxKoqzj/Y2SY=
github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20250402062734-d50d98c305f0/go.mod h1:tRI2LfMudSkKHhyv1uex3BWzcice2s/l8Ah8axporfA=
github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI=

View File

@@ -2,7 +2,7 @@ module amap-tools
go 1.24.1
require github.com/alibaba/higress/plugins/wasm-go v1.4.4-0.20250402072030-ae5d1c59f256
require github.com/alibaba/higress/plugins/wasm-go v1.4.4-0.20250407124215-3431eeb8d374
require (
dario.cat/mergo v1.0.1 // indirect

View File

@@ -6,12 +6,8 @@ github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+
github.com/Masterminds/semver/v3 v3.3.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs=
github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0=
github.com/alibaba/higress/plugins/wasm-go v1.4.4-0.20250402062842-06623068b150 h1:FsX18TO0CzG5Ao8L3lToIG4Vl+c+NqOw7U++t4Rb7RE=
github.com/alibaba/higress/plugins/wasm-go v1.4.4-0.20250402062842-06623068b150/go.mod h1:nAmuA22tHQhn8to3y980Ut7FFv/Ayjj/B7n/F8Wf5JY=
github.com/alibaba/higress/plugins/wasm-go v1.4.4-0.20250402071250-98a6d18b0641 h1:6Lu8YkpNyJrMrlSmdU7G9YLoQb773xFkhEaKIfJMb7s=
github.com/alibaba/higress/plugins/wasm-go v1.4.4-0.20250402071250-98a6d18b0641/go.mod h1:nAmuA22tHQhn8to3y980Ut7FFv/Ayjj/B7n/F8Wf5JY=
github.com/alibaba/higress/plugins/wasm-go v1.4.4-0.20250402072030-ae5d1c59f256 h1:tvCFhodIs+Fy2sk6Ng1f/az/dCKntfZbF6B+sVqctVs=
github.com/alibaba/higress/plugins/wasm-go v1.4.4-0.20250402072030-ae5d1c59f256/go.mod h1:nAmuA22tHQhn8to3y980Ut7FFv/Ayjj/B7n/F8Wf5JY=
github.com/alibaba/higress/plugins/wasm-go v1.4.4-0.20250407124215-3431eeb8d374 h1:Ht+XEuYcuytDa6YkgTXR/94h+/XAafX0GhGXcnr9siw=
github.com/alibaba/higress/plugins/wasm-go v1.4.4-0.20250407124215-3431eeb8d374/go.mod h1:nAmuA22tHQhn8to3y980Ut7FFv/Ayjj/B7n/F8Wf5JY=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=

View File

@@ -37,7 +37,7 @@ type TransitIntegratedRequest struct {
}
func (t TransitIntegratedRequest) Description() string {
return "公交路径规划 API 可以根据用户起终点经纬度坐标规划综合各类公共(火车、公交、地铁)交通方式的通勤方案,并且返回通勤方案的数据,跨城场景下必须传起点城市与终点城市"
return "公交路径规划 API 可以根据用户起终点经纬度坐标规划综合各类公共(火车、公交、地铁)交通方式的通勤方案,并且返回通勤方案的数据,跨城场景下必须传起点城市与终点城市, 起点城市名称可以通过基于ip定位位置的mcp工具获取"
}
func (t TransitIntegratedRequest) InputSchema() map[string]any {

View File

@@ -35,7 +35,7 @@ type GeoRequest struct {
}
func (t GeoRequest) Description() string {
return "将详细的结构化地址转换为经纬度坐标。支持对地标性名胜景区、建筑物名称解析为经纬度坐标"
return "将详细的结构化地址转换为经纬度坐标。支持对地标性名胜景区、建筑物名称解析为经纬度坐标, 城市名称可以通过基于ip定位位置的mcp工具获取"
}
func (t GeoRequest) InputSchema() map[string]any {

View File

@@ -20,21 +20,23 @@ import (
"fmt"
"net/http"
"net/url"
"strings"
"amap-tools/config"
"github.com/alibaba/higress/plugins/wasm-go/pkg/mcp/server"
"github.com/alibaba/higress/plugins/wasm-go/pkg/mcp/utils"
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm"
)
var _ server.Tool = IPLocationRequest{}
type IPLocationRequest struct {
IP string `json:"ip" jsonschema_description:"IP地址"`
IP string `json:"ip" jsonschema_description:"IP地址,获取不到则填写unknow,服务端将根据socket地址来获取IP"`
}
func (t IPLocationRequest) Description() string {
return "IP 定位根据用户输入的 IP 地址,定位 IP 的所在位置"
return "通过IP定位所在的国家和城市等位置信息"
}
func (t IPLocationRequest) InputSchema() map[string]any {
@@ -53,7 +55,19 @@ func (t IPLocationRequest) Call(ctx server.HttpContext, s server.Server) error {
if serverConfig.ApiKey == "" {
return errors.New("amap API-KEY is not configured")
}
if t.IP == "" || strings.Contains(t.IP, "unknow") {
var bs []byte
var ipStr string
fromHeader := false
bs, _ = proxywasm.GetProperty([]string{"source", "address"})
if len(bs) > 0 {
ipStr = string(bs)
} else {
ipStr, _ = proxywasm.GetHttpRequestHeader("x-forwarded-for")
fromHeader = true
}
t.IP = parseIP(ipStr, fromHeader)
}
url := fmt.Sprintf("https://restapi.amap.com/v3/ip?ip=%s&key=%s&source=ts_mcp", url.QueryEscape(t.IP), serverConfig.ApiKey)
return ctx.RouteCall(http.MethodGet, url,
[][2]string{{"Accept", "application/json"}}, nil, func(statusCode int, responseHeaders http.Header, responseBody []byte) {
@@ -64,3 +78,21 @@ func (t IPLocationRequest) Call(ctx server.HttpContext, s server.Server) error {
utils.SendMCPToolTextResult(ctx, string(responseBody))
})
}
// parseIP 解析IP
func parseIP(source string, fromHeader bool) string {
if fromHeader {
source = strings.Split(source, ",")[0]
}
source = strings.Trim(source, " ")
if strings.Contains(source, ".") {
// parse ipv4
return strings.Split(source, ":")[0]
}
//parse ipv6
if strings.Contains(source, "]") {
return strings.Split(source, "]")[0][1:]
}
return source
}

View File

@@ -36,7 +36,7 @@ type TextSearchRequest struct {
}
func (t TextSearchRequest) Description() string {
return "关键词搜根据用户传入关键词搜索出相关的POI"
return "关键词搜根据用户传入关键词搜索出相关的POI城市名称可以通过基于ip定位位置的mcp工具获取"
}
func (t TextSearchRequest) InputSchema() map[string]any {

View File

@@ -34,7 +34,7 @@ type WeatherRequest struct {
}
func (t WeatherRequest) Description() string {
return "根据城市名称或者标准adcode查询指定城市的天气"
return "根据城市名称或者标准adcode查询指定城市的天气城市名称可以通过基于ip定位位置的mcp工具获取"
}
func (t WeatherRequest) InputSchema() map[string]any {

View File

@@ -3,7 +3,7 @@ module quark-search
go 1.24.1
require (
github.com/alibaba/higress/plugins/wasm-go v1.4.4-0.20250402072030-ae5d1c59f256
github.com/alibaba/higress/plugins/wasm-go v1.4.4-0.20250407124215-3431eeb8d374
github.com/tidwall/gjson v1.18.0
)

View File

@@ -6,12 +6,8 @@ github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+
github.com/Masterminds/semver/v3 v3.3.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs=
github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0=
github.com/alibaba/higress/plugins/wasm-go v1.4.4-0.20250402062842-06623068b150 h1:FsX18TO0CzG5Ao8L3lToIG4Vl+c+NqOw7U++t4Rb7RE=
github.com/alibaba/higress/plugins/wasm-go v1.4.4-0.20250402062842-06623068b150/go.mod h1:nAmuA22tHQhn8to3y980Ut7FFv/Ayjj/B7n/F8Wf5JY=
github.com/alibaba/higress/plugins/wasm-go v1.4.4-0.20250402071250-98a6d18b0641 h1:6Lu8YkpNyJrMrlSmdU7G9YLoQb773xFkhEaKIfJMb7s=
github.com/alibaba/higress/plugins/wasm-go v1.4.4-0.20250402071250-98a6d18b0641/go.mod h1:nAmuA22tHQhn8to3y980Ut7FFv/Ayjj/B7n/F8Wf5JY=
github.com/alibaba/higress/plugins/wasm-go v1.4.4-0.20250402072030-ae5d1c59f256 h1:tvCFhodIs+Fy2sk6Ng1f/az/dCKntfZbF6B+sVqctVs=
github.com/alibaba/higress/plugins/wasm-go v1.4.4-0.20250402072030-ae5d1c59f256/go.mod h1:nAmuA22tHQhn8to3y980Ut7FFv/Ayjj/B7n/F8Wf5JY=
github.com/alibaba/higress/plugins/wasm-go v1.4.4-0.20250407124215-3431eeb8d374 h1:Ht+XEuYcuytDa6YkgTXR/94h+/XAafX0GhGXcnr9siw=
github.com/alibaba/higress/plugins/wasm-go v1.4.4-0.20250407124215-3431eeb8d374/go.mod h1:nAmuA22tHQhn8to3y980Ut7FFv/Ayjj/B7n/F8Wf5JY=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=

View File

@@ -73,6 +73,8 @@ type HttpContext interface {
SetRequestBodyBufferLimit(byteSize uint32)
// Note that this parameter affects the gateway's memory usage! Support setting a maximum buffer size for each response body individually in response phase.
SetResponseBodyBufferLimit(byteSize uint32)
// Get contextId of HttpContext
GetContextId() uint32
}
type oldParseConfigFunc[PluginConfig any] func(json gjson.Result, config *PluginConfig, log Log) error
@@ -406,7 +408,6 @@ func NewCommonVmCtxWithOptions[PluginConfig any](pluginName string, options ...C
var config PluginConfig
if unsafe.Sizeof(config) != 0 {
msg := "the `parseConfig` is missing in NewCommonVmCtx's arguments"
ctx.log.Critical(msg)
panic(msg)
}
ctx.hasCustomConfig = false
@@ -673,6 +674,10 @@ func (ctx *CommonHttpCtx[PluginConfig]) SetResponseBodyBufferLimit(size uint32)
_ = proxywasm.SetProperty([]string{"set_encoder_buffer_limit"}, []byte(strconv.Itoa(int(size))))
}
func (ctx *CommonHttpCtx[PluginConfig]) GetContextId() uint32 {
return ctx.contextID
}
func (ctx *CommonHttpCtx[PluginConfig]) OnHttpRequestHeaders(numHeaders int, endOfStream bool) types.Action {
requestID, _ := proxywasm.GetHttpRequestHeader("x-request-id")
_ = proxywasm.SetProperty([]string{"x_request_id"}, []byte(requestID))

View File

@@ -29,7 +29,7 @@ if [ ! -n "$INNER_GO_FILTER_NAME" ]; then
name=${file##*/}
echo "🚀 Build Go Filter: $name"
GO_FILTER_NAME=${name} GOARCH=${TARGET_ARCH} make build
cp ${GO_FILTERS_DIR}/${file}/${name}_${TARGET_ARCH}.so ${OUTPUT_PACKAGE_DIR}
cp ${GO_FILTERS_DIR}/${file}/golang-filter_${TARGET_ARCH}.so ${OUTPUT_PACKAGE_DIR}
fi
done
else