mirror of
https://github.com/alibaba/higress.git
synced 2026-02-26 05:30:50 +08:00
Compare commits
25 Commits
v2.1.0
...
v2.1.1-rc.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
707061fb68 | ||
|
|
3255925bf0 | ||
|
|
a44f7ef76e | ||
|
|
c7abfb8aff | ||
|
|
ed925ddf84 | ||
|
|
1301af4638 | ||
|
|
de6144439f | ||
|
|
e37c4dc286 | ||
|
|
b8e0baa5ab | ||
|
|
4a157e98e9 | ||
|
|
6af8b17216 | ||
|
|
4500b10a42 | ||
|
|
c5a86b5298 | ||
|
|
36806d9e5c | ||
|
|
d1700009e8 | ||
|
|
2c3188dad7 | ||
|
|
7d423cddbd | ||
|
|
0e94e1a58a | ||
|
|
b1307ba97e | ||
|
|
8ae810b01a | ||
|
|
83b38b896c | ||
|
|
1385028f01 | ||
|
|
af663b701a | ||
|
|
e5c24a10fb | ||
|
|
ea85ccb694 |
69
.github/workflows/helm-docs.yaml
vendored
69
.github/workflows/helm-docs.yaml
vendored
@@ -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
|
||||
@@ -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/
|
||||
|
||||
14
README.md
14
README.md
@@ -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/) |
|
||||
[**Docs**](https://higress.io/en-us/docs/overview/what-is-higress) |
|
||||
[**Blog**](https://higress.io/en-us/blog) |
|
||||
[**Developer**](https://higress.io/en-us/docs/developers/developers_dev) |
|
||||
[**Higress in Cloud**](https://www.alibabacloud.com/product/microservices-engine?spm=higress-website.topbar.0.0.0)
|
||||
|
||||
[**Official Site**](https://higress.ai/en/) |
|
||||
[**MCP Server QuickStart**](https://higress.cn/en/ai/mcp-quick-start/) |
|
||||
[**Wasm Plugin Hub**](https://higress.cn/en/plugin/) |
|
||||
|
||||
<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.
|
||||
|
||||
Submodule envoy/envoy updated: a2c5a07960...e114a74dd8
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
Submodule istio/istio updated: a698755c49...e6578f7dd0
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
153
plugins/golang-filter/mcp-server/handler/config_handler.go
Normal file
153
plugins/golang-filter/mcp-server/handler/config_handler.go
Normal 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
|
||||
}
|
||||
105
plugins/golang-filter/mcp-server/handler/config_store.go
Normal file
105
plugins/golang-filter/mcp-server/handler/config_store.go
Normal 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
|
||||
}
|
||||
129
plugins/golang-filter/mcp-server/handler/rate_limit_handler.go
Normal file
129
plugins/golang-filter/mcp-server/handler/rate_limit_handler.go
Normal 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
|
||||
}
|
||||
76
plugins/golang-filter/mcp-server/internal/crypto.go
Normal file
76
plugins/golang-filter/mcp-server/internal/crypto.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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请求指定覆盖或者填充参数 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
```
|
||||
|
||||
@@ -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,194;The first and third positions are 1 and 4 respectively |
|
||||
| x23 | 023,123,223,323,423,523,623,723,823,923;The second and third positions are 23 |
|
||||
| 4xx | 400-499;The first digit is 4 |
|
||||
| x4x | 040-049,140-149,240-249,340-349,440-449,540-549,640-649,740-749,840-849,940-949;The 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:
|
||||
|
||||
@@ -1 +1 @@
|
||||
1.0.0
|
||||
1.1.0
|
||||
|
||||
@@ -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: {}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
|
||||
80
plugins/wasm-go/extensions/custom-response/main_test.go
Normal file
80
plugins/wasm-go/extensions/custom-response/main_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
11
plugins/wasm-go/extensions/frontend-gray/Makefile
Normal file
11
plugins/wasm-go/extensions/frontend-gray/Makefile
Normal 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
|
||||
@@ -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>
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 助手通信。它可以提供:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -35,7 +35,7 @@ type GeoRequest struct {
|
||||
}
|
||||
|
||||
func (t GeoRequest) Description() string {
|
||||
return "将详细的结构化地址转换为经纬度坐标。支持对地标性名胜景区、建筑物名称解析为经纬度坐标"
|
||||
return "将详细的结构化地址转换为经纬度坐标。支持对地标性名胜景区、建筑物名称解析为经纬度坐标, 城市名称可以通过基于ip定位位置的mcp工具获取"
|
||||
}
|
||||
|
||||
func (t GeoRequest) InputSchema() map[string]any {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user