mirror of
https://github.com/alibaba/higress.git
synced 2026-07-24 13:20:41 +08:00
Compare commits
15 Commits
fix/bump-e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8b82797c5 | ||
|
|
b272f3a818 | ||
|
|
762324c376 | ||
|
|
165f2974db | ||
|
|
2682c926fc | ||
|
|
e01db53e9f | ||
|
|
45942d90b6 | ||
|
|
58773cb98e | ||
|
|
afb72b81cb | ||
|
|
152f14b171 | ||
|
|
e169ccbe5f | ||
|
|
154782660c | ||
|
|
e8b9989265 | ||
|
|
c6fa1f2ad0 | ||
|
|
bd9c4c5104 |
94
.agents/skills/higress-update-envoy-gateway/SKILL.md
Normal file
94
.agents/skills/higress-update-envoy-gateway/SKILL.md
Normal file
@@ -0,0 +1,94 @@
|
||||
---
|
||||
name: higress-update-envoy-gateway
|
||||
description: Update Higress Envoy binary and gateway image dependencies for e2e validation. Use when Codex needs to build Envoy packages from the current Higress branch, upload those packages to higress-group/proxy releases, update Makefile.core.mk ENVOY_PACKAGE_URL_PATTERN and ENVOY_LATEST_IMAGE_TAG, run make build-gateway-local, retag the generated proxy/proxyv2 image as gateway, push the gateway image, or prepare a signed-off PR that lets Higress e2e tests consume a new Envoy build.
|
||||
---
|
||||
|
||||
# Higress Envoy Gateway Dependency Update
|
||||
|
||||
## Core Workflow
|
||||
|
||||
Run from the Higress repo root unless a step explicitly says otherwise.
|
||||
|
||||
1. Verify context:
|
||||
- Check `git status --short --branch`.
|
||||
- Do not remove unrelated user changes or generated artifacts.
|
||||
- Use `gh` for GitHub release/PR checks and always pass `--repo` for non-current repos.
|
||||
|
||||
2. Build Envoy packages from the current branch:
|
||||
```bash
|
||||
git submodule update --init
|
||||
make build-envoy
|
||||
```
|
||||
Expected artifacts land in `external/package/`, typically:
|
||||
- `envoy-alpha-<proxy-sha>.tar.gz`
|
||||
- `envoy-symbol-<proxy-sha>.tar.gz`
|
||||
- matching `.sha256` and `.dwp` files
|
||||
|
||||
3. Publish Envoy package release in `higress-group/proxy`:
|
||||
- Create a new release tag by incrementing the requested RC/test tag, for example `v2.2.4-rc.2-test-cpp-host`.
|
||||
- Match the reference release asset names, even if local filenames include SHAs:
|
||||
- local `envoy-alpha-<sha>.tar.gz` uploads as `envoy-amd64.tar.gz`
|
||||
- local `envoy-symbol-<sha>.tar.gz` uploads as `envoy-symbol-amd64.tar.gz`
|
||||
- Use temporary renamed copies rather than renaming source artifacts:
|
||||
```bash
|
||||
cp external/package/envoy-alpha-<sha>.tar.gz /tmp/envoy-amd64.tar.gz
|
||||
cp external/package/envoy-symbol-<sha>.tar.gz /tmp/envoy-symbol-amd64.tar.gz
|
||||
gh release create <release-tag> /tmp/envoy-amd64.tar.gz /tmp/envoy-symbol-amd64.tar.gz \
|
||||
--repo higress-group/proxy --target <target-branch> --title <release-tag> --generate-notes
|
||||
gh release view <release-tag> --repo higress-group/proxy --json tagName,targetCommitish,assets,url
|
||||
```
|
||||
- If following an existing reference release, inspect it first with `gh release view`.
|
||||
|
||||
4. Update Higress Makefile dependencies:
|
||||
- In `Makefile.core.mk`, set:
|
||||
```make
|
||||
export ENVOY_PACKAGE_URL_PATTERN?=https://github.com/higress-group/proxy/releases/download/<release-tag>/envoy-symbol-ARCH.tar.gz
|
||||
```
|
||||
- After building and pushing the gateway image, set:
|
||||
```make
|
||||
ENVOY_LATEST_IMAGE_TAG ?= <gateway-image-tag>
|
||||
```
|
||||
|
||||
5. Build the local gateway image:
|
||||
```bash
|
||||
make build-gateway-local
|
||||
```
|
||||
Watch the log to confirm Envoy downloads from the new release URL. The build target may emit an image under a proxy-style repository/name, commonly:
|
||||
- `higress-registry.cn-hangzhou.cr.aliyuncs.com/higress/proxyv2:<tag>`
|
||||
|
||||
6. Retag proxy image as gateway:
|
||||
```bash
|
||||
docker tag higress-registry.cn-hangzhou.cr.aliyuncs.com/higress/proxyv2:<tag> \
|
||||
higress-registry.cn-hangzhou.cr.aliyuncs.com/higress/gateway:<tag>
|
||||
docker images --format '{{.Repository}}:{{.Tag}} {{.ID}} {{.CreatedSince}}' \
|
||||
higress-registry.cn-hangzhou.cr.aliyuncs.com/higress/gateway
|
||||
```
|
||||
Verify the `gateway:<tag>` and `proxyv2:<tag>` image IDs match.
|
||||
|
||||
7. Push the gateway image when requested:
|
||||
```bash
|
||||
docker push higress-registry.cn-hangzhou.cr.aliyuncs.com/higress/gateway:<tag>
|
||||
```
|
||||
Record the pushed digest in the final response.
|
||||
|
||||
8. Commit and push Makefile changes:
|
||||
- Commit only intended tracked files. Do not add `plugins/golang-filter/golang-filter_amd64.so` or other build outputs unless explicitly requested.
|
||||
- Use DCO sign-off:
|
||||
```bash
|
||||
git add Makefile.core.mk
|
||||
git commit -s -m "Update gateway envoy dependencies"
|
||||
git push origin <branch>
|
||||
```
|
||||
- If DCO fails after a previous unsigned commit:
|
||||
```bash
|
||||
git commit --amend --no-edit --signoff
|
||||
git push --force-with-lease origin <branch>
|
||||
gh pr checks <pr-number> --repo higress-group/higress
|
||||
```
|
||||
|
||||
## Practical Notes
|
||||
|
||||
- `make build-gateway-local` may need Docker daemon access, network access, and write access to repo/submodule state.
|
||||
- If sandboxed commands fail with read-only filesystem errors, Docker socket permission errors, or network failures, rerun the same important command with elevated permissions and a concrete justification.
|
||||
- The default local gateway tag usually comes from the current Git revision shown in the build log as `TAG=<sha>`.
|
||||
- For e2e validation, the point of this workflow is to make `install-dev`, `install-dev-wasmplugin`, and local image update targets use the newly pushed gateway image through `ENVOY_LATEST_IMAGE_TAG`.
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Higress Envoy Gateway"
|
||||
short_description: "Update Envoy packages and gateway tag"
|
||||
default_prompt: "Use $higress-update-envoy-gateway to update Higress Makefile Envoy dependencies and build a gateway image for e2e validation."
|
||||
1
.claude/skills/higress-update-envoy-gateway
Symbolic link
1
.claude/skills/higress-update-envoy-gateway
Symbolic link
@@ -0,0 +1 @@
|
||||
../../.agents/skills/higress-update-envoy-gateway
|
||||
@@ -7,10 +7,14 @@ assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**If you are reporting *any* crash or *any* potential security issue, *do not*
|
||||
open an issue in this repo. Please report the issue via [ASRC](https://security.alibaba.com/)(Alibaba Security Response Center) where the issue will be triaged appropriately.**
|
||||
**If you are reporting a potential security issue, do not open a public issue.
|
||||
You must submit the same substantive report through both
|
||||
[GitHub Private Security Advisories](https://github.com/higress-group/higress/security/advisories/new)
|
||||
and the [Alibaba Security Response Center](https://security.alibaba.com/), as
|
||||
required by the project's [security policy](https://github.com/higress-group/higress/blob/main/SECURITY.md).
|
||||
A crash with no suspected security impact may be reported with this template.**
|
||||
|
||||
- [ ] I have searched the [issues](https://github.com/alibaba/higress/issues) of this repository and believe that this is not a duplicate.
|
||||
- [ ] I have searched the [issues](https://github.com/higress-group/higress/issues) of this repository and believe that this is not a duplicate.
|
||||
|
||||
### Ⅰ. Issue Description
|
||||
|
||||
|
||||
@@ -104,11 +104,6 @@ jobs:
|
||||
push_command=${push_command#\"}
|
||||
push_command=${push_command%\"} # 删除PUSH_COMMAND中的双引号,确保oras push正常解析
|
||||
|
||||
target_image="${{ env.IMAGE_REGISTRY_SERVICE }}/${{ env.IMAGE_REPOSITORY}}/${{ env.PLUGIN_NAME }}:${{ env.VERSION }}"
|
||||
target_image_latest="${{ env.IMAGE_REGISTRY_SERVICE }}/${{ env.IMAGE_REPOSITORY}}/${{ env.PLUGIN_NAME }}:latest"
|
||||
echo "TargetImage=${target_image}"
|
||||
echo "TargetImageLatest=${target_image_latest}"
|
||||
|
||||
cd ${{ github.workspace }}/plugins/wasm-${PLUGIN_TYPE}/extensions/${PLUGIN_NAME}
|
||||
if [ -f ./.buildrc ]; then
|
||||
echo 'Found .buildrc file, sourcing it...'
|
||||
@@ -116,6 +111,21 @@ jobs:
|
||||
else
|
||||
echo '.buildrc file not found'
|
||||
fi
|
||||
|
||||
# Resolve custom image short name from .buildrc, fallback to plugin directory name.
|
||||
# .buildrc may define IMAGE_NAME=xxx to override the output image tag.
|
||||
IMAGE_NAME=${IMAGE_NAME:-$PLUGIN_NAME}
|
||||
if ! [[ "$IMAGE_NAME" =~ ^[a-z0-9._-]+$ ]]; then
|
||||
echo "::error::Invalid IMAGE_NAME '$IMAGE_NAME' in .buildrc — must match [a-z0-9._-]+"
|
||||
exit 1
|
||||
fi
|
||||
echo "IMAGE_NAME=${IMAGE_NAME}"
|
||||
|
||||
target_image="${{ env.IMAGE_REGISTRY_SERVICE }}/${{ env.IMAGE_REPOSITORY}}/${IMAGE_NAME}:${{ env.VERSION }}"
|
||||
target_image_latest="${{ env.IMAGE_REGISTRY_SERVICE }}/${{ env.IMAGE_REPOSITORY}}/${IMAGE_NAME}:latest"
|
||||
echo "TargetImage=${target_image}"
|
||||
echo "TargetImageLatest=${target_image_latest}"
|
||||
|
||||
echo "EXTRA_TAGS=${EXTRA_TAGS}"
|
||||
if [ "${PLUGIN_TYPE}" == "go" ]; then
|
||||
command="
|
||||
|
||||
71
.github/workflows/build-and-test.yaml
vendored
71
.github/workflows/build-and-test.yaml
vendored
@@ -93,8 +93,77 @@ jobs:
|
||||
gateway-conformance-test:
|
||||
runs-on: ubuntu-22.04
|
||||
needs: [build]
|
||||
env:
|
||||
GATEWAY_API_TEST_NAMESPACE: gateway-conformance-infra
|
||||
HIGRESS_TEST_IMAGE_TAG: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ env.HIGRESS_TEST_IMAGE_TAG }}
|
||||
|
||||
- name: Disable containerd image store
|
||||
run: |
|
||||
sudo bash -c 'cat > /etc/docker/daemon.json << EOF
|
||||
{
|
||||
"features": {
|
||||
"containerd-snapshotter": false
|
||||
}
|
||||
}
|
||||
EOF'
|
||||
sudo systemctl restart docker
|
||||
docker info -f '{{ .DriverStatus }}'
|
||||
|
||||
- name: Free Up GitHub Actions Ubuntu Runner Disk Space 🔧
|
||||
uses: jlumbroso/free-disk-space@main
|
||||
with:
|
||||
tool-cache: false
|
||||
android: true
|
||||
dotnet: true
|
||||
haskell: true
|
||||
large-packages: true
|
||||
swap-storage: true
|
||||
|
||||
- name: "Setup Go"
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
|
||||
- name: Setup Golang Caches
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |-
|
||||
~/.cache/go-build
|
||||
~/go/pkg/mod
|
||||
key: ${{ runner.os }}-go-${{ github.run_id }}
|
||||
restore-keys: ${{ runner.os }}-go
|
||||
|
||||
- name: Run Higress Gateway API Tests
|
||||
run: |-
|
||||
GOPROXY="https://proxy.golang.org,direct" make gateway-conformance-test \
|
||||
TAG="${HIGRESS_TEST_IMAGE_TAG}" \
|
||||
HIGRESS_CONFORMANCE_VERSION="${HIGRESS_TEST_IMAGE_TAG}"
|
||||
|
||||
- name: Collect Gateway API Test Diagnostics
|
||||
if: always()
|
||||
run: |-
|
||||
mkdir -p out/gateway-api-conformance/diagnostics
|
||||
kubectl get gatewayclass,gateway,httproute,referencegrant -A -o yaml > out/gateway-api-conformance/diagnostics/resources.yaml 2>&1 || true
|
||||
kubectl get events -A --sort-by=.lastTimestamp > out/gateway-api-conformance/diagnostics/events.txt 2>&1 || true
|
||||
kubectl logs -n "${GATEWAY_API_TEST_NAMESPACE}" deployment/higress-controller --all-containers > out/gateway-api-conformance/diagnostics/controller.log 2>&1 || true
|
||||
kubectl logs -n "${GATEWAY_API_TEST_NAMESPACE}" deployment/higress-gateway --all-containers > out/gateway-api-conformance/diagnostics/gateway.log 2>&1 || true
|
||||
tools/bin/kind-gateway-api export logs out/gateway-api-conformance/diagnostics/kind --name higress || true
|
||||
|
||||
- name: Upload Gateway API Test Results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: gateway-conformance-test
|
||||
path: out/gateway-api-conformance/
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Clean Gateway API Test Environment
|
||||
if: always()
|
||||
run: make gateway-conformance-test-clean
|
||||
|
||||
higress-conformance-test:
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
32
.github/workflows/check-golang-filter-envoy-sync.yaml
vendored
Normal file
32
.github/workflows/check-golang-filter-envoy-sync.yaml
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
name: "Check golang-filter Envoy Sync"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "plugins/golang-filter/go.mod"
|
||||
- "envoy/envoy"
|
||||
- ".gitmodules"
|
||||
- "tools/hack/check-golang-filter-envoy-sync.sh"
|
||||
- ".github/workflows/check-golang-filter-envoy-sync.yaml"
|
||||
pull_request:
|
||||
branches: ["*"]
|
||||
paths:
|
||||
- "plugins/golang-filter/go.mod"
|
||||
- "envoy/envoy"
|
||||
- ".gitmodules"
|
||||
- "tools/hack/check-golang-filter-envoy-sync.sh"
|
||||
- ".github/workflows/check-golang-filter-envoy-sync.yaml"
|
||||
workflow_dispatch: ~
|
||||
|
||||
jobs:
|
||||
check-envoy-sync:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# The check reads the submodule commit from the tree, so submodules
|
||||
# do not need to be fetched.
|
||||
submodules: false
|
||||
- name: "Verify golang-filter Envoy pin matches the envoy/envoy submodule"
|
||||
run: bash tools/hack/check-golang-filter-envoy-sync.sh
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -17,3 +17,6 @@ target/
|
||||
tools/hack/cluster.conf
|
||||
envoy/1.20
|
||||
istio/1.12
|
||||
|
||||
# Local working notes (design specs, implementation plans) — not for upstream PRs
|
||||
docs/superpowers/
|
||||
|
||||
79
COMMUNITY.md
Normal file
79
COMMUNITY.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# Higress Community
|
||||
|
||||
This document is the authoritative inventory of official Higress project
|
||||
communication channels, including channels used by its subprojects. Official
|
||||
technical and governance decisions are recorded in public GitHub issues,
|
||||
discussions, pull requests, or published meeting notes.
|
||||
|
||||
## Public channels
|
||||
|
||||
| Channel | Scope and purpose |
|
||||
| --- | --- |
|
||||
| [GitHub Issues](https://github.com/higress-group/higress/issues) | Bug reports, feature requests, and work tracking for the primary repository |
|
||||
| [GitHub Pull Requests](https://github.com/higress-group/higress/pulls) | Public change proposals, reviews, and decision records |
|
||||
| [GitHub Discussions](https://github.com/higress-group/higress/discussions) | User questions, ideas, announcements, and longer-form community discussion |
|
||||
| [Discord](https://discord.gg/tSbww9VDaM) | Public real-time user and contributor chat; decisions arising there must be recorded on GitHub |
|
||||
| [Higress mailing list](mailto:higress@googlegroups.com) | Community questions and contributor contact by email |
|
||||
| [Chinese-language community group](./README_ZH.md#%E4%BA%A4%E6%B5%81%E7%BE%A4) | Publicly advertised Chinese-language user and contributor chat |
|
||||
| [Higress WeChat Official Account](./README_ZH.md#%E6%8A%80%E6%9C%AF%E5%88%86%E4%BA%AB) | Chinese-language technical articles and project announcements; broadcast rather than a decision channel |
|
||||
| [Higress website and documentation](https://higress.cn/en/) | Published user and contributor documentation and project announcements |
|
||||
|
||||
Each subproject uses its own public GitHub issues and pull requests for work
|
||||
specific to that repository:
|
||||
|
||||
| Subproject | Issues | Pull requests |
|
||||
| --- | --- | --- |
|
||||
| `higress-console` | [Issues](https://github.com/higress-group/higress-console/issues) | [Pull requests](https://github.com/higress-group/higress-console/pulls) |
|
||||
| `higress-standalone` | [Issues](https://github.com/higress-group/higress-standalone/issues) | [Pull requests](https://github.com/higress-group/higress-standalone/pulls) |
|
||||
| `plugin-server` | [Issues](https://github.com/higress-group/plugin-server/issues) | [Pull requests](https://github.com/higress-group/plugin-server/pulls) |
|
||||
| `wasm-go` | [Issues](https://github.com/higress-group/wasm-go/issues) | [Pull requests](https://github.com/higress-group/wasm-go/pulls) |
|
||||
|
||||
Cross-subproject and project-governance decisions are recorded in the primary
|
||||
Higress repository.
|
||||
|
||||
## Non-public channels
|
||||
|
||||
Non-public channels are limited to reports whose confidentiality protects
|
||||
reporters or users:
|
||||
|
||||
| Channel | Special purpose |
|
||||
| --- | --- |
|
||||
| [GitHub Private Security Advisories](https://github.com/higress-group/higress/security/advisories/new) | One of the two required confidential vulnerability-reporting channels; used for project triage, remediation, and disclosure coordination under [`SECURITY.md`](./SECURITY.md) |
|
||||
| [Alibaba Security Response Center](https://security.alibaba.com/) | The second required confidential vulnerability-reporting channel; the same substantive report must be submitted here and correlated with the GitHub advisory by the Security Response Team |
|
||||
| [CNCF Code of Conduct reporting](mailto:conduct@cncf.io) | Confidential Code of Conduct incident reporting under [`CODE_OF_CONDUCT.md`](./CODE_OF_CONDUCT.md) |
|
||||
| [CNCF TOC private mailing list](mailto:cncf-private-toc@lists.cncf.io) | Confidential escalation when project leadership cannot resolve a security or governance conflict without conflicted participants |
|
||||
|
||||
Personal messages, employer-internal systems, and informal maintainer chats are
|
||||
not official project decision channels. If they inform project work, the
|
||||
non-sensitive decision and rationale must be recorded publicly.
|
||||
|
||||
## Community meetings
|
||||
|
||||
Higress does not currently run a recurring public community meeting and does
|
||||
not yet have a CNCF calendar entry. Establishing an up-to-date public meeting
|
||||
scheduler and/or CNCF calendar integration is tracked as an Incubation
|
||||
readiness item in
|
||||
[`docs/cncf/governance-review.md`](./docs/cncf/governance-review.md).
|
||||
|
||||
When a recurring meeting is established, its schedule and joining information
|
||||
will be published here and on the CNCF calendar. Agendas, notes, recordings,
|
||||
and decisions will be public, with confidential security and Code of Conduct
|
||||
matters excluded.
|
||||
|
||||
## Contributor activity and recruitment
|
||||
|
||||
Public, continuously updated evidence is available through:
|
||||
|
||||
- [GitHub contributor activity](https://github.com/higress-group/higress/graphs/contributors)
|
||||
- [Recent repository activity](https://github.com/higress-group/higress/pulse)
|
||||
- [CNCF DevStats for Higress](https://higress.devstats.cncf.io/)
|
||||
- [`help wanted` issues](https://github.com/higress-group/higress/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22)
|
||||
- [`good first issue` issues](https://github.com/higress-group/higress/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22)
|
||||
- the contributor path in [`CONTRIBUTING_EN.md`](./CONTRIBUTING_EN.md)
|
||||
- the active-maintainer evidence and annual roster review in
|
||||
[`MAINTAINERS.md`](./MAINTAINERS.md)
|
||||
|
||||
Contributors can start through an issue, discussion, documentation update, bug
|
||||
fix, test, plugin, or other pull request. Maintainers and code owners recruit
|
||||
and mentor contributors through the public channels above and identify
|
||||
approachable work with contribution labels.
|
||||
@@ -19,22 +19,109 @@ Higress governance is guided by the following values:
|
||||
|
||||
## Roles
|
||||
|
||||
Higress role definitions are documented in:
|
||||
Higress has the following project-wide roles:
|
||||
|
||||
- **Contributor**: anyone who participates through issues, discussions,
|
||||
documentation, code, testing, or other community work. Contributors do not
|
||||
need a formal appointment.
|
||||
- **Code owner**: a contributor delegated to review changes in one or more
|
||||
paths in [`CODEOWNERS`](./CODEOWNERS). Code owners assess technical quality,
|
||||
request changes, and help maintain their assigned area. Code ownership does
|
||||
not grant project-wide governance authority or a permanent seat. A code
|
||||
owner is added or removed by a public pull request updating `CODEOWNERS`,
|
||||
using the decision process below. Maintainers remain accountable for the
|
||||
resulting ownership coverage and may merge only in accordance with the
|
||||
repository's configured permissions and review rules.
|
||||
- **Maintainer**: a project-wide leadership role responsible for technical
|
||||
direction, releases, governance, community health, and the delegation of
|
||||
code ownership. Maintainers are not limited to the paths where they are
|
||||
named as code owners.
|
||||
|
||||
The current roster, responsibilities, and lifecycle are documented in:
|
||||
|
||||
- [`MAINTAINERS.md`](./MAINTAINERS.md)
|
||||
- [`CODEOWNERS`](./CODEOWNERS)
|
||||
- [`CONTRIBUTING_EN.md`](./CONTRIBUTING_EN.md)
|
||||
|
||||
## Project Scope and Subprojects
|
||||
|
||||
The following repositories comprise the CNCF Higress project. Unless a
|
||||
subproject documents an additional local rule, this governance and the
|
||||
project-wide maintainer roster apply to it.
|
||||
|
||||
| Repository | Responsibility | Status |
|
||||
| --- | --- | --- |
|
||||
| [`higress-group/higress`](https://github.com/higress-group/higress) | Core control plane, data plane integration, APIs, Helm charts, CLI, plugins, documentation, and releases | Active; primary repository |
|
||||
| [`higress-group/higress-console`](https://github.com/higress-group/higress-console) | Web management console | Active subproject |
|
||||
| [`higress-group/higress-standalone`](https://github.com/higress-group/higress-standalone) | Standalone and local deployment packaging | Active subproject |
|
||||
| [`higress-group/plugin-server`](https://github.com/higress-group/plugin-server) | Plugin distribution service used by Higress | Active subproject |
|
||||
| [`higress-group/wasm-go`](https://github.com/higress-group/wasm-go) | Go SDK for Higress Wasm plugins | Active subproject |
|
||||
|
||||
Git submodules declared in [`.gitmodules`](./.gitmodules), including the
|
||||
Higress-hosted forks of Istio, Envoy, and their related API and client
|
||||
libraries, are pinned source dependencies used to build Higress; they are not
|
||||
separately governed Higress subprojects. Other upstream dependency forks,
|
||||
integrations, examples, experiments, websites, and repositories in the
|
||||
`higress-group` organization are also not Higress subprojects unless they are
|
||||
added to this table.
|
||||
|
||||
A proposal to add, remove, transfer, or archive a subproject must be made in a
|
||||
public issue or pull request. It must identify the repository, purpose,
|
||||
maintainers or delegated code owners, contribution path, communication
|
||||
channels, and lifecycle status. The proposal follows the project-direction
|
||||
decision process below. An archived or removed subproject remains listed here
|
||||
with its final status and successor, if any.
|
||||
|
||||
## Decision Making
|
||||
|
||||
Higress uses **lazy consensus** by default.
|
||||
|
||||
When consensus cannot be reached, maintainers may start a vote on a public
|
||||
issue or pull request. A simple majority of votes cast decides the outcome.
|
||||
Technical proposals, contribution acceptance, project goals, leadership and
|
||||
role assignments, subproject changes, requests made on behalf of Higress to
|
||||
the CNCF, and governance changes are discussed in public issues or pull
|
||||
requests. Maintainers are responsible for ensuring that significant decisions
|
||||
record their rationale and outcome in the relevant public thread.
|
||||
|
||||
When consensus cannot be reached, a maintainer may start a vote on the public
|
||||
issue or pull request. A simple majority of non-conflicted maintainer votes
|
||||
cast decides the outcome. A maintainer with a material personal or employer
|
||||
conflict must disclose it and abstain from the decision. If all maintainers are
|
||||
conflicted, the project will request guidance from the CNCF TOC.
|
||||
|
||||
For governance or project direction changes, maintainers should allow adequate
|
||||
time for public discussion before finalizing decisions.
|
||||
|
||||
## Vendor Neutrality
|
||||
|
||||
Higress project direction and governance are independent of any single
|
||||
company. Maintainer and code-owner roles are held by individuals, not reserved
|
||||
for employers. No employer has a guaranteed seat, veto, or preferred decision
|
||||
weight. Contributions, integrations, defaults, and roadmap priorities are
|
||||
evaluated on community and technical merit. Company-specific products may be
|
||||
supported, but they do not receive privileged governance treatment.
|
||||
|
||||
Affiliation changes do not by themselves add or remove a role. Maintainers
|
||||
disclose affiliations in `MAINTAINERS.md` and disclose material conflicts in
|
||||
the public decision record.
|
||||
|
||||
## Function-Based Teams
|
||||
|
||||
Maintainers may create a function-based team, such as a release or security
|
||||
team, through a public governance pull request. The change must document the
|
||||
team's purpose, authority, membership or selection method, onboarding,
|
||||
conflict handling, removal, and retirement. Sensitive operational details may
|
||||
remain private, but the team's mandate and decision authority must be public.
|
||||
|
||||
The current Security Response Team and its private report-handling process are
|
||||
defined in [`SECURITY.md`](./SECURITY.md).
|
||||
|
||||
## Community and Meetings
|
||||
|
||||
The authoritative inventory of public and non-public communication channels,
|
||||
subproject channels, contribution activity, and meeting information is in
|
||||
[`COMMUNITY.md`](./COMMUNITY.md). Project decisions must be recorded in a
|
||||
public issue, discussion, pull request, or published meeting notes even when
|
||||
preliminary conversation occurred elsewhere.
|
||||
|
||||
## Governance Updates
|
||||
|
||||
Changes to this document are made through pull requests and approved by
|
||||
|
||||
@@ -2,24 +2,49 @@
|
||||
|
||||
This file lists the current maintainers of the Higress project.
|
||||
|
||||
A maintainer is a contributor who is actively responsible for guiding the
|
||||
direction of the project, reviewing and merging contributions, and
|
||||
participating in project governance.
|
||||
A maintainer is a contributor who is actively responsible for project-wide
|
||||
technical direction, releases, governance, community health, and the
|
||||
delegation of code ownership across the Higress project and its subprojects.
|
||||
Maintainer authority is held by individuals and is not tied to an employer.
|
||||
|
||||
For the day-to-day code-review ownership of individual subdirectories,
|
||||
see [`CODEOWNERS`](./CODEOWNERS).
|
||||
|
||||
## Maintainers
|
||||
|
||||
| Name | GitHub | Affiliation |
|
||||
| -------------- | ------------------------------------------------------- | ----------------- |
|
||||
| Yiquan Dong | [@CH3CHO](https://github.com/CH3CHO) | Trip.com |
|
||||
| Yuanxiao Zhao | [@EndlessSeeker](https://github.com/EndlessSeeker) | Alibaba Cloud |
|
||||
| Leilei Geng | [@gengleilei](https://github.com/gengleilei) | Alibaba Cloud |
|
||||
| Xiantao Han | [@hanxiantao](https://github.com/hanxiantao) | XinYe Technology |
|
||||
| Zhiwei Cheng | [@cr7258](https://github.com/cr7258) | NVIDIA |
|
||||
| Tianyi Zhang | [@johnlanni](https://github.com/johnlanni) | Alibaba Cloud |
|
||||
| Jingfeng Xu | [@lexburner](https://github.com/lexburner) | Alibaba Cloud |
|
||||
| Name | GitHub contact | Domain of responsibility | Affiliation |
|
||||
| --- | --- | --- | --- |
|
||||
| Yiquan Dong | [@CH3CHO](https://github.com/CH3CHO) | Project-wide governance, technical direction, releases, and community stewardship | Trip.com |
|
||||
| Yuanxiao Zhao | [@EndlessSeeker](https://github.com/EndlessSeeker) | Project-wide governance, technical direction, releases, and community stewardship | Alibaba Cloud |
|
||||
| Leilei Geng | [@gengleilei](https://github.com/gengleilei) | Project-wide governance, technical direction, releases, and community stewardship | Alibaba Cloud |
|
||||
| Xiantao Han | [@hanxiantao](https://github.com/hanxiantao) | Project-wide governance, technical direction, releases, and community stewardship | XinYe Technology |
|
||||
| Zhiwei Cheng | [@cr7258](https://github.com/cr7258) | Project-wide governance, technical direction, releases, and community stewardship | NVIDIA |
|
||||
| Tianyi Zhang | [@johnlanni](https://github.com/johnlanni) | Project-wide governance, technical direction, releases, and community stewardship | Alibaba Cloud |
|
||||
| Jingfeng Xu | [@lexburner](https://github.com/lexburner) | Project-wide governance, technical direction, releases, and community stewardship | Alibaba Cloud |
|
||||
|
||||
More specific day-to-day review responsibilities are delegated through
|
||||
[`CODEOWNERS`](./CODEOWNERS); they do not narrow a maintainer's project-wide
|
||||
responsibility.
|
||||
|
||||
## Activity and roster review
|
||||
|
||||
A maintainer is active when they have participated in project review,
|
||||
development, releases, issue triage, community support, or governance during
|
||||
the preceding 12 months and remain willing to perform the role. The roster is
|
||||
reviewed in a public pull request at least annually. The review checks public
|
||||
activity, current affiliation and contact information, and asks maintainers
|
||||
whose status is unclear to confirm their availability.
|
||||
|
||||
The 2026 roster review used public GitHub issue and pull-request activity for
|
||||
each maintainer:
|
||||
|
||||
- [Yiquan Dong](https://github.com/higress-group/higress/issues?q=updated%3A%3E%3D2025-07-21+involves%3ACH3CHO)
|
||||
- [Yuanxiao Zhao](https://github.com/higress-group/higress/issues?q=updated%3A%3E%3D2025-07-21+involves%3AEndlessSeeker)
|
||||
- [Leilei Geng](https://github.com/higress-group/higress/issues?q=updated%3A%3E%3D2025-07-21+involves%3Agengleilei)
|
||||
- [Xiantao Han](https://github.com/higress-group/higress/issues?q=updated%3A%3E%3D2025-07-21+involves%3Ahanxiantao)
|
||||
- [Zhiwei Cheng](https://github.com/higress-group/higress/issues?q=updated%3A%3E%3D2025-07-21+involves%3Acr7258)
|
||||
- [Tianyi Zhang](https://github.com/higress-group/higress/issues?q=updated%3A%3E%3D2025-07-21+involves%3Ajohnlanni)
|
||||
- [Jingfeng Xu](https://github.com/higress-group/higress/issues?q=updated%3A%3E%3D2025-07-21+involves%3Alexburner)
|
||||
|
||||
## Becoming a maintainer
|
||||
|
||||
@@ -28,7 +53,29 @@ Higress follows the contribution and graduation paths described in
|
||||
contributors who consistently demonstrate good technical judgement and
|
||||
community stewardship can be nominated as maintainers by an existing
|
||||
maintainer; nominations are accepted by lazy consensus among the current
|
||||
maintainers.
|
||||
maintainers. The nomination pull request must update this roster and state the
|
||||
candidate's public contribution history, domain of responsibility, contact,
|
||||
and affiliation.
|
||||
|
||||
## Leaving or changing maintainer status
|
||||
|
||||
- A maintainer may step down by opening or approving a pull request that moves
|
||||
them to the emeritus list or removes them from the roster.
|
||||
- A maintainer who has no qualifying activity for 12 months will be contacted
|
||||
publicly where practical and given at least 30 days to confirm whether they
|
||||
want to resume the role, move to emeritus, or step down. If there is no
|
||||
response, another maintainer may propose the change through lazy consensus.
|
||||
- A maintainer may be removed for sustained failure to perform the role or for
|
||||
a Code of Conduct violation. The proposal follows the governance decision
|
||||
process, excludes conflicted maintainers, and preserves confidential details
|
||||
where required by the Code of Conduct process.
|
||||
- Emeritus maintainers retain recognition but no maintainer authority or
|
||||
required repository access. An emeritus or former maintainer may return
|
||||
through the same public nomination process used for a new maintainer.
|
||||
|
||||
## Emeritus maintainers
|
||||
|
||||
There are currently no emeritus maintainers.
|
||||
|
||||
## Reporting issues
|
||||
|
||||
|
||||
@@ -21,6 +21,21 @@ GO ?= go
|
||||
|
||||
export GOPROXY ?= https://proxy.golang.org,direct
|
||||
|
||||
GATEWAY_API_VERSION ?= v1.4.0
|
||||
GATEWAY_CONFORMANCE_PROFILE ?= GATEWAY-HTTP
|
||||
GATEWAY_CONFORMANCE_SUPPORTED_FEATURES ?= Gateway,HTTPRoute,ReferenceGrant
|
||||
GATEWAY_CONFORMANCE_REPORT ?= out/gateway-api-conformance/report.yaml
|
||||
GATEWAY_CONFORMANCE_CONTACT ?= https://github.com/alibaba/higress/issues
|
||||
GATEWAY_CONFORMANCE_RUN_TEST ?=
|
||||
GATEWAY_CONFORMANCE_ALLOW_CRDS_MISMATCH ?= false
|
||||
GATEWAY_API_TEST_NAMESPACE ?= gateway-conformance-infra
|
||||
GATEWAY_API_GATEWAY_SERVICE_TYPE ?= ClusterIP
|
||||
GATEWAY_API_DIAL_LOCALHOST ?= true
|
||||
GATEWAY_API_LOCAL_HTTP_PORT ?= 80
|
||||
GATEWAY_API_LOCAL_HTTPS_PORT ?= 443
|
||||
GATEWAY_API_KIND_NODE_TAG ?= v1.34.0@sha256:7416a61b42b1662ca6ca89f02028ac133a309a2a30ba309614e8ec94d976dc5a
|
||||
HIGRESS_CONFORMANCE_VERSION ?= $(shell git rev-parse HEAD)
|
||||
|
||||
TARGET_ARCH ?= amd64
|
||||
|
||||
VALID_ARCHS := amd64 arm64
|
||||
@@ -151,7 +166,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.2.3/envoy-symbol-ARCH.tar.gz
|
||||
export ENVOY_PACKAGE_URL_PATTERN?=https://github.com/higress-group/proxy/releases/download/v2.2.4-rc.2-test-cpp-host/envoy-symbol-ARCH.tar.gz
|
||||
|
||||
build-envoy: prebuild
|
||||
./tools/hack/build-envoy.sh
|
||||
@@ -205,11 +220,15 @@ install: pre-install
|
||||
helm install higress helm/higress -n higress-system --create-namespace --set 'global.local=true'
|
||||
|
||||
HIGRESS_LATEST_IMAGE_TAG ?= latest
|
||||
ENVOY_LATEST_IMAGE_TAG ?= 91244c578aef498af93cacb2cf353f3878b92fc4
|
||||
ENVOY_LATEST_IMAGE_TAG ?= 481184afc44176eb23d64e0011dc3ea1ae6a410c
|
||||
ISTIO_LATEST_IMAGE_TAG ?= de2c9628294f51b13c4a70b3a862b4372890797a
|
||||
|
||||
install-dev: pre-install
|
||||
helm install higress helm/core -n higress-system --create-namespace --set 'controller.tag=$(TAG)' --set 'gateway.replicas=1' --set 'pilot.tag=$(ISTIO_LATEST_IMAGE_TAG)' --set 'gateway.tag=$(ENVOY_LATEST_IMAGE_TAG)' --set 'global.local=true'
|
||||
|
||||
install-dev-gateway-api: pre-install
|
||||
helm install higress helm/core -n $(GATEWAY_API_TEST_NAMESPACE) --create-namespace --set 'controller.tag=$(TAG)' --set 'gateway.replicas=1' --set 'pilot.tag=$(ISTIO_LATEST_IMAGE_TAG)' --set 'gateway.tag=$(ENVOY_LATEST_IMAGE_TAG)' --set 'global.local=true' --set 'gateway.service.type=$(GATEWAY_API_GATEWAY_SERVICE_TYPE)'
|
||||
|
||||
install-dev-wasmplugin: build-wasmplugins pre-install
|
||||
helm install higress helm/core -n higress-system --create-namespace --set 'controller.tag=$(TAG)' --set 'gateway.replicas=1' --set 'pilot.tag=$(ISTIO_LATEST_IMAGE_TAG)' --set 'gateway.tag=$(ENVOY_LATEST_IMAGE_TAG)' --set 'global.local=true' --set 'global.volumeWasmPlugins=true' --set 'global.onlyPushRouteCluster=false'
|
||||
|
||||
@@ -262,9 +281,71 @@ clean: clean-higress clean-gateway clean-istio clean-env clean-tool
|
||||
include tools/tools.mk
|
||||
include tools/lint.mk
|
||||
|
||||
# gateway-conformance-test runs gateway api conformance tests.
|
||||
# install-gateway-api-crds installs the Gateway API CRDs used by the conformance suite.
|
||||
.PHONY: install-gateway-api-crds
|
||||
install-gateway-api-crds:
|
||||
kubectl apply --server-side=true -f https://github.com/kubernetes-sigs/gateway-api/releases/download/$(GATEWAY_API_VERSION)/standard-install.yaml
|
||||
kubectl wait --for=condition=Established crd/gatewayclasses.gateway.networking.k8s.io --timeout=120s
|
||||
kubectl wait --for=condition=Established crd/gateways.gateway.networking.k8s.io --timeout=120s
|
||||
kubectl wait --for=condition=Established crd/httproutes.gateway.networking.k8s.io --timeout=120s
|
||||
kubectl wait --for=condition=Established crd/referencegrants.gateway.networking.k8s.io --timeout=120s
|
||||
|
||||
# create-gateway-api-cluster creates the Kubernetes version used by Gateway API v1.4.0 tests.
|
||||
.PHONY: create-gateway-api-cluster
|
||||
create-gateway-api-cluster: $(tools/kind-gateway-api)
|
||||
KIND=$(tools/kind-gateway-api) KIND_NODE_TAG=$(GATEWAY_API_KIND_NODE_TAG) tools/hack/create-cluster.sh
|
||||
|
||||
# delete-gateway-api-cluster deletes the Gateway API test cluster.
|
||||
.PHONY: delete-gateway-api-cluster
|
||||
delete-gateway-api-cluster: $(tools/kind-gateway-api)
|
||||
$(tools/kind-gateway-api) delete cluster --name higress
|
||||
|
||||
# kube-load-gateway-api-images loads only the images required by the Gateway API tests.
|
||||
.PHONY: kube-load-gateway-api-images
|
||||
kube-load-gateway-api-images: $(tools/kind-gateway-api)
|
||||
KIND=$(tools/kind-gateway-api) tools/hack/kind-load-image.sh higress-registry.cn-hangzhou.cr.aliyuncs.com/higress/higress $(TAG)
|
||||
tools/hack/docker-pull-image.sh higress-registry.cn-hangzhou.cr.aliyuncs.com/higress/pilot $(ISTIO_LATEST_IMAGE_TAG)
|
||||
tools/hack/docker-pull-image.sh higress-registry.cn-hangzhou.cr.aliyuncs.com/higress/gateway $(ENVOY_LATEST_IMAGE_TAG)
|
||||
KIND=$(tools/kind-gateway-api) tools/hack/kind-load-image.sh higress-registry.cn-hangzhou.cr.aliyuncs.com/higress/pilot $(ISTIO_LATEST_IMAGE_TAG)
|
||||
KIND=$(tools/kind-gateway-api) tools/hack/kind-load-image.sh higress-registry.cn-hangzhou.cr.aliyuncs.com/higress/gateway $(ENVOY_LATEST_IMAGE_TAG)
|
||||
|
||||
# gateway-conformance-test-prepare prepares a kind cluster for Gateway API tests.
|
||||
.PHONY: gateway-conformance-test-prepare
|
||||
gateway-conformance-test-prepare: delete-gateway-api-cluster create-gateway-api-cluster install-gateway-api-crds docker-build kube-load-gateway-api-images install-dev-gateway-api
|
||||
kubectl wait --timeout=10m -n $(GATEWAY_API_TEST_NAMESPACE) deployment/higress-controller --for=condition=Available
|
||||
kubectl wait --timeout=10m -n $(GATEWAY_API_TEST_NAMESPACE) deployment/higress-gateway --for=condition=Available
|
||||
kubectl wait --timeout=10m gatewayclass/higress --for=condition=Accepted
|
||||
|
||||
# run-gateway-conformance-test runs the upstream Gateway API Conformance Suite.
|
||||
.PHONY: run-gateway-conformance-test
|
||||
run-gateway-conformance-test:
|
||||
mkdir -p $(dir $(GATEWAY_CONFORMANCE_REPORT))
|
||||
HIGRESS_GATEWAY_API_TEST_DIAL_LOCALHOST=$(GATEWAY_API_DIAL_LOCALHOST) \
|
||||
HIGRESS_GATEWAY_API_TEST_LOCAL_HTTP_PORT=$(GATEWAY_API_LOCAL_HTTP_PORT) \
|
||||
HIGRESS_GATEWAY_API_TEST_LOCAL_HTTPS_PORT=$(GATEWAY_API_LOCAL_HTTPS_PORT) go test -v ./test/gateway \
|
||||
-run '^TestGatewayAPIConformance$$' \
|
||||
-args \
|
||||
--gateway-class=higress \
|
||||
--supported-features=$(GATEWAY_CONFORMANCE_SUPPORTED_FEATURES) \
|
||||
--conformance-profiles=$(GATEWAY_CONFORMANCE_PROFILE) \
|
||||
--organization=alibaba \
|
||||
--project=higress \
|
||||
--url=https://github.com/alibaba/higress \
|
||||
--version=$(HIGRESS_CONFORMANCE_VERSION) \
|
||||
--contact=$(GATEWAY_CONFORMANCE_CONTACT) \
|
||||
--mode=default \
|
||||
--cleanup-base-resources=false \
|
||||
--allow-crds-mismatch=$(GATEWAY_CONFORMANCE_ALLOW_CRDS_MISMATCH) \
|
||||
$(if $(GATEWAY_CONFORMANCE_RUN_TEST),--run-test=$(GATEWAY_CONFORMANCE_RUN_TEST),) \
|
||||
--report-output=$(abspath $(GATEWAY_CONFORMANCE_REPORT))
|
||||
|
||||
# gateway-conformance-test runs Gateway API tests as a standard Higress integration test.
|
||||
.PHONY: gateway-conformance-test
|
||||
gateway-conformance-test:
|
||||
gateway-conformance-test: gateway-conformance-test-prepare run-gateway-conformance-test
|
||||
|
||||
# gateway-conformance-test-clean deletes the Gateway API test cluster.
|
||||
.PHONY: gateway-conformance-test-clean
|
||||
gateway-conformance-test-clean: delete-gateway-api-cluster
|
||||
|
||||
# higress-conformance-test-prepare prepares the environment for higress conformance tests.
|
||||
.PHONY: higress-conformance-test-prepare
|
||||
|
||||
41
README.md
41
README.md
@@ -8,8 +8,8 @@
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://github.com/alibaba/higress/actions)
|
||||
[](https://www.apache.org/licenses/LICENSE-2.0.html)
|
||||
[](https://github.com/higress-group/higress/actions)
|
||||
[](https://www.apache.org/licenses/LICENSE-2.0.html)
|
||||
[](https://discord.gg/tSbww9VDaM)
|
||||
[](https://www.cncf.io/projects/)
|
||||
[](https://www.bestpractices.dev/projects/12667)
|
||||
@@ -21,6 +21,7 @@
|
||||
[**Official Site**](https://higress.ai/en/) |
|
||||
[**Docs**](https://higress.cn/en/docs/latest/overview/what-is-higress/) |
|
||||
[**Blog**](https://higress.cn/en/blog/) |
|
||||
[**Roadmap**](./ROADMAP.md) |
|
||||
[**MCP Server QuickStart**](https://higress.cn/en/ai/mcp-quick-start/) |
|
||||
[**Developer Guide**](https://higress.cn/en/docs/latest/dev/architecture/) |
|
||||
[**Wasm Plugin Hub**](https://higress.cn/en/plugin/) |
|
||||
@@ -35,19 +36,19 @@ Higress is a cloud-native API gateway based on Istio and Envoy, which can be ext
|
||||
|
||||
### Core Use Cases
|
||||
|
||||
Higress's AI gateway capabilities support all [mainstream model providers](https://github.com/alibaba/higress/tree/main/plugins/wasm-go/extensions/ai-proxy/provider) both domestic and international. It also supports hosting MCP (Model Context Protocol) Servers through its plugin mechanism, enabling AI Agents to easily call various tools and services. With the [openapi-to-mcp tool](https://github.com/higress-group/openapi-to-mcpserver), you can quickly convert OpenAPI specifications into remote MCP servers for hosting. Higress provides unified management for both LLM API and MCP API.
|
||||
Higress's AI gateway capabilities support all [mainstream model providers](https://github.com/higress-group/higress/tree/main/plugins/wasm-go/extensions/ai-proxy/provider) both domestic and international. It also supports hosting MCP (Model Context Protocol) Servers through its plugin mechanism, enabling AI Agents to easily call various tools and services. With the [openapi-to-mcp tool](https://github.com/higress-group/openapi-to-mcpserver), you can quickly convert OpenAPI specifications into remote MCP servers for hosting. Higress provides unified management for both LLM API and MCP API.
|
||||
|
||||
**🌟 Try it now at [https://mcp.higress.ai/](https://mcp.higress.ai/)** to experience Higress-hosted Remote MCP Servers firsthand:
|
||||
|
||||

|
||||
|
||||
### Enterprise Adoption
|
||||
### Production Adoption
|
||||
|
||||
Higress was born within Alibaba to solve the issues of Tengine reload affecting long-connection services and insufficient load balancing capabilities for gRPC/Dubbo. Within Alibaba Cloud, Higress's AI gateway capabilities support core AI applications such as Tongyi Bailian model studio, machine learning PAI platform, and other critical AI services. Alibaba Cloud has built its cloud-native API gateway product based on Higress, providing 99.99% gateway high availability guarantee service capabilities for a large number of enterprise customers.
|
||||
|
||||
You can click the button below to install the enterprise version of Higress:
|
||||
|
||||
[](https://www.aliyun.com/product/api-gateway?spm=higress-github.topbar.0.0.0)
|
||||
Higress originated at Alibaba to address long-connection disruption during
|
||||
gateway reloads and improve gRPC/Dubbo load balancing. It is now developed as
|
||||
a vendor-neutral CNCF project and is used by organizations across multiple
|
||||
industries. Public adopters and their use cases are listed in
|
||||
[`ADOPTERS.md`](./ADOPTERS.md).
|
||||
|
||||
|
||||
## Summary
|
||||
@@ -77,8 +78,9 @@ Port descriptions:
|
||||
- Port 8080: Gateway HTTP protocol entry
|
||||
- Port 8443: Gateway HTTPS protocol entry
|
||||
|
||||
> 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.
|
||||
> Higress publishes project images through dedicated regional registry
|
||||
> endpoints. Operators may mirror the images to a registry they control and
|
||||
> configure the Helm `global.hub` value accordingly.
|
||||
>
|
||||
> If you experience a timeout when pulling image from `higress-registry.cn-hangzhou.cr.aliyuncs.com`, you can try replacing it with the following docker registry mirror source:
|
||||
>
|
||||
@@ -100,9 +102,6 @@ Port descriptions:
|
||||
|
||||
For other installation methods such as Helm deployment under K8s, please refer to the official [Quick Start documentation](https://higress.ai/en/docs/latest/user/quickstart/).
|
||||
|
||||
If you are deploying on the cloud, it is recommended to use the [Enterprise Edition](https://www.aliyun.com/product/apigateway?spm=higress-github.topbar.0.0.0)
|
||||
|
||||
|
||||
## Use Cases
|
||||
|
||||
- **MCP Server Hosting**:
|
||||
@@ -185,6 +184,10 @@ Join our Discord community! This is where you can connect with developers and ot
|
||||
|
||||
[](https://discord.gg/tSbww9VDaM)
|
||||
|
||||
The complete inventory of public and private communication channels,
|
||||
subproject channels, meeting information, and contributor activity is in
|
||||
[`COMMUNITY.md`](./COMMUNITY.md).
|
||||
|
||||
### Code of Conduct
|
||||
|
||||
The Higress community follows the
|
||||
@@ -198,7 +201,9 @@ participating in the community.
|
||||
Project governance, the maintainer roster, and the contribution model are
|
||||
described in [`GOVERNANCE.md`](./GOVERNANCE.md) and
|
||||
[`MAINTAINERS.md`](./MAINTAINERS.md). New contributors are encouraged to start
|
||||
with [`CONTRIBUTING_EN.md`](./CONTRIBUTING_EN.md).
|
||||
with [`CONTRIBUTING_EN.md`](./CONTRIBUTING_EN.md). Forward planning and release
|
||||
procedures are documented in [`ROADMAP.md`](./ROADMAP.md) and
|
||||
[`RELEASE.md`](./RELEASE.md).
|
||||
|
||||
### Security
|
||||
|
||||
@@ -218,13 +223,13 @@ Higress would not be possible without the valuable open-source work of projects
|
||||
|
||||
### Contributors
|
||||
|
||||
<a href="https://github.com/alibaba/higress/graphs/contributors">
|
||||
<img alt="contributors" src="https://contrib.rocks/image?repo=alibaba/higress"/>
|
||||
<a href="https://github.com/higress-group/higress/graphs/contributors">
|
||||
<img alt="contributors" src="https://contrib.rocks/image?repo=higress-group/higress"/>
|
||||
</a>
|
||||
|
||||
### Star History
|
||||
|
||||
[](https://star-history.com/#alibaba/higress&Date)
|
||||
[](https://star-history.com/#higress-group/higress&Date)
|
||||
|
||||
---
|
||||
|
||||
|
||||
20
README_JP.md
20
README_JP.md
@@ -6,8 +6,8 @@
|
||||
</h1>
|
||||
<h4 align="center"> AIネイティブAPIゲートウェイ </h4>
|
||||
|
||||
[](https://github.com/alibaba/higress/actions)
|
||||
[](https://www.apache.org/licenses/LICENSE-2.0.html)
|
||||
[](https://github.com/higress-group/higress/actions)
|
||||
[](https://www.apache.org/licenses/LICENSE-2.0.html)
|
||||
[](https://www.cncf.io/projects/)
|
||||
[](https://www.bestpractices.dev/projects/12667)
|
||||
|
||||
@@ -30,15 +30,19 @@ Higressは、IstioとEnvoyをベースにしたクラウドネイティブAPIゲ
|
||||
|
||||
### 主な使用シナリオ
|
||||
|
||||
HigressのAIゲートウェイ機能は、国内外のすべての[主要モデルプロバイダー](https://github.com/alibaba/higress/tree/main/plugins/wasm-go/extensions/ai-proxy/provider)をサポートし、vllm/ollamaなどに基づく自己構築DeepSeekモデルにも対応しています。また、プラグインメカニズムを通じてMCP(Model Context Protocol)サーバーをホストすることもでき、AI Agentが様々なツールやサービスを簡単に呼び出せるようにします。[openapi-to-mcpツール](https://github.com/higress-group/openapi-to-mcpserver)を使用すると、OpenAPI仕様を迅速にリモートMCPサーバーに変換してホスティングできます。HigressはLLM APIとMCP APIの統一管理を提供します。
|
||||
HigressのAIゲートウェイ機能は、国内外のすべての[主要モデルプロバイダー](https://github.com/higress-group/higress/tree/main/plugins/wasm-go/extensions/ai-proxy/provider)をサポートし、vllm/ollamaなどに基づく自己構築DeepSeekモデルにも対応しています。また、プラグインメカニズムを通じてMCP(Model Context Protocol)サーバーをホストすることもでき、AI Agentが様々なツールやサービスを簡単に呼び出せるようにします。[openapi-to-mcpツール](https://github.com/higress-group/openapi-to-mcpserver)を使用すると、OpenAPI仕様を迅速にリモートMCPサーバーに変換してホスティングできます。HigressはLLM APIとMCP APIの統一管理を提供します。
|
||||
|
||||
**🌟 今すぐ[https://mcp.higress.ai/](https://mcp.higress.ai/)で体験**してください。HigressがホストするリモートMCPサーバーを直接体験できます:
|
||||
|
||||

|
||||
|
||||
### 企業での採用
|
||||
### 本番環境での採用
|
||||
|
||||
Higressは、Tengineのリロードが長時間接続のビジネスに影響を与える問題や、gRPC/Dubboの負荷分散能力の不足を解決するために、Alibaba内部で誕生しました。Alibaba Cloud内では、HigressのAIゲートウェイ機能がTongyi Qianwen APP、Tongyi Bailian Model Studio、機械学習PAIプラットフォームなどの中核的なAIアプリケーションをサポートしています。また、国内の主要なAIGC企業(例:ZeroOne)やAI製品(例:FastGPT)にもサービスを提供しています。Alibaba Cloudは、Higressを基盤にクラウドネイティブAPIゲートウェイ製品を構築し、多くの企業顧客に99.99%のゲートウェイ高可用性保証サービスを提供しています。
|
||||
Higressは、ゲートウェイのリロードによる長時間接続への影響と
|
||||
gRPC/Dubboの負荷分散上の課題を解決するため、Alibabaで誕生しました。
|
||||
現在はベンダーニュートラルなCNCFプロジェクトとしてコミュニティにより
|
||||
開発され、複数業界の組織で利用されています。公開されている採用組織と
|
||||
ユースケースは[`ADOPTERS.md`](./ADOPTERS.md)を参照してください。
|
||||
|
||||
|
||||
## 目次
|
||||
@@ -232,13 +236,13 @@ WeChat公式アカウント:
|
||||
|
||||
### 貢献者
|
||||
|
||||
<a href="https://github.com/alibaba/higress/graphs/contributors">
|
||||
<img alt="contributors" src="https://contrib.rocks/image?repo=alibaba/higress"/>
|
||||
<a href="https://github.com/higress-group/higress/graphs/contributors">
|
||||
<img alt="contributors" src="https://contrib.rocks/image?repo=higress-group/higress"/>
|
||||
</a>
|
||||
|
||||
### スターの歴史
|
||||
|
||||
[](https://star-history.com/#alibaba/higress&Date)
|
||||
[](https://star-history.com/#higress-group/higress&Date)
|
||||
|
||||
---
|
||||
|
||||
|
||||
28
README_ZH.md
28
README_ZH.md
@@ -8,8 +8,8 @@
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://github.com/alibaba/higress/actions)
|
||||
[](https://www.apache.org/licenses/LICENSE-2.0.html)
|
||||
[](https://github.com/higress-group/higress/actions)
|
||||
[](https://www.apache.org/licenses/LICENSE-2.0.html)
|
||||
[](https://www.cncf.io/projects/)
|
||||
[](https://www.bestpractices.dev/projects/12667)
|
||||
|
||||
@@ -37,7 +37,7 @@ Higress 是一款云原生 API 网关,内核基于 Istio 和 Envoy,可以用
|
||||
|
||||
### 核心使用场景
|
||||
|
||||
Higress 的 AI 网关能力支持国内外所有[主流模型供应商](https://github.com/alibaba/higress/tree/main/plugins/wasm-go/extensions/ai-proxy/provider)和基于 vllm/ollama 等自建的 DeepSeek 模型。同时,Higress 支持通过插件方式托管 MCP (Model Context Protocol) 服务器,使 AI Agent 能够更容易地调用各种工具和服务。借助 [openapi-to-mcp 工具](https://github.com/higress-group/openapi-to-mcpserver),您可以快速将 OpenAPI 规范转换为远程 MCP 服务器进行托管。Higress 提供了对 LLM API 和 MCP API 的统一管理。
|
||||
Higress 的 AI 网关能力支持国内外所有[主流模型供应商](https://github.com/higress-group/higress/tree/main/plugins/wasm-go/extensions/ai-proxy/provider)和基于 vllm/ollama 等自建的 DeepSeek 模型。同时,Higress 支持通过插件方式托管 MCP (Model Context Protocol) 服务器,使 AI Agent 能够更容易地调用各种工具和服务。借助 [openapi-to-mcp 工具](https://github.com/higress-group/openapi-to-mcpserver),您可以快速将 OpenAPI 规范转换为远程 MCP 服务器进行托管。Higress 提供了对 LLM API 和 MCP API 的统一管理。
|
||||
|
||||
**🌟 立即体验 [https://mcp.higress.ai/](https://mcp.higress.ai/)** 基于 Higress 托管的远程 MCP 服务器:
|
||||
|
||||
@@ -45,13 +45,10 @@ Higress 的 AI 网关能力支持国内外所有[主流模型供应商](https://
|
||||
|
||||
### 生产环境采用
|
||||
|
||||
Higress 在阿里内部为解决 Tengine reload 对长连接业务有损,以及 gRPC/Dubbo 负载均衡能力不足而诞生。在阿里云内部,Higress 的 AI 网关能力支撑了通义千问 APP、通义百炼模型工作室、机器学习 PAI 平台等核心 AI 应用。同时服务国内头部的 AIGC 企业(如零一万物),以及 AI 产品(如 FastGPT)。阿里云基于 Higress 构建了云原生 API 网关产品,为大量企业客户提供 99.99% 的网关高可用保障服务能力。
|
||||
|
||||
可以点下方按钮安装企业版 Higress:
|
||||
|
||||
[](https://www.aliyun.com/product/apigateway?spm=higress-github.topbar.0.0.0)
|
||||
|
||||
如果您使用开源的Higress并希望获得企业级支持,可以联系johnlanni的邮箱:zty98751@alibaba-inc.com或社交媒体账号(微信号:nomadao,钉钉号:chengtanzty)。添加好友时请备注Higress :)
|
||||
Higress 最初在阿里内部为解决网关 reload 对长连接业务有损,以及
|
||||
gRPC/Dubbo 负载均衡能力不足而诞生。Higress 目前作为厂商中立的 CNCF
|
||||
项目由社区共同开发,并已被多个行业的组织采用。公开采用者和使用场景见
|
||||
[`ADOPTERS.md`](./ADOPTERS.md)。
|
||||
|
||||
## Summary
|
||||
|
||||
@@ -80,7 +77,8 @@ docker run -d --rm --name higress-ai -v ${PWD}:/data \
|
||||
- 8080 端口:网关 HTTP 协议入口
|
||||
- 8443 端口:网关 HTTPS 协议入口
|
||||
|
||||
**Higress 的所有 Docker 镜像都一直使用自己独享的仓库,不受 Docker Hub 境内访问受限的影响**
|
||||
**Higress 通过多个区域的项目镜像仓库发布镜像。使用者也可以将镜像同步到
|
||||
自己控制的仓库,并通过 Helm 的 `global.hub` 参数进行配置。**
|
||||
|
||||
> 如果从 `higress-registry.cn-hangzhou.cr.aliyuncs.com` 拉取镜像超时,可以尝试使用以下镜像加速源:
|
||||
>
|
||||
@@ -102,8 +100,6 @@ docker run -d --rm --name higress-ai -v ${PWD}:/data \
|
||||
|
||||
K8s 下使用 Helm 部署等其他安装方式可以参考官网 [Quick Start 文档](https://higress.cn/docs/latest/user/quickstart/)。
|
||||
|
||||
如果您是在云上部署,推荐使用[企业版](https://www.aliyun.com/product/apigateway?spm=higress-github.topbar.0.0.0)
|
||||
|
||||
## 使用场景
|
||||
|
||||
- **AI 网关**:
|
||||
@@ -261,13 +257,13 @@ Higress 社区遵循 [**CNCF Code of Conduct**](https://github.com/cncf/foundati
|
||||
|
||||
### 贡献者
|
||||
|
||||
<a href="https://github.com/alibaba/higress/graphs/contributors">
|
||||
<img alt="contributors" src="https://contrib.rocks/image?repo=alibaba/higress"/>
|
||||
<a href="https://github.com/higress-group/higress/graphs/contributors">
|
||||
<img alt="contributors" src="https://contrib.rocks/image?repo=higress-group/higress"/>
|
||||
</a>
|
||||
|
||||
### Star History
|
||||
|
||||
[](https://star-history.com/#alibaba/higress&Date)
|
||||
[](https://star-history.com/#higress-group/higress&Date)
|
||||
|
||||
---
|
||||
|
||||
|
||||
112
RELEASE.md
Normal file
112
RELEASE.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# Higress Release Process
|
||||
|
||||
This document defines the project-wide process for producing a Higress core
|
||||
release from the primary repository. Subprojects may add repository-specific
|
||||
steps, but must use the same public approval and security principles.
|
||||
|
||||
## Versioning and support
|
||||
|
||||
Higress uses [Semantic Versioning](https://semver.org/) with Git tags in the
|
||||
form `vMAJOR.MINOR.PATCH`. A release-candidate tag may add an `-rc.N` suffix.
|
||||
|
||||
- A **major** release may contain incompatible changes and requires migration
|
||||
guidance.
|
||||
- A **minor** release adds backward-compatible features and may contain
|
||||
announced deprecations.
|
||||
- A **patch** release contains backward-compatible fixes, including security
|
||||
fixes when appropriate.
|
||||
|
||||
The supported major-version lines are listed in [`SECURITY.md`](./SECURITY.md).
|
||||
A proposal to end support for a major line is made publicly through a pull
|
||||
request updating `SECURITY.md`, normally at least 90 days before the stated end
|
||||
of support. Already unsupported versions do not receive routine fixes.
|
||||
|
||||
## Roles and authorization
|
||||
|
||||
A maintainer acts as **release manager** for each release. The release manager
|
||||
coordinates the tracking issue, version changes, validation, tag, automation,
|
||||
and announcement. At least one other unconflicted maintainer reviews and
|
||||
approves the release pull request. Only maintainers with the required GitHub
|
||||
and protected-environment access may create release tags or approve publishing
|
||||
jobs.
|
||||
|
||||
Security releases additionally follow [`SECURITY.md`](./SECURITY.md). Embargoed
|
||||
details stay in the private advisory until the Security Response Team approves
|
||||
disclosure.
|
||||
|
||||
## 1. Plan the release
|
||||
|
||||
The release manager opens or identifies a public tracking issue for a normal
|
||||
release. It records:
|
||||
|
||||
- the target version and release type;
|
||||
- intended scope and linked changes;
|
||||
- the release manager and expected timing;
|
||||
- known compatibility, migration, deprecation, and security considerations;
|
||||
- validation status and unresolved blockers.
|
||||
|
||||
An embargoed security release uses a private advisory for planning until public
|
||||
disclosure is safe.
|
||||
|
||||
## 2. Prepare the release pull request
|
||||
|
||||
Create a release pull request against `main` that keeps the version in
|
||||
`VERSION`, `helm/core/Chart.yaml`, `helm/higress/Chart.yaml`, dependency
|
||||
metadata, and release notes consistent. Update documentation, migration or
|
||||
deprecation guidance, and supported-version information when they change.
|
||||
|
||||
The pull request must pass the repository's required build, unit, race,
|
||||
conformance, plugin, license, and other configured checks relevant to its
|
||||
changes. The release manager records any intentionally inapplicable check or
|
||||
accepted exception in the pull request. A known release-blocking regression,
|
||||
unresolved critical vulnerability, inconsistent version, or missing migration
|
||||
guidance blocks the release.
|
||||
|
||||
The approving maintainer verifies that the release scope matches the public
|
||||
plan, required checks passed, versioned dependencies are intentional, and the
|
||||
release notes accurately describe user-visible changes.
|
||||
|
||||
## 3. Tag and publish
|
||||
|
||||
After the release pull request is merged, the release manager creates the
|
||||
corresponding immutable `vMAJOR.MINOR.PATCH` or release-candidate tag from the
|
||||
reviewed commit. Published tags are not moved or reused.
|
||||
|
||||
The tag triggers GitHub Actions that publish, as applicable:
|
||||
|
||||
- controller, pilot, and gateway container images;
|
||||
- Helm charts and chart indexes;
|
||||
- `hgctl` archives for supported platforms;
|
||||
- generated CRDs;
|
||||
- standalone distribution artifacts;
|
||||
- GitHub release notes and repository release notes.
|
||||
|
||||
Manual workflow dispatch is for recovery or a documented exceptional case; it
|
||||
does not bypass release approval or change the commit being released.
|
||||
|
||||
## 4. Verify and announce
|
||||
|
||||
The release manager verifies that publishing workflows succeeded and that the
|
||||
release page, checksums or archives, image tags, Helm charts, CRDs, and release
|
||||
notes refer to the intended version. Installation and representative gateway
|
||||
traffic are smoke-tested with the published artifacts before the release is
|
||||
announced as ready.
|
||||
|
||||
Release announcements link the GitHub release and call out breaking changes,
|
||||
deprecations, migrations, known issues, and security impact. Confirmed security
|
||||
issues are disclosed through a GitHub Security Advisory and CVE when
|
||||
appropriate.
|
||||
|
||||
## Failure, rollback, and correction
|
||||
|
||||
If validation or publishing fails, stop promotion and document the failure in
|
||||
the tracking issue or release pull request. Do not move or overwrite an
|
||||
existing public tag. Fix the problem through normal review and publish a new
|
||||
release-candidate or patch version.
|
||||
|
||||
An operator rollback uses the previously pinned Helm chart, configuration, and
|
||||
images, normally through `helm rollback`. Because CRD schemas and stored
|
||||
resources may not be reversible, release notes must identify migrations that
|
||||
need special rollback handling. The project may withdraw a compromised
|
||||
artifact from distribution, but it preserves a public advisory and audit trail
|
||||
explaining the affected version and safe replacement.
|
||||
30
ROADMAP.md
Normal file
30
ROADMAP.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# Higress Roadmap
|
||||
|
||||
The maintained public Higress roadmap is published on the
|
||||
[Higress website](https://higress.ai/en/docs/latest/overview/roadmap/). Its
|
||||
source is version controlled in the
|
||||
[`higress-group/higress-group.github.io`](https://github.com/higress-group/higress-group.github.io/blob/main/src/content/docs/latest/en/overview/roadmap.md)
|
||||
repository.
|
||||
|
||||
Roadmap entries describe intended direction and target timing, not a guarantee
|
||||
that a feature or release will ship on a particular date. Security, quality,
|
||||
compatibility, and contributor availability can change priorities.
|
||||
|
||||
## Changing the roadmap
|
||||
|
||||
Anyone may propose a roadmap item or priority change through a public
|
||||
[Higress issue](https://github.com/higress-group/higress/issues) or
|
||||
[discussion](https://github.com/higress-group/higress/discussions). A proposal
|
||||
should describe the user problem, expected outcome, project scope, major
|
||||
dependencies, compatibility or security impact, and a responsible contributor
|
||||
or maintainer when known.
|
||||
|
||||
Maintainers decide roadmap changes through the public lazy-consensus process in
|
||||
[`GOVERNANCE.md`](./GOVERNANCE.md). Accepted changes are reflected in the
|
||||
version-controlled website roadmap. Significant changes should link back to
|
||||
the public issue, discussion, or pull request that records their rationale.
|
||||
|
||||
The roadmap is reviewed when planning a minor or major release and whenever a
|
||||
material project-direction change is accepted. Completed work is documented in
|
||||
[GitHub releases](https://github.com/higress-group/higress/releases) and the
|
||||
[`release-notes`](./release-notes) directory.
|
||||
65
SECURITY.md
65
SECURITY.md
@@ -17,11 +17,15 @@ your contributions.
|
||||
**Please do NOT report security vulnerabilities through public GitHub issues,
|
||||
discussions, or pull requests.**
|
||||
|
||||
Instead, please report them through **both** of the following private channels:
|
||||
Every vulnerability report must be submitted through both of the following
|
||||
private channels. Please provide the same substantive report to each channel
|
||||
and, when available, include the case identifier from the other channel so the
|
||||
Security Response Team can correlate the records. Do not wait for one channel
|
||||
to acknowledge the report before submitting it to the other.
|
||||
|
||||
1. **GitHub Private Security Advisory**:
|
||||
1. **GitHub Private Security Advisory (required)**:
|
||||
<https://github.com/higress-group/higress/security/advisories/new>
|
||||
2. **Alibaba Security Response Center (ASRC)**:
|
||||
2. **Alibaba Security Response Center (required)**:
|
||||
<https://security.alibaba.com/>
|
||||
|
||||
Please include as much of the following information as possible to help us
|
||||
@@ -53,9 +57,58 @@ keep you informed of our progress throughout the process.
|
||||
|
||||
## Security Response Team
|
||||
|
||||
The Higress security response is handled by the project maintainers listed in
|
||||
[`MAINTAINERS.md`](./MAINTAINERS.md). Security reports submitted via GitHub
|
||||
Private Security Advisory are visible to all current maintainers.
|
||||
The Security Response Team (SRT) is composed of the current project
|
||||
maintainers:
|
||||
|
||||
- Yiquan Dong ([@CH3CHO](https://github.com/CH3CHO))
|
||||
- Yuanxiao Zhao ([@EndlessSeeker](https://github.com/EndlessSeeker))
|
||||
- Leilei Geng ([@gengleilei](https://github.com/gengleilei))
|
||||
- Xiantao Han ([@hanxiantao](https://github.com/hanxiantao))
|
||||
- Zhiwei Cheng ([@cr7258](https://github.com/cr7258))
|
||||
- Tianyi Zhang ([@johnlanni](https://github.com/johnlanni))
|
||||
- Jingfeng Xu ([@lexburner](https://github.com/lexburner))
|
||||
|
||||
[`MAINTAINERS.md`](./MAINTAINERS.md) is authoritative for membership. A merged
|
||||
change to that roster onboards or offboards the same person from the SRT and
|
||||
their private security access must be updated promptly.
|
||||
|
||||
For each report, the SRT assigns the following responsibilities in the private
|
||||
advisory or equivalent confidential case record:
|
||||
|
||||
- **Triage coordinator**: acknowledges the report, maintains contact with the
|
||||
reporter, assigns severity, tracks deadlines, and coordinates the team.
|
||||
- **Fix lead**: reproduces the issue and develops or coordinates remediation.
|
||||
- **Reviewer and release lead**: independently reviews the fix, prepares the
|
||||
supported-version releases, and verifies that artifacts are available.
|
||||
- **Disclosure lead**: prepares the advisory, CVE request when appropriate,
|
||||
credits, and coordinated public communication.
|
||||
|
||||
One person may perform more than one role, but every confirmed vulnerability
|
||||
must involve at least two unconflicted SRT members so that remediation receives
|
||||
independent review.
|
||||
|
||||
### Report handling, conflicts, and escalation
|
||||
|
||||
1. The SRT correlates the required GitHub Private Security Advisory and Alibaba
|
||||
Security Response Center submissions. The GitHub advisory or an equivalent
|
||||
access-controlled project record contains the report, assignments,
|
||||
decisions, timeline, fix, and disclosure plan; material status and
|
||||
disclosure updates are reflected in both required reporting records.
|
||||
2. An SRT member with a personal, employer, or product conflict must disclose
|
||||
it privately and recuse from severity, release, or disclosure decisions for
|
||||
that case. The triage coordinator assigns an unconflicted replacement.
|
||||
3. If acknowledgement, triage, remediation, or disclosure is at risk of
|
||||
missing the timelines in this policy, the triage coordinator escalates the
|
||||
case to the full unconflicted SRT and records a revised plan. Critical
|
||||
vulnerabilities are escalated immediately.
|
||||
4. If a reporter receives no acknowledgement within three business days, they
|
||||
should follow up through both private reporting channels and reference both
|
||||
case identifiers when available. No vulnerability details should be posted
|
||||
publicly.
|
||||
5. If fewer than two SRT members are unconflicted, the unconflicted member
|
||||
escalates confidentially to the CNCF TOC private mailing list at
|
||||
[cncf-private-toc@lists.cncf.io](mailto:cncf-private-toc@lists.cncf.io)
|
||||
before a release or disclosure decision is made.
|
||||
|
||||
## Disclosure Policy
|
||||
|
||||
|
||||
409
docs/cncf/general-technical-review.md
Normal file
409
docs/cncf/general-technical-review.md
Normal file
@@ -0,0 +1,409 @@
|
||||
# General Technical Review — Higress / Incubation
|
||||
|
||||
- **Project:** Higress
|
||||
- **Project version:** v2.2.3
|
||||
- **Website:** <https://higress.ai/en/>
|
||||
- **Date updated:** 2026-07-21
|
||||
- **Template version:** CNCF General Technical Review v1.0
|
||||
- **Review state:** Project working draft; maintainer approval and CNCF Project
|
||||
Reviews verification pending
|
||||
- **Evidence branch:**
|
||||
[`higress-group/higress@main`](https://github.com/higress-group/higress/tree/main)
|
||||
- **Intended TOC snapshot:** `projects/higress/tech-review/YYYY-MM-DD.md`
|
||||
- **Description:** Higress is a cloud-native API gateway built on Envoy and
|
||||
Istio. It supports Kubernetes Ingress and Gateway API and is extensible with
|
||||
Wasm plugins and native Go filters.
|
||||
|
||||
This document answers all Day 0 and Day 1 questions required for an Incubation
|
||||
review. It follows the current CNCF TOC
|
||||
[General Technical Review questionnaire](https://github.com/cncf/toc/blob/main/toc_subprojects/project-reviews-subproject/general-technical-questions.md).
|
||||
Statements that are not yet backed by a project guarantee are identified as
|
||||
gaps rather than treated as completed controls.
|
||||
|
||||
This is the project-maintained working copy. For formal Due Diligence, a CNCF
|
||||
reviewer or associate verifies it and archives a dated snapshot in `cncf/toc`.
|
||||
The reviewer should freeze evidence links to the reviewed revision when that
|
||||
snapshot is archived.
|
||||
|
||||
## Project Self-Assessment Summary
|
||||
|
||||
Higress has substantive evidence for every Day 0 and Day 1 question, but this
|
||||
document does not assign the project an external technical-review rating. The
|
||||
strongest Day 1 gaps are the absence of an automated
|
||||
upgrade→downgrade→upgrade matrix, a published compatibility-review cadence,
|
||||
and complete release supply-chain attestations. Broad control-plane RBAC also
|
||||
requires minimization and documented justification. These findings should be
|
||||
tracked during Due Diligence rather than hidden by marking the questionnaire
|
||||
complete.
|
||||
|
||||
Day 2 questions are not required for an Incubation application and are not
|
||||
answered in this snapshot. Operational evidence that already exists may be
|
||||
added during CNCF review, but it is not needed to make the Day 0 and Day 1
|
||||
scope complete.
|
||||
|
||||
## Day 0 — Planning Phase
|
||||
|
||||
### Scope
|
||||
|
||||
**How are roadmap scope and mid- to long-term features determined, and how does
|
||||
that map to contributions and the maintainer ladder?**
|
||||
|
||||
Forward-looking goals and target versions are maintained in the public
|
||||
[`ROADMAP.md`](https://github.com/higress-group/higress/blob/main/ROADMAP.md),
|
||||
which links the version-controlled website roadmap. Feature scope and roadmap
|
||||
changes are discussed through public GitHub issues, discussions, and pull
|
||||
requests. Maintainers use lazy consensus, as documented in
|
||||
[`GOVERNANCE.md`](https://github.com/higress-group/higress/blob/main/GOVERNANCE.md),
|
||||
to accept project-direction changes.
|
||||
Contributors implement accepted work through pull requests, may receive
|
||||
delegated path-review responsibility as code owners, and may be nominated as
|
||||
maintainers after sustained contribution under
|
||||
[`MAINTAINERS.md`](https://github.com/higress-group/higress/blob/main/MAINTAINERS.md).
|
||||
Objective progression expectations for the intermediate code-owner role remain
|
||||
a project-maturity gap.
|
||||
|
||||
**Who are the target personas?**
|
||||
|
||||
- Platform and gateway engineers operating shared application entry points.
|
||||
- Application teams publishing APIs through Ingress or Gateway API.
|
||||
- AI platform teams governing access to LLM and MCP services.
|
||||
- Plugin developers extending request and response processing.
|
||||
- Security and SRE teams configuring policy, telemetry, and reliability.
|
||||
|
||||
**What are the primary, additional, and unsupported use cases?**
|
||||
|
||||
The primary use case is routing and governing north-south API traffic. Other
|
||||
supported use cases include Kubernetes Ingress and Gateway API, service
|
||||
registry integration, AI/LLM proxying, MCP server exposure, authentication,
|
||||
rate limiting, observability, and standalone development installations.
|
||||
|
||||
Higress is not a transparent east-west service mesh, identity provider, model
|
||||
provider, general-purpose application runtime, secrets manager, or substitute
|
||||
for an organization's PKI, security governance, and incident response.
|
||||
|
||||
**Which organizations benefit from adoption?**
|
||||
|
||||
Organizations operating Kubernetes platforms, microservices, public or
|
||||
partner APIs, multi-provider AI platforms, or centralized platform-engineering
|
||||
services can benefit. Public examples are listed in
|
||||
[`ADOPTERS.md`](https://github.com/higress-group/higress/blob/main/ADOPTERS.md).
|
||||
|
||||
**What end-user research has been completed?**
|
||||
|
||||
The project records adopter names and use cases in `ADOPTERS.md` and receives
|
||||
feedback through GitHub and Discord. No independent end-user research report or
|
||||
published structured interview set is currently available. Adopter interviews
|
||||
for CNCF Incubation remain to be completed through the official TOC process.
|
||||
|
||||
### Usability
|
||||
|
||||
**How do target personas interact with the project?**
|
||||
|
||||
Cluster administrators install Higress with Helm. Platform teams configure it
|
||||
with Kubernetes Ingress, Gateway API, Higress and Istio custom resources, the
|
||||
`hgctl` CLI, or the optional console. Application teams normally submit routes
|
||||
and policies rather than operate the data plane directly. Plugin developers use
|
||||
the Wasm SDKs or native filter interfaces.
|
||||
|
||||
**What are the UX and UI?**
|
||||
|
||||
The primary interface is declarative Kubernetes YAML and Helm values. The
|
||||
optional Higress Console provides browser-based management of routes, services,
|
||||
certificates, and plugins. `hgctl` provides command-line operations. Runtime
|
||||
behavior is observed through logs, metrics, traces, Kubernetes status, and
|
||||
Envoy administration data where enabled.
|
||||
|
||||
**How does Higress integrate with production environments?**
|
||||
|
||||
Higress integrates with Kubernetes, Envoy, Istio APIs, Gateway API,
|
||||
Prometheus-compatible monitoring, OpenTelemetry tracing, OCI registries,
|
||||
certificate issuers, and service registries such as Nacos, Consul, Eureka, and
|
||||
ZooKeeper. AI deployments may integrate with identity services, Redis, and
|
||||
model providers. Except for Kubernetes in the standard Helm deployment, these
|
||||
integrations are optional and selected by the adopter.
|
||||
|
||||
### Design
|
||||
|
||||
**What design principles and practices are followed?**
|
||||
|
||||
- Declarative, versioned APIs and reconciliation.
|
||||
- Separation of control plane and Envoy-based data plane.
|
||||
- xDS dynamic updates without gateway configuration reloads.
|
||||
- Standard Kubernetes Ingress and Gateway API where possible.
|
||||
- Independently versioned extensions, with Wasm used as an isolation boundary.
|
||||
- Compatibility and regression coverage through unit, race, conformance, and
|
||||
end-to-end tests.
|
||||
|
||||
The component architecture and data flow are documented in
|
||||
[`docs/architecture.md`](https://github.com/higress-group/higress/blob/main/docs/architecture.md).
|
||||
|
||||
**What differs between proof-of-concept, development, test, and production?**
|
||||
|
||||
The all-in-one image or a minimal single-cluster Helm installation is suitable
|
||||
for evaluation. CI uses ephemeral kind clusters. Production operators should
|
||||
pin versions, use multiple gateway replicas, define resource sizing, configure
|
||||
PodDisruptionBudgets and topology placement, use production PKI, and monitor
|
||||
traffic and xDS health. The chart defaults to one controller replica and does
|
||||
not constitute a production high-availability guarantee.
|
||||
|
||||
**Which services are required in the cluster?**
|
||||
|
||||
The Kubernetes deployment requires the Kubernetes API and DNS. Higress
|
||||
controller/discovery supplies xDS to the gateway. The console, external service
|
||||
registries, Redis, certificate issuers, observability backends, OCI registries,
|
||||
and AI providers are optional.
|
||||
|
||||
**How is identity and access management implemented?**
|
||||
|
||||
Kubernetes ServiceAccounts and RBAC authorize control-plane access to cluster
|
||||
resources. TokenReview and SubjectAccessReview APIs are used by inherited Istio
|
||||
control-plane functions. Gateway-facing identity and policy are configured with
|
||||
TLS and plugins such as JWT, OIDC, key-auth, HMAC, and basic-auth. Higress does
|
||||
not operate an identity provider.
|
||||
|
||||
**How is sovereignty addressed?**
|
||||
|
||||
Higress does not transmit project usage telemetry by default. Operators choose
|
||||
their image/plugin registries, model providers, observability destinations,
|
||||
certificate authorities, and data locations. Request data is nevertheless
|
||||
processed by every configured gateway plugin and upstream provider; operators
|
||||
are responsible for selecting integrations that meet residency requirements.
|
||||
|
||||
**Which compliance requirements are addressed?**
|
||||
|
||||
The open-source project does not claim PCI-DSS, SOC 2, ISO 27001, GDPR, or other
|
||||
regulatory certification. It is Apache-2.0 licensed and CI includes source and
|
||||
dependency license checks. Deployers must assess their full environment and
|
||||
configuration.
|
||||
|
||||
**What are the high-availability requirements?**
|
||||
|
||||
Gateway availability requires multiple replicas or nodes, adequate capacity,
|
||||
health probes, and failure-domain placement. Controller unavailability stops
|
||||
new configuration but existing Envoy processes may continue serving their last
|
||||
accepted configuration. Production control-plane availability requires
|
||||
multiple appropriately configured replicas and Kubernetes leader-election
|
||||
behavior. The default single controller is a gap operators must address.
|
||||
|
||||
**What are the CPU, network, memory, and storage requirements?**
|
||||
|
||||
Current Helm defaults request 500m CPU and 2 GiB memory for the controller and
|
||||
2 CPU and 2 GiB memory per gateway. Actual requirements depend on route count,
|
||||
plugin set, configuration churn, request rate, payload sizes, connections, and
|
||||
telemetry. Operators must size from load tests. Traffic bandwidth and
|
||||
connection count drive network and socket consumption.
|
||||
|
||||
Configuration and certificates are stored as Kubernetes API objects. Gateway
|
||||
and controller runtime files are ephemeral. The optional console and configured
|
||||
integrations may introduce separate persistence requirements.
|
||||
|
||||
**What is the API design?**
|
||||
|
||||
- **Topology and conventions:** Kubernetes Ingress, Gateway API, Istio
|
||||
networking APIs, and versioned Higress CRDs under `networking.higress.io` and
|
||||
`extensions.higress.io` are reconciled into xDS resources.
|
||||
- **Defaults:** Helm defaults create controller/discovery and gateway
|
||||
deployments, ServiceAccounts, RBAC, Services, and CRDs. Gateway service type
|
||||
defaults to `LoadBalancer`; automatic HTTPS is enabled in chart values.
|
||||
- **Additional configuration:** Production use normally requires explicit
|
||||
resource sizing, exposure, TLS, replicas, credentials, monitoring, and route
|
||||
policy.
|
||||
- **New/changed calls:** Enabling registry, certificate, identity, OCI, Redis,
|
||||
or AI integrations causes calls to the endpoints explicitly configured by
|
||||
the operator. Enabling Gateway API or inference support causes the controller
|
||||
to watch and reconcile those API groups.
|
||||
- **Kubernetes compatibility:** Compatibility is tested with kind and Gateway
|
||||
API conformance. CRD and Kubernetes-version requirements are carried in Helm
|
||||
packaging and release dependencies.
|
||||
- **Versioning and breaking changes:** CRDs have explicit API versions. Stable
|
||||
breaking changes require migration guidance, deprecation notice, and release
|
||||
notes. Alpha Gateway API and inference capabilities are explicitly enabled
|
||||
and do not have the same stability promise as stable APIs.
|
||||
|
||||
**What is the release process?**
|
||||
|
||||
Higress uses semantic version tags, including release-candidate suffixes when
|
||||
needed. A `v*.*.*` tag triggers workflows that build controller, pilot and
|
||||
gateway images, attach generated CRDs, build multi-platform `hgctl` archives,
|
||||
and generate release notes. Versions are recorded in `VERSION`, Helm chart
|
||||
metadata, dependency metadata, and `release-notes/`. The normative
|
||||
[`RELEASE.md`](https://github.com/higress-group/higress/blob/main/RELEASE.md)
|
||||
defines planning, two-person approval, validation, tagging, publishing,
|
||||
verification, rollback/correction, security releases, and supported-version
|
||||
changes.
|
||||
|
||||
### Installation
|
||||
|
||||
**How is the project installed and initialized?**
|
||||
|
||||
```console
|
||||
helm repo add higress.io https://higress.io/helm-charts
|
||||
helm repo update
|
||||
helm install higress -n higress-system higress.io/higress --create-namespace
|
||||
```
|
||||
|
||||
The chart installs CRDs, controller/discovery, gateway, Services,
|
||||
ServiceAccounts, and RBAC. Operators then apply routes and policies or use the
|
||||
optional console.
|
||||
|
||||
**How does an adopter validate installation?**
|
||||
|
||||
Check the Helm release and Ready state of controller and gateway pods, apply a
|
||||
sample backend and route, then send an HTTP request through the gateway and
|
||||
inspect status, logs, and metrics. CI performs the same class of installation
|
||||
in ephemeral clusters and runs Gateway API and Higress conformance traffic.
|
||||
|
||||
### Security
|
||||
|
||||
**Where is the cloud-native security self-assessment?**
|
||||
|
||||
[`docs/cncf/security-self-assessment.md`](./security-self-assessment.md)
|
||||
|
||||
**How does Higress address the Cloud Native Security Tenets?**
|
||||
|
||||
- **Secure by default:** gateway containers use non-root and restricted
|
||||
settings on supported Kubernetes platforms, TLS and private Secret delivery
|
||||
are supported, and optional integrations require explicit configuration.
|
||||
- **Least privilege:** separate ServiceAccounts and RBAC exist for controller
|
||||
and gateway. The controller's broad ClusterRole and empty default container
|
||||
security context are known exceptions requiring improvement.
|
||||
- **Defense in depth:** Kubernetes RBAC, TLS/SDS, Envoy validation, Wasm
|
||||
isolation, authentication/policy plugins, and network controls provide
|
||||
distinct layers.
|
||||
- **Explicit trust and boundaries:** Kubernetes administrators, xDS,
|
||||
certificate sources, plugins, registries, identity providers, and upstreams
|
||||
are separate trust boundaries described in the security self-assessment.
|
||||
- **Secure lifecycle:** public review, automated tests, license checks, weekly
|
||||
CodeQL, private advisories, and coordinated disclosure are in place, with
|
||||
SBOM/signing/dynamic-analysis gaps disclosed.
|
||||
|
||||
Operators can loosen security by overriding pod/container security contexts,
|
||||
using privileged or host networking modes, expanding RBAC, exposing admin
|
||||
ports, disabling TLS/policy, or installing third-party/native plugins. These
|
||||
settings should be treated as risk acceptances and tested in the adopter's
|
||||
threat model.
|
||||
|
||||
**What security hygiene is maintained, and which features are high risk if not
|
||||
maintained?**
|
||||
|
||||
The project uses required review, build and unit tests with Go race detection,
|
||||
Gateway API/Higress conformance, plugin tests, license checks, weekly CodeQL,
|
||||
versioned dependencies, Private Security Advisories, and coordinated
|
||||
disclosure. Certificate handling, RBAC reconciliation, xDS generation and
|
||||
validation, parsers/routing, authentication and authorization plugins, plugin
|
||||
loading, and release artifacts are treated as security-critical boundaries.
|
||||
|
||||
**What privileges are required?**
|
||||
|
||||
The gateway reads Kubernetes Secrets for SDS. The controller watches and
|
||||
reconciles ingress, Gateway API, Higress/Istio, discovery, certificate,
|
||||
admission, and leader-election resources and performs TokenReview and
|
||||
SubjectAccessReview calls. Several controller rules are cluster-wide and use
|
||||
broad resource or verb sets. These are functional requirements of the current
|
||||
implementation, but the project has not demonstrated that every permission is
|
||||
minimal.
|
||||
|
||||
**How are certificates rotated?**
|
||||
|
||||
Gateway certificates are delivered through SDS. Automatic HTTPS can request
|
||||
and renew ACME certificates, and Kubernetes Secrets can be updated without
|
||||
gateway configuration reload. Operators remain responsible for issuer trust,
|
||||
renewal monitoring, emergency rotation, and external certificate systems.
|
||||
|
||||
**How is the software supply chain secured?**
|
||||
|
||||
Source and dependency licenses are checked; dependencies and submodule commits
|
||||
are versioned; changes are reviewed and tested; release artifacts are produced
|
||||
by GitHub Actions from version tags. Some workflow actions are commit-pinned.
|
||||
Project-generated release SBOMs, universal immutable action pinning, artifact
|
||||
signatures, and verifiable build provenance are not currently present and are
|
||||
recorded as gaps in the security self-assessment.
|
||||
|
||||
## Day 1 — Installation and Deployment Phase
|
||||
|
||||
### Project Installation and Configuration
|
||||
|
||||
**What do installation and configuration look like?**
|
||||
|
||||
Helm installs the project resources. Values select watched namespaces, replica
|
||||
counts, resources, autoscaling, Service exposure, security contexts,
|
||||
observability, Gateway API features, image/plugin registries, and optional
|
||||
integrations. Operators should pin a chart/application version and keep reviewed
|
||||
values in version control. Routes, services, credentials, and plugin policy are
|
||||
then applied as Kubernetes resources or through the console.
|
||||
|
||||
### Project Enablement and Rollback
|
||||
|
||||
**How is Higress enabled or disabled in a live cluster, and is downtime
|
||||
required?**
|
||||
|
||||
Install the chart and direct selected IngressClasses, GatewayClasses, routes,
|
||||
DNS, or load-balancer traffic to Higress. Workloads do not require sidecars.
|
||||
With sufficient replicas, ordinary controller and gateway rolling updates are
|
||||
designed not to require application downtime. Capacity loss, incompatible
|
||||
configuration, or a single-replica deployment can cause interruption.
|
||||
|
||||
Disable Higress by first moving traffic and route ownership to another entry
|
||||
point, then uninstalling the Helm release. Existing workload behavior changes
|
||||
only when traffic or resources select Higress.
|
||||
|
||||
**How are enablement and disablement tested?**
|
||||
|
||||
Pull-request CI installs Higress into kind and executes conformance and E2E
|
||||
traffic. There is no dedicated automated migration test that moves live traffic
|
||||
from another gateway to Higress and back; this is a gap.
|
||||
|
||||
**How are created resources cleaned up, including CRDs?**
|
||||
|
||||
`helm uninstall` removes chart-owned namespaced and cluster-scoped resources
|
||||
managed by the release. CRDs and user-authored custom resources must be
|
||||
inventoried and removed deliberately because automatic deletion could destroy
|
||||
configuration data.
|
||||
|
||||
### Rollout, Upgrade and Rollback Planning
|
||||
|
||||
**How is compatibility with Kubernetes and orchestration tools maintained?**
|
||||
|
||||
The project updates Kubernetes, Gateway API, Istio, and Envoy dependencies and
|
||||
uses build, kind, conformance, and E2E workflows on ongoing changes. Supported
|
||||
versions are communicated through chart/dependency metadata, documentation,
|
||||
and releases. No fixed public compatibility-review cadence is documented.
|
||||
|
||||
**How are rollbacks performed, and how can they fail?**
|
||||
|
||||
Operators retain the previous Helm revision, values, configuration, and pinned
|
||||
images, then use `helm rollback <release> <revision>`. CRD storage/schema
|
||||
changes, incompatible user configuration, unavailable old images, RBAC changes,
|
||||
certificate changes, or external dependency changes may prevent a complete
|
||||
rollback. Control-plane failure may leave existing gateways serving their last
|
||||
accepted xDS state; data-plane rollout, plugin load failure, or invalid
|
||||
fail-closed policy can affect live traffic.
|
||||
|
||||
**Which metrics should trigger rollback?**
|
||||
|
||||
Readiness failures, xDS rejection/NACKs, HTTP 4xx/5xx changes, latency and
|
||||
timeout regression, gateway/controller restart rate, certificate errors,
|
||||
plugin load failures, connection failures, and CPU/memory/socket saturation
|
||||
should be compared with the pre-rollout baseline.
|
||||
|
||||
**How are upgrade→downgrade→upgrade paths tested?**
|
||||
|
||||
The repository tests clean installation and current-version conformance but
|
||||
does not contain a dedicated automated upgrade→downgrade→upgrade version
|
||||
matrix. This is a known Day 1 gap; operators must validate their exact source
|
||||
and target versions in a non-production cluster.
|
||||
|
||||
**How are deprecations and removals communicated?**
|
||||
|
||||
They are communicated through GitHub releases, versioned release notes,
|
||||
documentation, and API/version changes. `RELEASE.md` requires migration and
|
||||
deprecation guidance in the release pull request and announcement. The project
|
||||
does not currently define a universal minimum deprecation period for all API
|
||||
types, which remains a process improvement.
|
||||
|
||||
**How are alpha and beta capabilities used during rollout?**
|
||||
|
||||
Alpha Gateway API and inference capabilities are selected through explicit
|
||||
chart values and API versions. Operators should enable them first in a test or
|
||||
canary environment, monitor the rollback signals above, and avoid relying on
|
||||
their stability as if they were stable APIs.
|
||||
274
docs/cncf/governance-review.md
Normal file
274
docs/cncf/governance-review.md
Normal file
@@ -0,0 +1,274 @@
|
||||
# Higress Governance Review
|
||||
|
||||
This is the Higress project's self-assessed Governance Review for its CNCF
|
||||
Incubation application. It follows the current CNCF TOC
|
||||
[Governance Review Template](https://github.com/cncf/toc/blob/main/toc_subprojects/project-reviews-subproject/governance-review-template.md).
|
||||
A CNCF Project Reviews reviewer may amend the assessment and findings during
|
||||
the public review.
|
||||
|
||||
- **Review state:** Project working draft; maintainer approval and CNCF Project
|
||||
Reviews verification pending
|
||||
- **Evidence branch:**
|
||||
[`higress-group/higress@main`](https://github.com/higress-group/higress/tree/main)
|
||||
- **Template verified:** 2026-07-21 against `cncf/toc` `main`
|
||||
- **Intended TOC snapshot:**
|
||||
`projects/higress/governance-review/YYYY-MM-DD.md`
|
||||
|
||||
This is the project-maintained working copy. CNCF reviewers may revise the
|
||||
status and findings before a dated snapshot is archived in `cncf/toc`.
|
||||
The reviewer should freeze evidence links to the reviewed revision when that
|
||||
snapshot is archived.
|
||||
|
||||
## Summary and Assessment
|
||||
|
||||
**Status: Mostly Satisfactory**
|
||||
|
||||
Higress has discoverable governance, maintainer, code-owner, contribution,
|
||||
community, security, and Code of Conduct documents. The project defines
|
||||
project-wide maintainer responsibility, delegated code ownership, subproject
|
||||
scope, vendor-neutral decisions, maintainer lifecycle, communication channels,
|
||||
and security-response roles. The maintainer list includes seven publicly active
|
||||
people affiliated with four organizations.
|
||||
|
||||
The documentation and public evidence satisfy all Governance Review criteria
|
||||
except one Incubation-required item: Higress does not currently publish an
|
||||
up-to-date public meeting scheduler or integrate its meetings with the CNCF
|
||||
calendar. The project should establish that real public meeting infrastructure
|
||||
before asserting that the Governance Review has no remaining Must-Fix item.
|
||||
|
||||
### Executing the Assessment
|
||||
|
||||
The self-assessment reviewed the following repository evidence as it exists on
|
||||
2026-07-21:
|
||||
|
||||
- [`GOVERNANCE.md`](https://github.com/higress-group/higress/blob/main/GOVERNANCE.md)
|
||||
- [`MAINTAINERS.md`](https://github.com/higress-group/higress/blob/main/MAINTAINERS.md)
|
||||
- [`CODEOWNERS`](https://github.com/higress-group/higress/blob/main/CODEOWNERS)
|
||||
- [`CONTRIBUTING_EN.md`](https://github.com/higress-group/higress/blob/main/CONTRIBUTING_EN.md)
|
||||
- [`CODE_OF_CONDUCT.md`](https://github.com/higress-group/higress/blob/main/CODE_OF_CONDUCT.md)
|
||||
- [`COMMUNITY.md`](https://github.com/higress-group/higress/blob/main/COMMUNITY.md)
|
||||
- [`SECURITY.md`](https://github.com/higress-group/higress/blob/main/SECURITY.md)
|
||||
- [`README.md`](https://github.com/higress-group/higress/blob/main/README.md)
|
||||
- Git history for the governance, maintainer, and ownership files
|
||||
|
||||
The review distinguishes documented policy from observed repository evidence.
|
||||
It does not infer private processes or settings that are not publicly recorded.
|
||||
|
||||
### Must-Fix Items
|
||||
|
||||
The following issues need to be resolved before the Higress Incubation
|
||||
application asserts completion of its Governance Review:
|
||||
|
||||
1. Publish an up-to-date public meeting scheduler and/or integrate the project
|
||||
meetings with the CNCF calendar.
|
||||
|
||||
### Points of Excellence
|
||||
|
||||
- The maintainer roster is public and shows affiliation diversity across
|
||||
Alibaba Cloud, Trip.com, XinYe Technology, and NVIDIA, with linked public
|
||||
activity evidence for every listed maintainer.
|
||||
- Governance distinguishes delegated code ownership from project-wide
|
||||
maintainer authority and defines role and subproject lifecycle processes.
|
||||
- The project publishes an authoritative channel inventory and separates
|
||||
public project decisions from narrowly scoped confidential reporting.
|
||||
- The named Security Response Team has defined incident roles, independent
|
||||
review, conflict handling, and escalation.
|
||||
- Governance explicitly protects vendor-neutral direction through individual
|
||||
seats, equal voting weight, and conflict disclosure.
|
||||
|
||||
### Areas for Improvement
|
||||
|
||||
The following are non-blocking at Incubation but would improve long-term
|
||||
governance maturity:
|
||||
|
||||
- Record governance evolution and link decisions to issues or pull requests.
|
||||
- Publish examples demonstrating the maintainer lifecycle in practice.
|
||||
- Add objective progression expectations for appointment to the code-owner
|
||||
role.
|
||||
- Extend `CODEOWNERS` or equivalent ownership records to each active
|
||||
subproject and periodically audit coverage.
|
||||
- Track contributor growth and recruitment trends using the linked CNCF
|
||||
DevStats metrics, not only point-in-time activity.
|
||||
|
||||
---
|
||||
|
||||
## Review
|
||||
|
||||
This review audits the project's current governance evidence. The project's
|
||||
Incubation application has not yet been opened; this file is the project's
|
||||
self-assessment to accompany that application.
|
||||
|
||||
### Governance Summary
|
||||
|
||||
Higress uses maintainer-led lazy consensus. Significant decisions are recorded
|
||||
in public issues or pull requests. When consensus cannot be reached,
|
||||
non-conflicted maintainers may vote publicly and a simple majority of votes
|
||||
cast decides. Governance defines contributor, code-owner, and maintainer
|
||||
authority; the maintainer and subproject lifecycles; vendor neutrality; and
|
||||
function-based teams. Confidential security work is handled by a named
|
||||
Security Response Team under a public mandate.
|
||||
|
||||
### Governance Evolution
|
||||
|
||||
**Incubating: Suggested — Partially satisfied.**
|
||||
|
||||
`GOVERNANCE.md` and `MAINTAINERS.md` were introduced in April 2026, and
|
||||
`CODEOWNERS` has changed repeatedly since 2022. This demonstrates change over
|
||||
time, but the repository does not explain why governance evolved or connect
|
||||
those changes to project experience and outcomes.
|
||||
|
||||
### Discoverability
|
||||
|
||||
**Incubating: Suggested — Satisfied.**
|
||||
|
||||
The README links the governance, maintainer, contribution, Code of Conduct, and
|
||||
security documents from its Community section.
|
||||
|
||||
### Accuracy and Clarity
|
||||
|
||||
**Governance reflects actual activities — Incubating: Suggested — Satisfied.**
|
||||
|
||||
Public issue/PR collaboration and lazy consensus are consistent with the
|
||||
repository workflow. Code-owner authority, security-team operation, and the
|
||||
absence of a current recurring public meeting are documented without claiming
|
||||
unobserved processes.
|
||||
|
||||
**Vendor-neutral direction — Incubating: Suggested — Satisfied.**
|
||||
|
||||
Governance states that roles are held by individuals, prohibits guaranteed
|
||||
employer seats, vetoes, or preferred decision weight, requires material
|
||||
conflict disclosure, and evaluates contributions and integrations on community
|
||||
and technical merit.
|
||||
|
||||
### Decisions and Role Assignments
|
||||
|
||||
**Leadership, contribution, CNCF, governance, and goal decisions — Incubating:
|
||||
Suggested — Satisfied.**
|
||||
|
||||
Governance applies public lazy consensus and non-conflicted maintainer voting
|
||||
to leadership, contribution acceptance, goals, requests to CNCF, subproject
|
||||
scope, and governance changes. The contribution guide and `CODEOWNERS` provide
|
||||
the day-to-day change-review path.
|
||||
|
||||
**Function-based teams — Incubating: Suggested — Satisfied.**
|
||||
|
||||
Governance defines the creation and retirement requirements for function-based
|
||||
teams. `SECURITY.md` names the Security Response Team and defines onboarding,
|
||||
offboarding, incident assignments, conflicts, independent review, and
|
||||
escalation.
|
||||
|
||||
### Maintainers and Maintainer Lifecycle
|
||||
|
||||
**Complete lifecycle — Incubating: Suggested — Satisfied.**
|
||||
|
||||
The project documents nomination, public activity review, voluntary departure,
|
||||
inactivity, removal, emeritus status, and return.
|
||||
|
||||
**Lifecycle demonstrated — Incubating: Suggested — Not demonstrated.**
|
||||
|
||||
The maintainer file has only one introducing commit in the available history.
|
||||
No public example of adding, replacing, or moving a maintainer to emeritus is
|
||||
linked.
|
||||
|
||||
**Names, contact, responsibility, affiliation — Incubating: Required —
|
||||
Satisfied.**
|
||||
|
||||
The roster provides names, GitHub contacts, project-wide responsibility
|
||||
domains, and affiliations for all maintainers.
|
||||
|
||||
**Appropriate number of active maintainers — Incubating: Required —
|
||||
Satisfied.**
|
||||
|
||||
Seven maintainers cover the project and its four subprojects. The roster
|
||||
defines active status and annual public review, and its 2026 review links
|
||||
public issue and pull-request activity for every listed maintainer during the
|
||||
preceding 12 months.
|
||||
|
||||
**Maintainers from at least two organizations — Incubating: N/A, but met.**
|
||||
|
||||
The roster names four affiliations.
|
||||
|
||||
### Ownership
|
||||
|
||||
**Code and documentation ownership matches governance roles — Incubating:
|
||||
Required — Satisfied.**
|
||||
|
||||
Governance defines code owners as delegated path reviewers, documents their
|
||||
authority and public appointment/removal process, and makes clear that code
|
||||
ownership neither grants project-wide governance authority nor limits a
|
||||
maintainer's project-wide responsibility. This matches `CODEOWNERS`, which
|
||||
includes maintainers and additional area reviewers.
|
||||
|
||||
### Code of Conduct
|
||||
|
||||
**Adoption and adherence — Incubating: Required — Satisfied as documented.**
|
||||
|
||||
The project adopts the CNCF Code of Conduct in `CODE_OF_CONDUCT.md` and links it
|
||||
from the README and governance.
|
||||
|
||||
**Cross-link from governance — Incubating: Required — Satisfied.**
|
||||
|
||||
`GOVERNANCE.md` links both the CNCF Code of Conduct and the project copy.
|
||||
|
||||
### Subprojects
|
||||
|
||||
**All subprojects listed — Incubating: Required — Satisfied.**
|
||||
|
||||
Governance identifies the primary repository and lists `higress-console`,
|
||||
`higress-standalone`, `plugin-server`, and `wasm-go` as active subprojects. It
|
||||
also distinguishes upstream forks, integrations, examples, websites, and
|
||||
other organization repositories from the CNCF project scope.
|
||||
|
||||
**Subproject lifecycle — Incubating: Suggested — Satisfied.**
|
||||
|
||||
Governance applies the project-wide maintainer roster and public decision
|
||||
process to subprojects, records each repository's responsibility and status,
|
||||
and requires leadership, contribution, communication, and lifecycle details
|
||||
when a subproject is added, removed, transferred, or archived.
|
||||
|
||||
### Contributors and Community
|
||||
|
||||
**Contributor ladder — Incubating: Suggested — Partially satisfied.**
|
||||
|
||||
Governance defines contributor, delegated code-owner, and project-wide
|
||||
maintainer roles. The contribution and maintainer documents define how
|
||||
contributors participate and how sustained contributors progress to
|
||||
maintainership; code-owner assignments are changed publicly through
|
||||
`CODEOWNERS`. Objective progression expectations for the intermediate
|
||||
code-owner role can be made more explicit.
|
||||
|
||||
**Issue and change submission — Incubating: Required — Satisfied.**
|
||||
|
||||
`CONTRIBUTING_EN.md`, issue templates, and the pull-request template document
|
||||
the process.
|
||||
|
||||
**At least one public communication channel — Incubating: Required —
|
||||
Satisfied.**
|
||||
|
||||
GitHub Issues, pull requests, and Discord are publicly documented.
|
||||
|
||||
**All public/private channels documented — Incubating: Required — Satisfied.**
|
||||
|
||||
`COMMUNITY.md` is the authoritative inventory for project and subproject GitHub
|
||||
channels, Discord, mailing and localized community channels, documentation,
|
||||
and narrowly scoped private security and Code of Conduct reporting. It states
|
||||
that informal or employer-internal conversations are not project decision
|
||||
channels and requires public decision records.
|
||||
|
||||
**Public meeting scheduler/CNCF calendar — Incubating: Required — Not
|
||||
satisfied.**
|
||||
|
||||
No current public meeting scheduler or CNCF calendar integration is linked from
|
||||
the reviewed repository documents.
|
||||
|
||||
**Contribution documentation — Incubating: Required — Satisfied.**
|
||||
|
||||
The contribution guide covers issues, pull requests, branches, commits, tests,
|
||||
style, and AI-assisted contribution requirements.
|
||||
|
||||
**Contributor activity and recruitment — Incubating: Required — Satisfied.**
|
||||
|
||||
`COMMUNITY.md` links continuously updated GitHub contributor and repository
|
||||
activity, CNCF DevStats, active-maintainer evidence, `help wanted` and `good
|
||||
first issue` recruitment queues, the contribution path, and public mentoring
|
||||
channels.
|
||||
218
docs/cncf/incubation-application.md
Normal file
218
docs/cncf/incubation-application.md
Normal file
@@ -0,0 +1,218 @@
|
||||
# Higress CNCF Incubation Application — Working Draft
|
||||
|
||||
This working draft follows the CNCF TOC
|
||||
[Project Incubation Application v1.6](https://github.com/cncf/toc/blob/main/.github/ISSUE_TEMPLATE/template-incubation-application.md),
|
||||
verified on 2026-07-21. It is not ready to file while any item labelled
|
||||
**BLOCKED** below remains unresolved.
|
||||
|
||||
## Review Project Moving Level Evaluation
|
||||
|
||||
- [ ] I have reviewed the TOC moving-level readiness triage guide, ensured the
|
||||
criteria are met before opening the issue, and understand that unmet criteria
|
||||
will result in closure.
|
||||
|
||||
This box remains unchecked because the public meeting, access-control evidence,
|
||||
OpenSSF Passing badge, adopter interviews/verification, and remaining
|
||||
vendor-neutral resource work are incomplete.
|
||||
|
||||
## Project information
|
||||
|
||||
- **Project repositories:**
|
||||
[`higress`](https://github.com/higress-group/higress),
|
||||
[`higress-console`](https://github.com/higress-group/higress-console),
|
||||
[`higress-standalone`](https://github.com/higress-group/higress-standalone),
|
||||
[`plugin-server`](https://github.com/higress-group/plugin-server), and
|
||||
[`wasm-go`](https://github.com/higress-group/wasm-go)
|
||||
- **Project site:** <https://higress.ai/en/>
|
||||
- **Subprojects:** `higress-console`, `higress-standalone`, `plugin-server`,
|
||||
and `wasm-go`; authoritative scope is in
|
||||
[`GOVERNANCE.md`](https://github.com/higress-group/higress/blob/main/GOVERNANCE.md)
|
||||
- **Related projects:** confirm before submission whether any project point of
|
||||
contact operates a technically related project in another foundation
|
||||
- **Communication:**
|
||||
[`COMMUNITY.md`](https://github.com/higress-group/higress/blob/main/COMMUNITY.md)
|
||||
- **Project points of contact:** Yuanxiao Zhao and Yiquan Dong; **TODO:** confirm
|
||||
direct contact emails before filing (public community contact:
|
||||
[higress@googlegroups.com](mailto:higress@googlegroups.com))
|
||||
|
||||
## Incubation Criteria Summary
|
||||
|
||||
### Application Level Assertion
|
||||
|
||||
- [x] Higress is currently Sandbox, accepted on 2026-03-15, and is applying to
|
||||
Incubation.
|
||||
- [ ] Higress is applying to join CNCF directly at Incubation level. Not
|
||||
applicable; remove this option from the filed issue.
|
||||
|
||||
### Adoption Assertion
|
||||
|
||||
Public production adopters in
|
||||
[`ADOPTERS.md`](https://github.com/higress-group/higress/blob/main/ADOPTERS.md)
|
||||
include Ant Digital, Kuaishou, Trip.com, Vipshop, and Labring.
|
||||
|
||||
- [ ] **BLOCKED — Adopter interviews:** submit 5–7 willing adopters through the
|
||||
official CNCF Adopter Interview Questionnaire before DD assignment. This
|
||||
requires adopter consent and current contact details; a repository edit
|
||||
cannot complete it.
|
||||
|
||||
## Application Process Principles
|
||||
|
||||
### Suggested
|
||||
|
||||
- [ ] Engage with the appropriate domain-specific TAG(s) to present the
|
||||
technical architecture. This is suggested, not a filing prerequisite in
|
||||
v1.6. A future presentation should be linked here if completed.
|
||||
|
||||
### Required
|
||||
|
||||
- [x] Complete a General Technical Review. The project self-assessment is in
|
||||
[`general-technical-review.md`](https://github.com/higress-group/higress/blob/main/docs/cncf/general-technical-review.md);
|
||||
maintainer approval and CNCF Project Reviews verification are requested.
|
||||
- [ ] Complete a Governance Review. The self-assessment is in
|
||||
[`governance-review.md`](https://github.com/higress-group/higress/blob/main/docs/cncf/governance-review.md),
|
||||
but its public-meeting Must-Fix remains open.
|
||||
- [ ] **BLOCKED — All project metadata and resources are vendor-neutral.**
|
||||
Governance and the primary README now document vendor-neutral direction and
|
||||
no longer promote a single commercial product, but release images remain
|
||||
hosted only on Alibaba Cloud registry domains, the historical Go module path
|
||||
contains `github.com/alibaba`, the website roadmap still includes a
|
||||
single-vendor enterprise mapping, and the security policy requires duplicate
|
||||
submission to the vendor-operated Alibaba Security Response Center. The
|
||||
application needs a maintainer/CNCF-reviewed compatibility and infrastructure
|
||||
plan.
|
||||
- [x] Review and acknowledgement of Sandbox expectations and maturity-level
|
||||
requirements. Higress was accepted as a Sandbox project on 2026-03-15 and
|
||||
this application explicitly acknowledges the current requirements.
|
||||
- [ ] Due Diligence Review. This is completed through CNCF review, resolution
|
||||
of concerns, and public comment after a ready application is filed.
|
||||
- [x] Appropriate installation, end-user, reference, and sample documentation
|
||||
is linked from the project README and website.
|
||||
|
||||
## Governance and Maintainers
|
||||
|
||||
### Suggested
|
||||
|
||||
- [ ] Complete the Governance Review with the CNCF Project Reviews subproject;
|
||||
external verification is pending.
|
||||
- [x] Governance is discoverable and version controlled.
|
||||
- [x] Governance documents actual public decision, leadership, role, and
|
||||
security-team processes without claiming a recurring meeting that does not
|
||||
exist.
|
||||
- [x] Governance documents vendor-neutral project direction and conflicts.
|
||||
- [x] Leadership, contribution, CNCF request, governance, goal, and subproject
|
||||
decisions use a public process.
|
||||
- [x] Function-based team assignment, onboarding, removal, conflicts, and
|
||||
retirement are documented.
|
||||
- [x] The complete maintainer lifecycle is documented.
|
||||
- [ ] Demonstrate a completed maintainer lifecycle event. Suggested; no public
|
||||
addition, replacement, or emeritus transition is yet linked.
|
||||
- [x] Subproject scope, leadership model, contribution, status, and lifecycle
|
||||
are documented.
|
||||
|
||||
### Required
|
||||
|
||||
- [x] Current maintainers include names, GitHub contact, responsibility domain,
|
||||
and affiliation in
|
||||
[`MAINTAINERS.md`](https://github.com/higress-group/higress/blob/main/MAINTAINERS.md).
|
||||
- [x] Seven active maintainers are appropriate to the primary repository and
|
||||
four subprojects; the annual activity review links public evidence for each.
|
||||
- [x] Code and documentation ownership matches the documented code-owner and
|
||||
maintainer roles.
|
||||
- [x] The CNCF Code of Conduct is adopted and linked.
|
||||
- [x] The Code of Conduct is cross-linked from governance.
|
||||
- [x] All current subprojects are listed in governance.
|
||||
|
||||
## Contributors and Community
|
||||
|
||||
### Suggested
|
||||
|
||||
- [x] Contributor, code-owner, and maintainer roles form a contributor ladder;
|
||||
objective code-owner progression criteria remain an improvement item.
|
||||
|
||||
### Required
|
||||
|
||||
- [x] Issue and change submission are documented.
|
||||
- [x] Public GitHub and Discord communication channels are documented.
|
||||
- [x] All official project, subproject, and narrowly scoped non-public channels
|
||||
are inventoried in `COMMUNITY.md`.
|
||||
- [ ] **BLOCKED — Public meeting scheduler/CNCF calendar.** Higress does not
|
||||
currently publish an up-to-date public meeting scheduler or integrate its
|
||||
meetings with the CNCF calendar. A real schedule or calendar integration must
|
||||
be established rather than represented by documentation alone.
|
||||
- [x] Contribution documentation is maintained.
|
||||
- [x] Contributor activity and recruitment are demonstrated through GitHub,
|
||||
DevStats, contribution labels, and maintainer activity links.
|
||||
|
||||
## Engineering Principles
|
||||
|
||||
### Suggested
|
||||
|
||||
- [x] The roadmap change process is documented in
|
||||
[`ROADMAP.md`](https://github.com/higress-group/higress/blob/main/ROADMAP.md).
|
||||
- [x] Regular release history is public in GitHub Releases and versioned
|
||||
release notes.
|
||||
|
||||
### Required
|
||||
|
||||
- [x] Project goals, differentiation, need, and cloud-native use cases are
|
||||
documented in the README and GTR.
|
||||
- [x] What Higress does and why it exists are documented in the README and GTR.
|
||||
- [x] A maintained public roadmap and change process are linked from
|
||||
`ROADMAP.md`.
|
||||
- [x] Architecture and software design are documented in
|
||||
[`docs/architecture.md`](https://github.com/higress-group/higress/blob/main/docs/architecture.md)
|
||||
and the GTR.
|
||||
- [x] The release process is documented in
|
||||
[`RELEASE.md`](https://github.com/higress-group/higress/blob/main/RELEASE.md).
|
||||
|
||||
## Security
|
||||
|
||||
### Suggested
|
||||
|
||||
- [ ] Complete a joint assessment with CNCF TAG Security and Compliance. This
|
||||
is suggested, not a v1.6 filing prerequisite.
|
||||
|
||||
### Required
|
||||
|
||||
- [x] Vulnerability reporting is documented in
|
||||
[`SECURITY.md`](https://github.com/higress-group/higress/blob/main/SECURITY.md).
|
||||
- [ ] **BLOCKED — Enforced repository access-control evidence.** Public files
|
||||
do not prove organization 2FA, least-privilege team access, protected default
|
||||
branch/rulesets, required non-author review, or protected release
|
||||
environments. An organization owner must verify/configure these settings and
|
||||
publish reviewable evidence appropriate for CNCF DD.
|
||||
- [x] Named security-response membership, incident roles, two-person review,
|
||||
conflicts, report handling, and escalation are documented.
|
||||
- [x] The Security Self-Assessment is documented in
|
||||
[`security-self-assessment.md`](https://github.com/higress-group/higress/blob/main/docs/cncf/security-self-assessment.md).
|
||||
- [ ] **BLOCKED — OpenSSF Best Practices Passing badge.** The public entry is
|
||||
currently 96%. Warning enforcement/remediation, static-analysis frequency,
|
||||
confirmed CodeQL alert disposition, and project-level dynamic analysis remain
|
||||
before the badge can be certified as Passing.
|
||||
|
||||
## Ecosystem
|
||||
|
||||
### Required
|
||||
|
||||
- [x] The public adopter list records organizations, contacts, environment, and
|
||||
use cases.
|
||||
- [x] At least three independent adopters report production use; five are
|
||||
publicly listed.
|
||||
- [ ] **BLOCKED — TOC adopter verification.** Complete the official interviews
|
||||
and allow CNCF/TOC reviewers to verify the adopter evidence.
|
||||
- [x] Integrations and compatibility with Kubernetes, Gateway API, Envoy,
|
||||
Istio, Prometheus/OpenTelemetry, OCI, service registries, and model providers
|
||||
are documented in the GTR, architecture, README, and user documentation.
|
||||
|
||||
## Current filing decision
|
||||
|
||||
**Do not file yet.** The top-level readiness attestation would be false while
|
||||
the following required items remain open:
|
||||
|
||||
1. a real public meeting scheduler and/or CNCF calendar integration;
|
||||
2. enforced repository access-control evidence;
|
||||
3. OpenSSF Best Practices Passing, including warning enforcement, analysis
|
||||
frequency, alert disposition, and dynamic analysis;
|
||||
4. vendor-neutral resource/website remediation or an accepted migration plan;
|
||||
5. 5–7 adopter interview submissions and later TOC verification; and
|
||||
6. direct project point-of-contact emails confirmed for the issue.
|
||||
282
docs/cncf/security-self-assessment.md
Normal file
282
docs/cncf/security-self-assessment.md
Normal file
@@ -0,0 +1,282 @@
|
||||
# Higress Security Self-Assessment
|
||||
|
||||
## Metadata
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| Assessment stage | Complete working draft, pending maintainer approval; updated 2026-07-21 |
|
||||
| Software | <https://github.com/higress-group/higress> |
|
||||
| Review state | Prepared from public project evidence; no independent security review or audit |
|
||||
| Evidence branch | [`higress-group/higress@main`](https://github.com/higress-group/higress/tree/main) |
|
||||
| Assessment outline | [CNCF TAG Security Self-Assessment](https://tag-security.cncf.io/community/assessments/guide/self-assessment/) |
|
||||
| Intended TOC snapshot | `projects/higress/security-assessment/self-assessment.md` |
|
||||
| Security provider | No. Higress provides security features, but its primary function is API gateway traffic management. |
|
||||
| Languages | Go, C++, Rust, AssemblyScript, shell, and Helm/YAML |
|
||||
| SBOM | Not generated for release artifacts. Go module files, Cargo lock data, and container build files provide dependency inputs. |
|
||||
|
||||
### Security Links
|
||||
|
||||
| Document | Location |
|
||||
| --- | --- |
|
||||
| Vulnerability reporting and response | [`SECURITY.md`](https://github.com/higress-group/higress/blob/main/SECURITY.md) |
|
||||
| Architecture | [`docs/architecture.md`](https://github.com/higress-group/higress/blob/main/docs/architecture.md) |
|
||||
| Helm defaults | [`helm/core/values.yaml`](https://github.com/higress-group/higress/blob/main/helm/core/values.yaml) |
|
||||
| OpenSSF Best Practices | <https://www.bestpractices.dev/projects/12667> |
|
||||
|
||||
This is the project-maintained working copy. For formal Due Diligence, a vetted
|
||||
snapshot must be archived at the path above in `cncf/toc`. The reviewer should
|
||||
freeze evidence links to the reviewed revision when that snapshot is archived.
|
||||
|
||||
## Overview
|
||||
|
||||
Higress translates declarative ingress, Gateway API, service discovery, and
|
||||
plugin configuration into xDS consumed by an Envoy-based gateway. It accepts
|
||||
untrusted downstream traffic, selects upstream services, applies traffic and
|
||||
security policy, and proxies requests and responses.
|
||||
|
||||
### Actors and Actions
|
||||
|
||||
- **Cluster administrator:** installs/upgrades Higress, grants RBAC, configures
|
||||
exposure, certificates, registries, and security contexts.
|
||||
- **Gateway operator/platform engineer:** creates routes, services, policies,
|
||||
plugins, credentials, and observability configuration.
|
||||
- **Application owner:** requests routes/policy and operates upstream services.
|
||||
- **End user/client:** sends potentially hostile network requests.
|
||||
- **Plugin author/provider:** supplies code that executes in the gateway's Wasm
|
||||
sandbox or native filter boundary.
|
||||
- **External provider/registry:** supplies service discovery data, plugins,
|
||||
identity metadata, certificates, or AI/model APIs when configured.
|
||||
- **Project maintainer/release manager:** reviews changes, responds to reports,
|
||||
and publishes releases.
|
||||
|
||||
### Background
|
||||
|
||||
Higress combines the Envoy data plane and Istio-derived control plane with
|
||||
Ingress/Gateway API translation and an extensible plugin ecosystem. The
|
||||
control plane watches configuration and discovery sources and generates xDS;
|
||||
the data plane accepts untrusted network traffic and applies that configuration.
|
||||
|
||||
### Architecture and Data Flow
|
||||
|
||||
The security-relevant flow is:
|
||||
|
||||
1. An authenticated Kubernetes user or automation writes Ingress, Gateway API,
|
||||
Higress/Istio, Secret, and policy resources to the Kubernetes API.
|
||||
2. Higress controller and discovery components watch authorized resources,
|
||||
optional service registries, and certificate sources, then translate the
|
||||
desired state into xDS and SDS configuration.
|
||||
3. Gateway pilot agents and Envoy processes receive configuration and secret
|
||||
material. Existing gateways may retain their last accepted configuration if
|
||||
the control plane becomes unavailable.
|
||||
4. Untrusted clients connect to the gateway. Envoy and configured filters parse,
|
||||
authenticate, authorize, transform, and route requests to upstream services
|
||||
or explicitly configured AI/model providers.
|
||||
5. The gateway emits access logs, metrics, and traces to destinations selected
|
||||
by the operator. Depending on configuration, those signals may include
|
||||
sensitive request metadata.
|
||||
|
||||
### Data Assets and Trust Boundaries
|
||||
|
||||
| Asset or boundary | Security relevance |
|
||||
| --- | --- |
|
||||
| Kubernetes configuration and status | Unauthorized mutation can redirect traffic, weaken policy, load code, or disrupt availability. |
|
||||
| TLS keys, certificates, tokens, and upstream credentials | Disclosure or replacement can enable impersonation, traffic decryption, or unauthorized upstream access. |
|
||||
| Request/response bodies, AI prompts, and model outputs | May contain application secrets, personal data, proprietary data, or attacker-controlled content. |
|
||||
| xDS/SDS control-plane boundary | Integrity and availability determine what code, routes, clusters, policy, and secrets the data plane uses. |
|
||||
| Gateway process boundary | Native Go/C++ filters execute inside the proxy trust boundary; a defect can affect the full gateway process. |
|
||||
| Wasm plugin boundary | Wasm provides stronger isolation than a native filter, but plugins can still observe or modify traffic granted to them. |
|
||||
| External integration boundary | Registries, identity providers, certificate issuers, Redis, observability systems, OCI registries, and model providers have separate operators and trust. |
|
||||
| Source and release boundary | Compromise of source control, CI identities, dependencies, images, or plugin artifacts can affect every adopter. |
|
||||
|
||||
### Goals
|
||||
|
||||
- Preserve the integrity and availability of configuration delivery between
|
||||
control and data planes, reject invalid xDS updates, and make transport trust
|
||||
assumptions explicit for operators.
|
||||
- Terminate and originate TLS as configured and distribute private key material
|
||||
through Kubernetes Secrets and SDS.
|
||||
- Isolate Wasm plugin execution from the gateway process to the extent provided
|
||||
by the Envoy/Wasm runtime.
|
||||
- Enforce configured routing, authentication, authorization, traffic, and data
|
||||
policies consistently.
|
||||
- Contact only the external registries, identity systems, observability
|
||||
backends, AI providers, and upstreams explicitly configured by the operator.
|
||||
- Provide a private vulnerability reporting and coordinated disclosure path.
|
||||
|
||||
### Non-goals
|
||||
|
||||
Higress does not secure a compromised Kubernetes control plane, node, cluster
|
||||
administrator, upstream service, identity provider, model provider, registry,
|
||||
or native plugin. It does not guarantee that user-authored policy is correct,
|
||||
provide regulatory certification, or replace network segmentation, secrets
|
||||
management, PKI governance, application security, or incident response.
|
||||
|
||||
## Self-Assessment Use
|
||||
|
||||
This document is an internal analysis by the Higress project. It is not an
|
||||
independent audit, certification, or attestation. It gives adopters and CNCF
|
||||
reviewers an initial view of security boundaries, practices, and known gaps.
|
||||
|
||||
## Security Functions and Features
|
||||
|
||||
### Critical
|
||||
|
||||
- xDS configuration generation, transport, validation, and last-known-good
|
||||
behavior between controller/discovery and gateways.
|
||||
- TLS termination/origination, SDS secret delivery, certificate issuance and
|
||||
rotation paths, and private-key access.
|
||||
- Kubernetes RBAC, ServiceAccounts, token reviews, subject-access reviews, and
|
||||
admission/configuration validation.
|
||||
- HTTP/TCP parsing, routing, upstream selection, and request/response mutation.
|
||||
- Plugin loading and Wasm sandbox boundary; native Go/C++ filters share the
|
||||
gateway process trust boundary.
|
||||
- Release workflows, container/plugin registries, dependency inputs, and
|
||||
published artifacts.
|
||||
|
||||
### Security Relevant
|
||||
|
||||
- Authentication and authorization plugins (JWT, OIDC, key, HMAC, basic auth),
|
||||
WAF, rate limiting, request blocking, and data masking.
|
||||
- Pod/container security contexts, host networking, privileged mode, RBAC
|
||||
toggles, network exposure, and admin/debug endpoints.
|
||||
- Access, audit-style, metrics, and trace output, which may contain sensitive
|
||||
request metadata depending on operator configuration.
|
||||
- External service registries, Redis, certificate issuers, identity providers,
|
||||
OCI registries, and AI/model providers.
|
||||
|
||||
### Threat Model
|
||||
|
||||
This project-authored model uses the actors, assets, flows, and boundaries above.
|
||||
Priority reflects generic impact for a shared production gateway; adopters must
|
||||
adjust it for their tenancy model and data classification.
|
||||
|
||||
| ID | Priority | Threat scenario | Current mitigation and residual risk |
|
||||
| --- | --- | --- | --- |
|
||||
| HG-TM-01 | High | A principal with excessive Kubernetes permissions creates or changes routes, policies, Secrets, or plugins to hijack traffic or bypass controls. | Kubernetes authentication, RBAC, API validation, namespace scoping where configured, and review/GitOps practices reduce exposure. Cluster administrators remain trusted, and the controller currently has broad cluster-wide permissions. |
|
||||
| HG-TM-02 | High | A compromised controller, gateway ServiceAccount, or ClusterRoleBinding exposes TLS keys or upstream credentials. | Secrets are delivered through Kubernetes APIs and SDS rather than embedded in images. Gateway and controller roles are separate, but secret access and several controller rules remain broad and require minimization. |
|
||||
| HG-TM-03 | High | A malicious or compromised plugin/image executes code, exfiltrates traffic, or alters policy. | Plugin installation is explicit and Wasm supplies a sandbox boundary. Native filters share the gateway process; release/plugin artifacts lack project-wide signature, SBOM, and provenance guarantees. |
|
||||
| HG-TM-04 | High | Control-plane or xDS/SDS channel compromise injects malicious configuration or secret material, or prevents updates. | Envoy validates delivered resources and can retain last accepted configuration. Operators must protect control-plane identities, endpoints, network paths, and Kubernetes access; transport and deployment assumptions require a dedicated hardening guide. |
|
||||
| HG-TM-05 | High | Hostile downstream traffic exploits an Envoy/filter parser defect or exhausts connections, memory, CPU, sockets, or upstream capacity. | Envoy validation, resource limits, timeouts, rate-limit/circuit-breaker features, probes, and multiple replicas can limit impact. Safe values are deployment-specific and timely Envoy/Higress patching remains essential. |
|
||||
| HG-TM-06 | High | A tenant accidentally or deliberately attaches a route or policy to another tenant's gateway and exposes or disrupts traffic. | Kubernetes RBAC, namespace separation, Gateway API attachment controls, and review can restrict authors. Higress does not make a hostile multi-tenant cluster safe when administrators grant overlapping write privileges. |
|
||||
| HG-TM-07 | High | Requests, AI prompts, credentials, or responses are disclosed to an external registry, plugin, observability backend, identity service, or model provider. | Integrations require operator configuration and can be restricted through credentials and network policy. Operators remain responsible for egress allowlists, provider contracts, log redaction, residency, and data-retention controls. |
|
||||
| HG-TM-08 | Medium | Logs, metrics, traces, admin/debug endpoints, or configuration dumps expose credentials or sensitive request metadata. | Admin interfaces are not intended as public endpoints and telemetry is configurable. Access control, redaction, retention, and exposure are deployment responsibilities; unsafe logging or endpoint exposure remains possible. |
|
||||
| HG-TM-09 | High | Certificate expiration, issuer compromise, or unauthorized Secret replacement causes outage or impersonation. | SDS and automatic HTTPS support dynamic certificate updates and renewal. Operators must secure issuers, monitor expiry/renewal, test emergency rotation, and control Secret writers. |
|
||||
| HG-TM-10 | High | A source-control, dependency, GitHub Actions, registry, or maintainer-account compromise produces malicious release artifacts. | Public review, CI tests, license checks, CodeQL, pinned dependency/submodule inputs, and GitHub release workflows provide layers. Missing universal immutable action pinning, release SBOMs, signatures, provenance, and documented repository access controls leave material residual risk. |
|
||||
|
||||
The model should be reviewed after material architecture, privilege, plugin,
|
||||
release-pipeline, or trust-boundary changes. The project does not yet enforce a
|
||||
review cadence or require a named security reviewer, which is itself a process
|
||||
gap.
|
||||
|
||||
## Project Compliance
|
||||
|
||||
The open-source project does not claim PCI-DSS, SOC 2, ISO 27001, GDPR, or
|
||||
other regulatory certification. Deployers are responsible for assessing their
|
||||
configuration and operational environment. Source is Apache-2.0 and pull
|
||||
requests run license header and dependency-license checks.
|
||||
|
||||
## Secure Development Practices
|
||||
|
||||
Pull requests are publicly reviewed and run build/unit tests with Go race
|
||||
detection, Gateway API and Higress conformance tests, plugin tests, and license
|
||||
checks. CodeQL is scheduled weekly; it is not currently a pull-request gate.
|
||||
The configured `golangci-lint` execution is commented out because of existing
|
||||
findings. Release tags trigger image and CLI/CRD artifact builds. Dependency
|
||||
inputs are versioned, but release artifacts do not currently have a
|
||||
project-generated SBOM, signature, or SLSA provenance. Not all workflow actions
|
||||
are pinned to immutable commits. The public repository does not prove a
|
||||
required reviewer count, signed-commit requirement, organization-wide 2FA, or
|
||||
branch-protection configuration; those controls require separate repository-
|
||||
settings evidence.
|
||||
|
||||
Ordinary project-team communication uses GitHub issues, pull requests,
|
||||
discussions, mailing, localized community channels, and Discord. Inbound users
|
||||
use the same public channels. Every vulnerability report must be submitted to
|
||||
both GitHub Private Security Advisories and the Alibaba Security Response
|
||||
Center, as documented in `SECURITY.md`. The Security Response Team correlates
|
||||
the two private records.
|
||||
Releases, the project website, and the WeChat Official Account are outbound
|
||||
channels. [`COMMUNITY.md`](https://github.com/higress-group/higress/blob/main/COMMUNITY.md)
|
||||
is the authoritative inventory of public, subproject, and narrowly scoped
|
||||
non-public channels.
|
||||
|
||||
Higress operates in the Kubernetes networking and cloud-native gateway
|
||||
ecosystem. It implements Ingress and Gateway API and builds on Envoy, Istio,
|
||||
OCI, Prometheus/OpenTelemetry conventions, and optional service registries.
|
||||
|
||||
## Security Issue Resolution
|
||||
|
||||
[`SECURITY.md`](https://github.com/higress-group/higress/blob/main/SECURITY.md)
|
||||
prohibits public vulnerability reports and
|
||||
requires reporters to submit the same substantive report through both GitHub
|
||||
Private Security Advisories and the Alibaba Security Response Center. The named
|
||||
Security Response Team is the current maintainer list.
|
||||
For each case it assigns a triage coordinator, fix lead, independent reviewer
|
||||
and release lead, and disclosure lead, with at least two unconflicted members.
|
||||
The policy documents conflicts and escalation. Its targets are acknowledgement
|
||||
within three business days and triage within 14 days, followed by private fix
|
||||
development, coordinated disclosure (typically within 90 days), a GitHub
|
||||
Security Advisory, and a CVE request where appropriate.
|
||||
|
||||
An operational security incident in an adopter environment remains the
|
||||
adopter's responsibility. The project handles defects in project code and
|
||||
artifacts. For confirmed project vulnerabilities, maintainers triage impact,
|
||||
develop and release a fix, notify affected users through a GitHub Security
|
||||
Advisory and release information, and coordinate timing with the reporter.
|
||||
|
||||
## Appendix
|
||||
|
||||
### Known Gaps
|
||||
|
||||
- OpenSSF Passing is not complete. Outstanding areas include compiler warning
|
||||
enforcement, static-analysis alert remediation, and dynamic analysis.
|
||||
- There are unresolved critical/high CodeQL alerts requiring maintainer access,
|
||||
triage, and either fixes or documented upstream/vendor dispositions.
|
||||
- Release SBOMs, signatures, and verifiable build provenance are absent.
|
||||
- The controller's ClusterRole is broad and its default container security
|
||||
context is empty. Gateway non-root defaults depend on Kubernetes/platform
|
||||
capability; legacy fallback adds `NET_BIND_SERVICE` and allows escalation.
|
||||
- No dedicated fuzzing/DAST program or automated upgrade/downgrade matrix is
|
||||
documented.
|
||||
- This threat model is project-authored, has not been independently validated,
|
||||
and is not yet backed by a published data-flow diagram with explicit trust
|
||||
boundaries or an independent security audit.
|
||||
- Requiring every reporter to duplicate the report in the vendor-operated
|
||||
Alibaba Security Response Center creates a vendor-neutrality concern that
|
||||
requires CNCF review or a future neutral replacement plan.
|
||||
|
||||
### Known Issues Over Time
|
||||
|
||||
Published project advisories are available from the repository's
|
||||
[Security Advisories](https://github.com/higress-group/higress/security/advisories)
|
||||
page. This assessment does not claim that the absence of a public advisory for
|
||||
a period means no vulnerability existed. The project has not published an
|
||||
aggregate vulnerability history or mean-time-to-remediation report.
|
||||
|
||||
### OpenSSF Best Practices
|
||||
|
||||
The [Higress OpenSSF entry](https://www.bestpractices.dev/projects/12667) is at
|
||||
96% of the Passing badge as of this assessment. Seven criteria remain
|
||||
unanswered or unmet: compiler-warning enforcement, strict-warning enforcement,
|
||||
warning remediation, static-analysis remediation, static-analysis frequency,
|
||||
dynamic analysis, and enabling assertions or equivalent dynamic-analysis
|
||||
checks. Passing requires evidence and implementation for all seven, not merely
|
||||
updating the questionnaire.
|
||||
|
||||
### Example Use Cases
|
||||
|
||||
1. A platform team exposes Kubernetes services through Gateway API with TLS,
|
||||
JWT authentication, rate limiting, and Prometheus metrics.
|
||||
2. An AI platform routes requests across model providers while applying token
|
||||
quotas, content policy, and request/response observability.
|
||||
3. A microservice platform discovers Nacos/Consul services and exposes them
|
||||
through stable API routes without reloading the data plane.
|
||||
|
||||
### Related Projects and Vendors
|
||||
|
||||
Envoy supplies the proxy foundation; Istio supplies xDS/control-plane building
|
||||
blocks; Kubernetes Gateway API and Ingress supply standard configuration APIs.
|
||||
Kong, Apache APISIX, Envoy Gateway, Traefik, and ingress controllers address
|
||||
overlapping gateway use cases with different APIs, extension models, and
|
||||
operational tradeoffs. Commercial products may distribute or manage Higress,
|
||||
but they are outside this open-source security assessment.
|
||||
Submodule envoy/envoy updated: f468a1a3ac...4491e6783b
1
go.mod
1
go.mod
@@ -128,6 +128,7 @@ require (
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-logr/zapr v1.3.0 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.21.2 // indirect
|
||||
github.com/go-openapi/jsonreference v0.21.0 // indirect
|
||||
github.com/go-openapi/swag v0.23.1 // indirect
|
||||
|
||||
4
go.sum
4
go.sum
@@ -5345,7 +5345,6 @@ google.golang.org/genproto/googleapis/api v0.0.0-20250219182151-9fdb1cabc7b2/go.
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250227231956-55c901821b1e/go.mod h1:Xsh8gBVxGCcbV8ZeTB9wI5XPyZ5RvC6V3CTeeplHbiA=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:jbe3Bkdp+Dh2IrslsFCklNhweNTBgSYanP1UXhJDhKg=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250324211829-b45e905df463/go.mod h1:U90ffi8eUL9MwPcrJylN5+Mk2v3vuPDptd5yyNUiRR8=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a/go.mod h1:a77HrdMjoeKbnd2jmgcWdaS++ZLZAEq3orIOAEIKiVw=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7/go.mod h1:kXqgZtrWaf6qS3jZOCnCH7WYfrvFjkC51bM8fz3RsCA=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250818200422-3122310a409c/go.mod h1:ea2MjsO70ssTfCjiwHgI0ZFqcw45Ksuk2ckf9G468GA=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5/go.mod h1:j3QtIyytwqGr1JUDtYXwtMXWPKsEa5LtzIFN1Wn5WvE=
|
||||
@@ -5475,8 +5474,6 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20250227231956-55c901821b1e/go.
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:LuRYeWDFV6WOn90g357N17oMCaxpgCnbi/44qJvDn2I=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250313205543-e70fdf4c4cb4/go.mod h1:LuRYeWDFV6WOn90g357N17oMCaxpgCnbi/44qJvDn2I=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250811230008-5f3141c8851a/go.mod h1:gw1tLEfykwDz2ET4a12jcXt4couGAm7IwsVaTy0Sflo=
|
||||
@@ -5564,7 +5561,6 @@ google.golang.org/grpc v1.69.4/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7
|
||||
google.golang.org/grpc v1.70.0/go.mod h1:ofIJqVKDXx/JiXrwr2IG4/zwdH9txy3IlF40RmcJSQw=
|
||||
google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec=
|
||||
google.golang.org/grpc v1.71.1/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec=
|
||||
google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc=
|
||||
google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
|
||||
google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
|
||||
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
@@ -31,6 +32,7 @@ import (
|
||||
networking "istio.io/api/networking/v1alpha3"
|
||||
"istio.io/istio/pilot/pkg/networking/core"
|
||||
"istio.io/istio/pilot/pkg/serviceregistry/kube/controller"
|
||||
"istio.io/istio/pilot/pkg/status"
|
||||
"istio.io/istio/pkg/config"
|
||||
"istio.io/istio/pkg/config/constants"
|
||||
"istio.io/istio/pkg/config/schema/gvk"
|
||||
@@ -192,6 +194,90 @@ func TestListGatewayResourceType(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestListGatewayResourceTypeWithAlternateGatewayClassName(t *testing.T) {
|
||||
alternateGateway := gatewaySpec.DeepCopy()
|
||||
alternateGateway.GatewayClassName = "alternate"
|
||||
controller := setupController(t,
|
||||
&k8sbeta.GatewayClass{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "alternate"},
|
||||
Spec: *gatewayClassSpec,
|
||||
},
|
||||
&k8sbeta.Gateway{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "alternate-gw", Namespace: "ns1"},
|
||||
Spec: *alternateGateway,
|
||||
})
|
||||
|
||||
cfg := controller.List(gvk.Gateway, "ns1")
|
||||
assert.Equal(t, len(cfg), 1)
|
||||
assert.Equal(t, cfg[0].Name, "alternate-gw-"+constants.KubernetesGatewayName+"-default")
|
||||
}
|
||||
|
||||
func TestHTTPRouteBackendServiceLifecycle(t *testing.T) {
|
||||
backendPort := k8s.PortNumber(80)
|
||||
route := httpRouteSpec.DeepCopy()
|
||||
route.Rules = []k8s.HTTPRouteRule{{
|
||||
BackendRefs: []k8s.HTTPBackendRef{{
|
||||
BackendRef: k8s.BackendRef{
|
||||
BackendObjectReference: k8s.BackendObjectReference{
|
||||
Name: "backend",
|
||||
Port: &backendPort,
|
||||
},
|
||||
},
|
||||
}},
|
||||
}}
|
||||
controller := setupController(t,
|
||||
&k8sbeta.GatewayClass{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "higress"},
|
||||
Spec: *gatewayClassSpec,
|
||||
},
|
||||
&k8sbeta.Gateway{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "gwspec", Namespace: "ns1"},
|
||||
Spec: *gatewaySpec,
|
||||
},
|
||||
&k8sbeta.HTTPRoute{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "http-route", Namespace: "ns1"},
|
||||
Spec: *route,
|
||||
},
|
||||
)
|
||||
|
||||
statusQueue := &TestStatusQueue{state: map[status.Resource]any{}}
|
||||
setAndWait(t, controller, statusQueue)
|
||||
resolvedRefs := func() string {
|
||||
for _, rawStatus := range statusQueue.Statuses() {
|
||||
routeStatus, ok := rawStatus.(*k8s.HTTPRouteStatus)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, parent := range routeStatus.Parents {
|
||||
for _, condition := range parent.Conditions {
|
||||
if condition.Type == string(k8s.RouteConditionResolvedRefs) {
|
||||
return string(condition.Status) + "/" + condition.Reason
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
assert.EventuallyEqual(t, resolvedRefs, "False/BackendNotFound")
|
||||
|
||||
service := &corev1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "backend", Namespace: "ns1"},
|
||||
Spec: corev1.ServiceSpec{Ports: []corev1.ServicePort{{
|
||||
Name: "http",
|
||||
Port: int32(backendPort),
|
||||
}}},
|
||||
}
|
||||
if _, err := controller.client.Kube().CoreV1().Services("ns1").Create(t.Context(), service, metav1.CreateOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assert.EventuallyEqual(t, resolvedRefs, "True/ResolvedRefs")
|
||||
|
||||
if err := controller.client.Kube().CoreV1().Services("ns1").Delete(t.Context(), service.Name, metav1.DeleteOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assert.EventuallyEqual(t, resolvedRefs, "False/BackendNotFound")
|
||||
}
|
||||
|
||||
func TestListGatewayResourceTypeWithCustomGatewayClass(t *testing.T) {
|
||||
if runInGatewayClassSubprocess(t) {
|
||||
return
|
||||
|
||||
@@ -1064,12 +1064,12 @@ func buildDestination(ctx RouteContext, to k8s.BackendRef, ns string,
|
||||
return nil, nil, &ConfigError{Reason: InvalidDestination, Message: "service name invalid; the name of the Service must be used, not the hostname."}
|
||||
}
|
||||
hostname = fmt.Sprintf("%s.%s.svc.%s", to.Name, namespace, ctx.DomainSuffix)
|
||||
// Start - Updated by Higress
|
||||
//key := namespace + "/" + string(to.Name)
|
||||
//svc := ptr.Flatten(krt.FetchOne(ctx.Krt, ctx.Services, krt.FilterKey(key)))
|
||||
svc := ctx.LookupHostname(hostname, namespace, "Service")
|
||||
// End - Updated by Higress
|
||||
if svc == nil {
|
||||
// Keep Higress service resolution as the primary lookup so its service naming and
|
||||
// plugin binding semantics remain unchanged. Always fetch from the informer to
|
||||
// retain the dependency needed to recompute the Route when the Service is deleted.
|
||||
key := namespace + "/" + string(to.Name)
|
||||
kubeSvc := ptr.Flatten(krt.FetchOne(ctx.Krt, ctx.Services, krt.FilterKey(key)))
|
||||
if ctx.LookupHostname(hostname, namespace, "Service") == nil && kubeSvc == nil {
|
||||
invalidBackendErr = &ConfigError{Reason: InvalidDestinationNotFound, Message: fmt.Sprintf("backend(%s) not found", hostname)}
|
||||
}
|
||||
case config.GroupVersionKind{Group: gvk.ServiceEntry.Group, Kind: "Hostname"}:
|
||||
@@ -1592,6 +1592,21 @@ func (r routeParentReference) IsMesh() bool {
|
||||
return r.InternalName == "mesh"
|
||||
}
|
||||
|
||||
func (r routeParentReference) hostnameIntersection(rawRouteHost string) (string, bool) {
|
||||
routeHost := host.Name(rawRouteHost)
|
||||
listenerHost := host.Name(r.Hostname)
|
||||
if len(listenerHost) == 0 || listenerHost == "*" {
|
||||
return rawRouteHost, true
|
||||
}
|
||||
if routeHost.SubsetOf(listenerHost) {
|
||||
return rawRouteHost, true
|
||||
}
|
||||
if listenerHost.SubsetOf(routeHost) {
|
||||
return r.Hostname, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func (r routeParentReference) hostnameAllowedByIsolation(rawRouteHost string) bool {
|
||||
routeHost := host.Name(rawRouteHost)
|
||||
ourListener := host.Name(r.Hostname)
|
||||
|
||||
@@ -1522,6 +1522,36 @@ func TestGatewayReferenceAllowedParentHostnameParsing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouteParentReferenceHostnameIntersection(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
listenerHost string
|
||||
routeHost string
|
||||
wantHost string
|
||||
wantMatch bool
|
||||
}{
|
||||
{name: "empty listener", listenerHost: "", routeHost: "example.com", wantHost: "example.com", wantMatch: true},
|
||||
{name: "empty route", listenerHost: "example.com", routeHost: "*", wantHost: "example.com", wantMatch: true},
|
||||
{name: "exact match", listenerHost: "example.com", routeHost: "example.com", wantHost: "example.com", wantMatch: true},
|
||||
{name: "exact listener and wildcard route", listenerHost: "foo.example.com", routeHost: "*.example.com", wantHost: "foo.example.com", wantMatch: true},
|
||||
{name: "wildcard listener and exact route", listenerHost: "*.example.com", routeHost: "foo.example.com", wantHost: "foo.example.com", wantMatch: true},
|
||||
{name: "narrower route wildcard", listenerHost: "*.example.com", routeHost: "*.foo.example.com", wantHost: "*.foo.example.com", wantMatch: true},
|
||||
{name: "narrower listener wildcard", listenerHost: "*.foo.example.com", routeHost: "*.example.com", wantHost: "*.foo.example.com", wantMatch: true},
|
||||
{name: "disjoint exact hosts", listenerHost: "foo.example.com", routeHost: "bar.example.com", wantMatch: false},
|
||||
{name: "disjoint wildcard hosts", listenerHost: "*.example.com", routeHost: "*.example.net", wantMatch: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
parent := routeParentReference{Hostname: tt.listenerHost}
|
||||
gotHost, gotMatch := parent.hostnameIntersection(tt.routeHost)
|
||||
if gotHost != tt.wantHost || gotMatch != tt.wantMatch {
|
||||
t.Fatalf("hostnameIntersection() = (%q, %v), want (%q, %v)", gotHost, gotMatch, tt.wantHost, tt.wantMatch)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReferencePolicy(t *testing.T) {
|
||||
validator := crdvalidation.NewIstioValidator(t)
|
||||
type res struct {
|
||||
|
||||
@@ -38,8 +38,7 @@ func GatewayClassesCollection(
|
||||
krt.Collection[GatewayClass],
|
||||
) {
|
||||
return krt.NewStatusCollection(gatewayClasses, func(ctx krt.HandlerContext, obj *gateway.GatewayClass) (*gateway.GatewayClassStatus, *GatewayClass) {
|
||||
if gatewayv1.ObjectName(obj.Name) != gatewayv1.ObjectName(gatewayClassName) ||
|
||||
obj.Spec.ControllerName != managedGatewayController {
|
||||
if obj.Spec.ControllerName != managedGatewayController {
|
||||
return nil, nil
|
||||
}
|
||||
if _, known := classInfos[obj.Spec.ControllerName]; !known {
|
||||
|
||||
@@ -148,7 +148,12 @@ func HTTPRouteCollection(
|
||||
}
|
||||
// Create one VS per hostname with a single hostname.
|
||||
// This ensures we can treat each hostname independently, as the spec requires
|
||||
for _, h := range vsHosts {
|
||||
processedHosts := sets.New[string]()
|
||||
for _, routeHost := range vsHosts {
|
||||
h, ok := parent.hostnameIntersection(routeHost)
|
||||
if !ok || processedHosts.InsertContains(h) {
|
||||
continue
|
||||
}
|
||||
if !parent.hostnameAllowedByIsolation(h) {
|
||||
// TODO: standardize a status message for this upstream and report
|
||||
continue
|
||||
|
||||
@@ -23,13 +23,13 @@ metadata:
|
||||
annotations:
|
||||
internal.istio.io/parents: HTTPRoute/http.higress-system
|
||||
internal.istio.io/route-semantics: gateway
|
||||
name: higress-system~gateway-istio-autogenerated-k8s-gateway-default~*
|
||||
name: higress-system~gateway-istio-autogenerated-k8s-gateway-default~*.domain.example
|
||||
namespace: higress-system
|
||||
spec:
|
||||
gateways:
|
||||
- higress-system/gateway-istio-autogenerated-k8s-gateway-default
|
||||
hosts:
|
||||
- '*'
|
||||
- '*.domain.example'
|
||||
http:
|
||||
- name: http
|
||||
route:
|
||||
|
||||
@@ -42,13 +42,13 @@ metadata:
|
||||
annotations:
|
||||
internal.istio.io/parents: HTTPRoute/http.apple
|
||||
internal.istio.io/route-semantics: gateway
|
||||
name: higress-system~gateway-istio-autogenerated-k8s-gateway-apple~*
|
||||
name: higress-system~gateway-istio-autogenerated-k8s-gateway-apple~apple.example
|
||||
namespace: apple
|
||||
spec:
|
||||
gateways:
|
||||
- higress-system/gateway-istio-autogenerated-k8s-gateway-apple
|
||||
hosts:
|
||||
- '*'
|
||||
- apple.example
|
||||
http:
|
||||
- name: apple/http
|
||||
route:
|
||||
@@ -63,13 +63,13 @@ metadata:
|
||||
annotations:
|
||||
internal.istio.io/parents: HTTPRoute/http.banana
|
||||
internal.istio.io/route-semantics: gateway
|
||||
name: higress-system~gateway-istio-autogenerated-k8s-gateway-banana~*
|
||||
name: higress-system~gateway-istio-autogenerated-k8s-gateway-banana~banana.example
|
||||
namespace: banana
|
||||
spec:
|
||||
gateways:
|
||||
- higress-system/gateway-istio-autogenerated-k8s-gateway-banana
|
||||
hosts:
|
||||
- '*'
|
||||
- banana.example
|
||||
http:
|
||||
- name: banana/http
|
||||
route:
|
||||
|
||||
@@ -23,13 +23,13 @@ metadata:
|
||||
annotations:
|
||||
internal.istio.io/parents: HTTPRoute/mirror.default,HTTPRoute/redirect.default,HTTPRoute/rewrite.default
|
||||
internal.istio.io/route-semantics: gateway
|
||||
name: higress-system~gateway-istio-autogenerated-k8s-gateway-default~*
|
||||
name: higress-system~gateway-istio-autogenerated-k8s-gateway-default~*.domain.example
|
||||
namespace: default
|
||||
spec:
|
||||
gateways:
|
||||
- higress-system/gateway-istio-autogenerated-k8s-gateway-default
|
||||
hosts:
|
||||
- '*'
|
||||
- '*.domain.example'
|
||||
http:
|
||||
- match:
|
||||
- uri:
|
||||
|
||||
@@ -142,13 +142,13 @@ metadata:
|
||||
annotations:
|
||||
internal.istio.io/parents: HTTPRoute/invalid-mirror.default
|
||||
internal.istio.io/route-semantics: gateway
|
||||
name: higress-system~gateway-istio-autogenerated-k8s-gateway-default~*
|
||||
name: higress-system~gateway-istio-autogenerated-k8s-gateway-default~*.domain.example
|
||||
namespace: default
|
||||
spec:
|
||||
gateways:
|
||||
- higress-system/gateway-istio-autogenerated-k8s-gateway-default
|
||||
hosts:
|
||||
- '*'
|
||||
- '*.domain.example'
|
||||
http:
|
||||
- name: default/invalid-mirror
|
||||
route:
|
||||
|
||||
@@ -305,13 +305,13 @@ metadata:
|
||||
annotations:
|
||||
internal.istio.io/parents: HTTPRoute/bind-all.default
|
||||
internal.istio.io/route-semantics: gateway
|
||||
name: higress-system~gateway-istio-autogenerated-k8s-gateway-default~*
|
||||
name: higress-system~gateway-istio-autogenerated-k8s-gateway-default~*.domain.example
|
||||
namespace: default
|
||||
spec:
|
||||
gateways:
|
||||
- higress-system/gateway-istio-autogenerated-k8s-gateway-default
|
||||
hosts:
|
||||
- '*'
|
||||
- '*.domain.example'
|
||||
http:
|
||||
- name: default/bind-all
|
||||
route:
|
||||
@@ -326,13 +326,13 @@ metadata:
|
||||
annotations:
|
||||
internal.istio.io/parents: HTTPRoute/bind-all.default,HTTPRoute/same-namespace-valid.istio-system
|
||||
internal.istio.io/route-semantics: gateway
|
||||
name: higress-system~gateway-istio-autogenerated-k8s-gateway-foobar~*
|
||||
name: higress-system~gateway-istio-autogenerated-k8s-gateway-foobar~*.foobar.example
|
||||
namespace: default
|
||||
spec:
|
||||
gateways:
|
||||
- higress-system/gateway-istio-autogenerated-k8s-gateway-foobar
|
||||
hosts:
|
||||
- '*'
|
||||
- '*.foobar.example'
|
||||
http:
|
||||
- name: default/bind-all
|
||||
route:
|
||||
@@ -374,13 +374,13 @@ metadata:
|
||||
annotations:
|
||||
internal.istio.io/parents: HTTPRoute/bind-all.default,HTTPRoute/bind-cross-namespace.group-namespace1
|
||||
internal.istio.io/route-semantics: gateway
|
||||
name: higress-system~gateway-istio-autogenerated-k8s-gateway-slctr-expr-dne-no~*
|
||||
name: higress-system~gateway-istio-autogenerated-k8s-gateway-slctr-expr-dne-no~*.slctr-expr-dne-no.example
|
||||
namespace: default
|
||||
spec:
|
||||
gateways:
|
||||
- higress-system/gateway-istio-autogenerated-k8s-gateway-slctr-expr-dne-no
|
||||
hosts:
|
||||
- '*'
|
||||
- '*.slctr-expr-dne-no.example'
|
||||
http:
|
||||
- name: default/bind-all
|
||||
route:
|
||||
@@ -401,13 +401,13 @@ metadata:
|
||||
annotations:
|
||||
internal.istio.io/parents: HTTPRoute/bind-all.default,HTTPRoute/bind-cross-namespace.group-namespace1
|
||||
internal.istio.io/route-semantics: gateway
|
||||
name: higress-system~gateway-istio-autogenerated-k8s-gateway-slctr-expr-dne-yes~*
|
||||
name: higress-system~gateway-istio-autogenerated-k8s-gateway-slctr-expr-dne-yes~*.slctr-expr-dne-yes.example
|
||||
namespace: default
|
||||
spec:
|
||||
gateways:
|
||||
- higress-system/gateway-istio-autogenerated-k8s-gateway-slctr-expr-dne-yes
|
||||
hosts:
|
||||
- '*'
|
||||
- '*.slctr-expr-dne-yes.example'
|
||||
http:
|
||||
- name: default/bind-all
|
||||
route:
|
||||
@@ -428,13 +428,13 @@ metadata:
|
||||
annotations:
|
||||
internal.istio.io/parents: HTTPRoute/bind-all.default,HTTPRoute/bind-cross-namespace.group-namespace1
|
||||
internal.istio.io/route-semantics: gateway
|
||||
name: higress-system~gateway-istio-autogenerated-k8s-gateway-slctr-expr-notin-no~*
|
||||
name: higress-system~gateway-istio-autogenerated-k8s-gateway-slctr-expr-notin-no~*.slctr-expr-notin-no.example
|
||||
namespace: default
|
||||
spec:
|
||||
gateways:
|
||||
- higress-system/gateway-istio-autogenerated-k8s-gateway-slctr-expr-notin-no
|
||||
hosts:
|
||||
- '*'
|
||||
- '*.slctr-expr-notin-no.example'
|
||||
http:
|
||||
- name: default/bind-all
|
||||
route:
|
||||
@@ -455,13 +455,13 @@ metadata:
|
||||
annotations:
|
||||
internal.istio.io/parents: HTTPRoute/bind-all.default,HTTPRoute/bind-cross-namespace.group-namespace1
|
||||
internal.istio.io/route-semantics: gateway
|
||||
name: higress-system~gateway-istio-autogenerated-k8s-gateway-slctr-expr-notin-yes~*
|
||||
name: higress-system~gateway-istio-autogenerated-k8s-gateway-slctr-expr-notin-yes~*.slctr-expr-notin-yes.example
|
||||
namespace: default
|
||||
spec:
|
||||
gateways:
|
||||
- higress-system/gateway-istio-autogenerated-k8s-gateway-slctr-expr-notin-yes
|
||||
hosts:
|
||||
- '*'
|
||||
- '*.slctr-expr-notin-yes.example'
|
||||
http:
|
||||
- name: default/bind-all
|
||||
route:
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
module github.com/alibaba/higress/plugins/golang-filter
|
||||
|
||||
go 1.22
|
||||
go 1.23
|
||||
|
||||
replace github.com/envoyproxy/envoy => github.com/higress-group/envoy v0.0.0-20250430151331-2c556780b65c
|
||||
replace github.com/envoyproxy/envoy => github.com/higress-group/envoy v0.0.0-20260707144248-4491e6783b3d
|
||||
|
||||
replace github.com/mark3labs/mcp-go => github.com/higress-group/mcp-go v0.0.0-20250428145706-792ce64b4b30
|
||||
|
||||
@@ -17,7 +17,7 @@ require (
|
||||
github.com/openai/openai-go/v2 v2.7.0
|
||||
github.com/pkoukk/tiktoken-go v0.1.8
|
||||
github.com/stretchr/testify v1.9.0
|
||||
google.golang.org/protobuf v1.36.5
|
||||
google.golang.org/protobuf v1.36.10
|
||||
gorm.io/driver/clickhouse v0.6.1
|
||||
gorm.io/driver/mysql v1.5.7
|
||||
gorm.io/driver/postgres v1.5.11
|
||||
@@ -53,6 +53,7 @@ require (
|
||||
github.com/cockroachdb/errors v1.9.1 // indirect
|
||||
github.com/cockroachdb/logtags v0.0.0-20211118104740-dabe8e521a4f // indirect
|
||||
github.com/cockroachdb/redact v1.1.3 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/deckarep/golang-set v1.7.1 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||
github.com/getsentry/sentry-go v0.12.0 // indirect
|
||||
|
||||
@@ -185,7 +185,10 @@ github.com/getsentry/sentry-go v0.12.0/go.mod h1:NSap0JBYWzHND8oMbyi0+XZhUalc1TB
|
||||
github.com/gin-contrib/sse v0.0.0-20190301062529-5545eab6dad3/go.mod h1:VJ0WA2NBN22VlZ2dKZQPAPnyWw5XTlK1KymzLKsr59s=
|
||||
github.com/gin-gonic/gin v1.4.0/go.mod h1:OW2EZn3DO8Ln9oIKOvM++LBO+5UPHJJDH72/q/3rZdM=
|
||||
github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98=
|
||||
github.com/go-errors/errors v1.0.1 h1:LUHzmkK3GUKUrL/1gfBUxAHzcev3apQlezX/+O7ma6w=
|
||||
github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
|
||||
github.com/go-faker/faker/v4 v4.1.0 h1:ffuWmpDrducIUOO0QSKSF5Q2dxAht+dhsT9FvVHhPEI=
|
||||
github.com/go-faker/faker/v4 v4.1.0/go.mod h1:uuNc0PSRxF8nMgjGrrrU4Nw5cF30Jc6Kd0/FUTTYbhg=
|
||||
github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw=
|
||||
github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw=
|
||||
github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
|
||||
@@ -264,8 +267,8 @@ github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
|
||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
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/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
|
||||
@@ -294,8 +297,8 @@ github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09
|
||||
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||
github.com/higress-group/envoy v0.0.0-20250430151331-2c556780b65c h1:chAOZk/qEXFhLILWoNucj3X6r9xYnRR+SWFvhsOa2oo=
|
||||
github.com/higress-group/envoy v0.0.0-20250430151331-2c556780b65c/go.mod h1:SU+IJUAfh1kkZtH+u0E1dnwho8AhbGeYMgp5vvjU+Gc=
|
||||
github.com/higress-group/envoy v0.0.0-20260707144248-4491e6783b3d h1:JHeCakX2pS4OxpAdBNqvkUfLSrSVCNNtPkSjKe8lkfk=
|
||||
github.com/higress-group/envoy v0.0.0-20260707144248-4491e6783b3d/go.mod h1:mgMEye9tOlNiUTG+iYhQYgCzQcX46MMS0Jo6bVwRt1U=
|
||||
github.com/higress-group/mcp-go v0.0.0-20250428145706-792ce64b4b30 h1:N4NMq8M1nZyyChPyzn+EUUdHi5asig2uLR5hOyRmsXI=
|
||||
github.com/higress-group/mcp-go v0.0.0-20250428145706-792ce64b4b30/go.mod h1:O9gri9UOzthw728vusc2oNu99lVh8cKCajpxNfC90gE=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
@@ -426,6 +429,7 @@ github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKf
|
||||
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
|
||||
github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=
|
||||
github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
|
||||
github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
@@ -934,8 +938,8 @@ google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp0
|
||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
|
||||
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
@@ -24,6 +24,10 @@ const (
|
||||
type SSEServerWrapper struct {
|
||||
BaseServer *common.SSEServer
|
||||
HostMatchers []common.HostMatcher // Pre-parsed host matchers for efficient matching
|
||||
// AuthUsername/AuthPassword hold the HTTP Basic credentials required to
|
||||
// reach this server. An empty AuthUsername means authentication is disabled.
|
||||
AuthUsername string
|
||||
AuthPassword string
|
||||
}
|
||||
|
||||
type config struct {
|
||||
@@ -110,12 +114,20 @@ func (p *Parser) Parse(any *anypb.Any, callbacks api.ConfigCallbackHandler) (int
|
||||
return nil, fmt.Errorf("failed to initialize MCP Server: %w", err)
|
||||
}
|
||||
|
||||
conf.servers = append(conf.servers, &SSEServerWrapper{
|
||||
wrapper := &SSEServerWrapper{
|
||||
BaseServer: common.NewSSEServer(serverInstance,
|
||||
common.WithSSEEndpoint(fmt.Sprintf("%s%s", serverPath, mcp_session.GlobalSSEPathSuffix)),
|
||||
common.WithMessageEndpoint(serverPath)),
|
||||
HostMatchers: hostMatchers,
|
||||
})
|
||||
}
|
||||
|
||||
// Servers that require HTTP Basic auth expose their credentials via the
|
||||
// BasicAuthProvider interface; the filter enforces them per request.
|
||||
if authProvider, ok := server.(common.BasicAuthProvider); ok {
|
||||
wrapper.AuthUsername, wrapper.AuthPassword = authProvider.GetBasicAuthCredentials()
|
||||
}
|
||||
|
||||
conf.servers = append(conf.servers, wrapper)
|
||||
api.LogDebug(fmt.Sprintf("Registered MCP Server: %s", serverType))
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,19 @@ func (f *filter) DecodeHeaders(header api.RequestHeaderMap, endStream bool) api.
|
||||
|
||||
for _, server := range f.config.servers {
|
||||
if common.MatchDomainWithMatchers(f.host, server.HostMatchers) && strings.HasPrefix(f.path, server.BaseServer.GetMessageEndpoint()) {
|
||||
// Enforce HTTP Basic auth for servers that require it, before any
|
||||
// further processing of the request.
|
||||
if server.AuthUsername != "" {
|
||||
authHeader, _ := header.Get("authorization")
|
||||
if !common.CheckBasicAuth(authHeader, server.AuthUsername, server.AuthPassword) {
|
||||
f.callbacks.DecoderFilterCallbacks().SendLocalReply(
|
||||
http.StatusUnauthorized,
|
||||
"Unauthorized",
|
||||
map[string][]string{"WWW-Authenticate": {`Basic realm="MCP Server"`}},
|
||||
0, "")
|
||||
return api.LocalReply
|
||||
}
|
||||
}
|
||||
if url.Method != http.MethodPost {
|
||||
f.callbacks.DecoderFilterCallbacks().SendLocalReply(http.StatusMethodNotAllowed, "Method not allowed", nil, 0, "")
|
||||
return api.LocalReply
|
||||
|
||||
@@ -19,9 +19,18 @@ type HigressOpsConfig struct {
|
||||
envoyAdminURL string
|
||||
namespace string
|
||||
istiodToken string
|
||||
username string
|
||||
password string
|
||||
description string
|
||||
}
|
||||
|
||||
// GetBasicAuthCredentials implements common.BasicAuthProvider. The higress-ops
|
||||
// server exposes Istio/Envoy debug interfaces, so it always requires callers to
|
||||
// present HTTP Basic credentials.
|
||||
func (c *HigressOpsConfig) GetBasicAuthCredentials() (string, string) {
|
||||
return c.username, c.password
|
||||
}
|
||||
|
||||
func (c *HigressOpsConfig) ParseConfig(config map[string]interface{}) error {
|
||||
istiodURL, ok := config["istiodURL"].(string)
|
||||
if !ok {
|
||||
@@ -41,6 +50,20 @@ func (c *HigressOpsConfig) ParseConfig(config map[string]interface{}) error {
|
||||
c.namespace = "higress-system"
|
||||
}
|
||||
|
||||
// Basic auth credentials are mandatory: the higress-ops server exposes
|
||||
// Istio/Envoy debug interfaces and must not be reachable unauthenticated.
|
||||
username, ok := config["username"].(string)
|
||||
if !ok || username == "" {
|
||||
return errors.New("missing username: higress-ops requires basic auth credentials (username and password)")
|
||||
}
|
||||
c.username = username
|
||||
|
||||
password, ok := config["password"].(string)
|
||||
if !ok || password == "" {
|
||||
return errors.New("missing password: higress-ops requires basic auth credentials (username and password)")
|
||||
}
|
||||
c.password = password
|
||||
|
||||
// Optional: Istiod authentication token (required for cross-pod access)
|
||||
if istiodToken, ok := config["istiodToken"].(string); ok {
|
||||
c.istiodToken = istiodToken
|
||||
|
||||
45
plugins/golang-filter/mcp-session/common/basicauth.go
Normal file
45
plugins/golang-filter/mcp-session/common/basicauth.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// BasicAuthProvider is implemented by server configs that require HTTP Basic
|
||||
// authentication. When a server exposes non-empty credentials, the MCP server
|
||||
// filter enforces Basic auth on every request routed to that server.
|
||||
type BasicAuthProvider interface {
|
||||
// GetBasicAuthCredentials returns the username and password required to
|
||||
// access the server. An empty username means authentication is disabled.
|
||||
GetBasicAuthCredentials() (username string, password string)
|
||||
}
|
||||
|
||||
// CheckBasicAuth reports whether the given Authorization header value carries
|
||||
// HTTP Basic credentials matching the expected username and password. The
|
||||
// comparison is constant-time to avoid leaking credential length or content
|
||||
// through timing. It always returns false when the expected username is empty.
|
||||
func CheckBasicAuth(authHeader, expectedUsername, expectedPassword string) bool {
|
||||
if expectedUsername == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
const prefix = "Basic "
|
||||
if len(authHeader) < len(prefix) || !strings.EqualFold(authHeader[:len(prefix)], prefix) {
|
||||
return false
|
||||
}
|
||||
|
||||
decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(authHeader[len(prefix):]))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
username, password, ok := strings.Cut(string(decoded), ":")
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
userMatch := subtle.ConstantTimeCompare([]byte(username), []byte(expectedUsername)) == 1
|
||||
passMatch := subtle.ConstantTimeCompare([]byte(password), []byte(expectedPassword)) == 1
|
||||
return userMatch && passMatch
|
||||
}
|
||||
39
plugins/golang-filter/mcp-session/common/basicauth_test.go
Normal file
39
plugins/golang-filter/mcp-session/common/basicauth_test.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func basicHeader(user, pass string) string {
|
||||
return "Basic " + base64.StdEncoding.EncodeToString([]byte(user+":"+pass))
|
||||
}
|
||||
|
||||
func TestCheckBasicAuth(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
authHeader string
|
||||
wantUser string
|
||||
wantPass string
|
||||
want bool
|
||||
}{
|
||||
{"valid", basicHeader("admin", "s3cret"), "admin", "s3cret", true},
|
||||
{"wrong password", basicHeader("admin", "nope"), "admin", "s3cret", false},
|
||||
{"wrong username", basicHeader("root", "s3cret"), "admin", "s3cret", false},
|
||||
{"empty expected username disables auth", basicHeader("admin", "s3cret"), "", "s3cret", false},
|
||||
{"missing header", "", "admin", "s3cret", false},
|
||||
{"non-basic scheme", "Bearer sometoken", "admin", "s3cret", false},
|
||||
{"malformed base64", "Basic !!!notbase64!!!", "admin", "s3cret", false},
|
||||
{"missing colon", "Basic " + base64.StdEncoding.EncodeToString([]byte("adminonly")), "admin", "s3cret", false},
|
||||
{"case-insensitive scheme", "basic " + base64.StdEncoding.EncodeToString([]byte("admin:s3cret")), "admin", "s3cret", true},
|
||||
{"password containing colon", basicHeader("admin", "a:b:c"), "admin", "a:b:c", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := CheckBasicAuth(tt.authHeader, tt.wantUser, tt.wantPass); got != tt.want {
|
||||
t.Fatalf("CheckBasicAuth(%q, %q, %q) = %v, want %v", tt.authHeader, tt.wantUser, tt.wantPass, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,10 @@
|
||||
```bash
|
||||
# NOTE: 如果你想在构建插件的时候设置额外的构建参数 EXTRA_TAGS
|
||||
# 请更新 extensions/${PLUGIN_NAME} 插件目录对应的 .buildrc 文件
|
||||
# NOTE: 如果你想自定义最终推送的镜像短名(覆盖默认的插件目录名),
|
||||
# 可以在 .buildrc 中添加 IMAGE_NAME=<your-image-name>。
|
||||
# 镜像短名只能包含小写字母、数字、`.`、`_`、`-`,否则构建会失败。
|
||||
# 本设置仅影响推送的镜像 tag,不影响源码路径。
|
||||
$ PLUGIN_NAME=request-block make build
|
||||
```
|
||||
|
||||
|
||||
@@ -9,6 +9,10 @@ The wasm-go plugin can be built quickly with the following command:
|
||||
```bash
|
||||
# NOTE: if you want to set EXTRA_TAGS for the wasm plugin
|
||||
# please set them in the .buildrc file under extensions/${PLUGIN_NAME} directory
|
||||
# NOTE: to override the output image short name (default: plugin directory name),
|
||||
# add `IMAGE_NAME=<your-image-name>` to .buildrc.
|
||||
# The image name must contain only [a-z0-9._-], or the build will fail.
|
||||
# This affects the pushed image tag only, not the source code path.
|
||||
$ PLUGIN_NAME=request-block make build
|
||||
```
|
||||
|
||||
|
||||
@@ -353,6 +353,26 @@ func (c *ClaudeToOpenAIConverter) ConvertClaudeRequestToOpenAIWithOptions(body [
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// computeClaudeInputTokens computes Claude-compatible input_tokens from OpenAI usage.
|
||||
//
|
||||
// In OpenAI's API, prompt_tokens includes cached_tokens (subset relationship).
|
||||
// In Claude's API, input_tokens should NOT include cache tokens.
|
||||
// We detect the OpenAI-standard semantics by checking if total_tokens == prompt_tokens + completion_tokens.
|
||||
// For providers like Bedrock where prompt_tokens does NOT include cache tokens
|
||||
// (total_tokens != prompt_tokens + completion_tokens), we return prompt_tokens as-is.
|
||||
func computeClaudeInputTokens(u *usage) int {
|
||||
if u == nil {
|
||||
return 0
|
||||
}
|
||||
promptTokens := u.PromptTokens
|
||||
if u.PromptTokensDetails != nil && u.PromptTokensDetails.CachedTokens > 0 {
|
||||
if u.TotalTokens > 0 && u.TotalTokens == promptTokens+u.CompletionTokens {
|
||||
return promptTokens - u.PromptTokensDetails.CachedTokens
|
||||
}
|
||||
}
|
||||
return promptTokens
|
||||
}
|
||||
|
||||
// ConvertOpenAIResponseToClaude converts an OpenAI response back to Claude format
|
||||
func (c *ClaudeToOpenAIConverter) ConvertOpenAIResponseToClaude(ctx wrapper.HttpContext, body []byte) ([]byte, error) {
|
||||
log.Debugf("[OpenAI->Claude] Original OpenAI response body: %s", string(body))
|
||||
@@ -373,7 +393,7 @@ func (c *ClaudeToOpenAIConverter) ConvertOpenAIResponseToClaude(ctx wrapper.Http
|
||||
// Only include usage if it's available
|
||||
if openaiResponse.Usage != nil {
|
||||
claudeResponse.Usage = claudeTextGenUsage{
|
||||
InputTokens: openaiResponse.Usage.PromptTokens,
|
||||
InputTokens: computeClaudeInputTokens(openaiResponse.Usage),
|
||||
OutputTokens: openaiResponse.Usage.CompletionTokens,
|
||||
}
|
||||
if openaiResponse.Usage.PromptTokensDetails != nil {
|
||||
@@ -582,7 +602,7 @@ func (c *ClaudeToOpenAIConverter) buildClaudeStreamResponse(ctx wrapper.HttpCont
|
||||
// Only include usage if it's available
|
||||
if openaiResponse.Usage != nil {
|
||||
message.Usage = claudeTextGenUsage{
|
||||
InputTokens: openaiResponse.Usage.PromptTokens,
|
||||
InputTokens: computeClaudeInputTokens(openaiResponse.Usage),
|
||||
OutputTokens: 0,
|
||||
}
|
||||
}
|
||||
@@ -828,7 +848,7 @@ func (c *ClaudeToOpenAIConverter) buildClaudeStreamResponse(ctx wrapper.HttpCont
|
||||
}
|
||||
if openaiResponse.Usage != nil {
|
||||
message.Usage = claudeTextGenUsage{
|
||||
InputTokens: openaiResponse.Usage.PromptTokens,
|
||||
InputTokens: computeClaudeInputTokens(openaiResponse.Usage),
|
||||
OutputTokens: 0,
|
||||
}
|
||||
}
|
||||
@@ -985,7 +1005,7 @@ func (c *ClaudeToOpenAIConverter) buildClaudeStreamResponse(ctx wrapper.HttpCont
|
||||
openaiResponse.Usage.PromptTokens, openaiResponse.Usage.CompletionTokens)
|
||||
|
||||
usage := &claudeTextGenUsage{
|
||||
InputTokens: openaiResponse.Usage.PromptTokens,
|
||||
InputTokens: computeClaudeInputTokens(openaiResponse.Usage),
|
||||
OutputTokens: openaiResponse.Usage.CompletionTokens,
|
||||
}
|
||||
if openaiResponse.Usage.PromptTokensDetails != nil {
|
||||
|
||||
@@ -1099,6 +1099,72 @@ func TestClaudeToOpenAIConverter_ConvertOpenAIResponseToClaude(t *testing.T) {
|
||||
require.NotNil(t, toolContent.Input)
|
||||
assert.Equal(t, "/Users/zhangty/git/higress/README.md", (*toolContent.Input)["file_path"])
|
||||
})
|
||||
|
||||
t.Run("openai_standard_usage_with_cached_tokens", func(t *testing.T) {
|
||||
// OpenAI standard: prompt_tokens includes cached_tokens.
|
||||
// total_tokens == prompt_tokens + completion_tokens -> subtract cached_tokens.
|
||||
openaiResponse := `{
|
||||
"id": "chatcmpl-test",
|
||||
"model": "gpt-4o",
|
||||
"object": "chat.completion",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"finish_reason": "stop",
|
||||
"message": {"role": "assistant", "content": "Hello!"}
|
||||
}],
|
||||
"usage": {
|
||||
"prompt_tokens": 100,
|
||||
"completion_tokens": 20,
|
||||
"total_tokens": 120,
|
||||
"prompt_tokens_details": {"cached_tokens": 60}
|
||||
}
|
||||
}`
|
||||
|
||||
result, err := converter.ConvertOpenAIResponseToClaude(nil, []byte(openaiResponse))
|
||||
require.NoError(t, err)
|
||||
|
||||
var claudeResp claudeTextGenResponse
|
||||
err = json.Unmarshal(result, &claudeResp)
|
||||
require.NoError(t, err)
|
||||
|
||||
// input_tokens should exclude cache: 100 - 60 = 40
|
||||
assert.Equal(t, 40, claudeResp.Usage.InputTokens)
|
||||
assert.Equal(t, 20, claudeResp.Usage.OutputTokens)
|
||||
assert.Equal(t, 60, claudeResp.Usage.CacheReadInputTokens)
|
||||
})
|
||||
|
||||
t.Run("bedrock_style_usage_with_cached_tokens", func(t *testing.T) {
|
||||
// Bedrock-style: prompt_tokens does NOT include cached_tokens.
|
||||
// total_tokens (180) != prompt_tokens (100) + completion_tokens (20) -> do NOT subtract.
|
||||
openaiResponse := `{
|
||||
"id": "chatcmpl-test",
|
||||
"model": "gpt-4o",
|
||||
"object": "chat.completion",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"finish_reason": "stop",
|
||||
"message": {"role": "assistant", "content": "Hello!"}
|
||||
}],
|
||||
"usage": {
|
||||
"prompt_tokens": 100,
|
||||
"completion_tokens": 20,
|
||||
"total_tokens": 180,
|
||||
"prompt_tokens_details": {"cached_tokens": 60}
|
||||
}
|
||||
}`
|
||||
|
||||
result, err := converter.ConvertOpenAIResponseToClaude(nil, []byte(openaiResponse))
|
||||
require.NoError(t, err)
|
||||
|
||||
var claudeResp claudeTextGenResponse
|
||||
err = json.Unmarshal(result, &claudeResp)
|
||||
require.NoError(t, err)
|
||||
|
||||
// input_tokens should NOT be adjusted: 100 (prompt_tokens already excludes cache)
|
||||
assert.Equal(t, 100, claudeResp.Usage.InputTokens)
|
||||
assert.Equal(t, 20, claudeResp.Usage.OutputTokens)
|
||||
assert.Equal(t, 60, claudeResp.Usage.CacheReadInputTokens)
|
||||
})
|
||||
}
|
||||
|
||||
func TestProviderConfigSupportsMessageReasoningContent(t *testing.T) {
|
||||
@@ -1354,6 +1420,28 @@ func TestClaudeToOpenAIConverter_ConvertOpenAIStreamResponseToClaude_WithCachedT
|
||||
|
||||
resultStr := string(result)
|
||||
assert.Contains(t, resultStr, "\"type\":\"message_delta\"")
|
||||
// OpenAI standard: prompt_tokens (100) includes cached_tokens (60).
|
||||
// Claude semantics: input_tokens should exclude cache tokens, so 100 - 60 = 40.
|
||||
assert.Contains(t, resultStr, "\"input_tokens\":40")
|
||||
assert.Contains(t, resultStr, "\"output_tokens\":20")
|
||||
assert.Contains(t, resultStr, "\"cache_read_input_tokens\":60")
|
||||
}
|
||||
|
||||
func TestClaudeToOpenAIConverter_ConvertOpenAIStreamResponseToClaude_BedrockStyleUsage(t *testing.T) {
|
||||
converter := &ClaudeToOpenAIConverter{}
|
||||
|
||||
// Bedrock-style usage: prompt_tokens does NOT include cached_tokens.
|
||||
// total_tokens (180) != prompt_tokens (100) + completion_tokens (20),
|
||||
// so computeClaudeInputTokens should NOT subtract cached_tokens.
|
||||
streamChunk := "data: {\"id\":\"chatcmpl-test\",\"model\":\"gpt-4o\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\"}}]}\n\n" +
|
||||
"data: {\"id\":\"chatcmpl-test\",\"model\":\"gpt-4o\",\"choices\":[{\"index\":0,\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":100,\"completion_tokens\":20,\"total_tokens\":180,\"prompt_tokens_details\":{\"cached_tokens\":60}}}\n\n"
|
||||
|
||||
result, err := converter.ConvertOpenAIStreamResponseToClaude(nil, []byte(streamChunk))
|
||||
require.NoError(t, err)
|
||||
|
||||
resultStr := string(result)
|
||||
assert.Contains(t, resultStr, "\"type\":\"message_delta\"")
|
||||
// Bedrock: prompt_tokens already excludes cache, so input_tokens = prompt_tokens = 100.
|
||||
assert.Contains(t, resultStr, "\"input_tokens\":100")
|
||||
assert.Contains(t, resultStr, "\"output_tokens\":20")
|
||||
assert.Contains(t, resultStr, "\"cache_read_input_tokens\":60")
|
||||
|
||||
@@ -649,6 +649,10 @@ func (c *ProviderConfig) GetGlobalRandomToken() string {
|
||||
count := len(apiTokens)
|
||||
switch count {
|
||||
case 0:
|
||||
if len(unavailableApiTokens) == 0 {
|
||||
log.Warn("all tokens are unavailable and no unavailable tokens recorded")
|
||||
return ""
|
||||
}
|
||||
log.Warn("all tokens are unavailable, will use random one of the unavailable tokens")
|
||||
return unavailableApiTokens[rand.Intn(len(unavailableApiTokens))]
|
||||
case 1:
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -82,6 +83,7 @@ const (
|
||||
LLMServiceDuration = "llm_service_duration"
|
||||
LLMDurationCount = "llm_duration_count"
|
||||
LLMStreamDurationCount = "llm_stream_duration_count"
|
||||
LLMFailureCount = "llm_failure_count"
|
||||
ResponseType = "response_type"
|
||||
ChatID = "chat_id"
|
||||
ChatRound = "chat_round"
|
||||
@@ -820,6 +822,13 @@ func onHttpStreamingBody(ctx wrapper.HttpContext, config AIStatisticsConfig, dat
|
||||
ctx.SetUserAttribute(ChatID, chatID.String())
|
||||
}
|
||||
|
||||
// Track streaming errors across chunks — SSE failures often appear as
|
||||
// data: {"error":{...}} before data: [DONE], so the last chunk alone is
|
||||
// insufficient for error detection.
|
||||
if !ctx.GetBoolContext("hasStreamError", false) && isErrorResponse(data) {
|
||||
ctx.SetContext("hasStreamError", true)
|
||||
}
|
||||
|
||||
// Get requestStartTime from http context
|
||||
requestStartTime, ok := ctx.GetContext(StatisticsRequestStartTime).(int64)
|
||||
if !ok {
|
||||
@@ -873,8 +882,13 @@ func onHttpStreamingBody(ctx wrapper.HttpContext, config AIStatisticsConfig, dat
|
||||
debugLogAiLog(ctx)
|
||||
_ = ctx.WriteUserAttributeToLogWithKey(wrapper.AILogKey)
|
||||
|
||||
// Write metrics
|
||||
writeMetric(ctx, config)
|
||||
// Write metrics — prefer the accumulated buffer for error detection
|
||||
// so that errors split across multiple SSE chunks are not missed.
|
||||
bodyForMetric := data
|
||||
if config.shouldBufferStreamingBody && len(streamingBodyBuffer) > 0 {
|
||||
bodyForMetric = streamingBodyBuffer
|
||||
}
|
||||
writeMetric(ctx, config, bodyForMetric)
|
||||
}
|
||||
return data
|
||||
}
|
||||
@@ -928,7 +942,7 @@ func onHttpResponseBody(ctx wrapper.HttpContext, config AIStatisticsConfig, body
|
||||
_ = ctx.WriteUserAttributeToLogWithKey(wrapper.AILogKey)
|
||||
|
||||
// Write metrics
|
||||
writeMetric(ctx, config)
|
||||
writeMetric(ctx, config, body)
|
||||
|
||||
return types.ActionContinue
|
||||
}
|
||||
@@ -1341,7 +1355,59 @@ func setSpanAttribute(key string, value interface{}) {
|
||||
}
|
||||
}
|
||||
|
||||
func writeMetric(ctx wrapper.HttpContext, config AIStatisticsConfig) {
|
||||
// isErrorResponse checks whether the LLM response indicates an error.
|
||||
// Detects errors by:
|
||||
// 1. Response body contains non-null "error" field at root level (OpenAI/Anthropic format).
|
||||
// Handles both raw JSON and SSE "data: " prefixed chunks, including multi-event
|
||||
// streaming buffers.
|
||||
// 2. HTTP status code >= 400 as fallback when body is empty.
|
||||
//
|
||||
// Note: some providers (e.g. Anthropic streaming responses) emit {"error":""}
|
||||
// even on success; an empty-string error is treated as not-an-error to avoid
|
||||
// false positives.
|
||||
func isErrorResponse(body []byte) bool {
|
||||
if len(body) > 0 {
|
||||
// SSE chunks are prefixed with "data: "; accumulated buffers contain
|
||||
// multiple SSE events separated by \n\n. Split and check each event.
|
||||
trimmed := bytes.TrimSpace(body)
|
||||
if bytes.HasPrefix(trimmed, []byte("data: ")) {
|
||||
for _, event := range bytes.Split(trimmed, []byte("\n\n")) {
|
||||
jsonBody := bytes.TrimSpace(event)
|
||||
if bytes.HasPrefix(jsonBody, []byte("data: ")) {
|
||||
jsonBody = jsonBody[len("data: "):]
|
||||
}
|
||||
if hasErrorField(jsonBody) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
return hasErrorField(body)
|
||||
}
|
||||
// Fallback: check HTTP status code for errors with empty body (connection reset, timeout, etc.)
|
||||
if len(body) == 0 {
|
||||
if statusCode, err := proxywasm.GetHttpResponseHeader(":status"); err == nil {
|
||||
if code, err := strconv.Atoi(statusCode); err == nil && code >= 400 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// hasErrorField checks whether a JSON body contains a non-null, non-empty-string "error" field.
|
||||
func hasErrorField(jsonBody []byte) bool {
|
||||
errorVal := gjson.GetBytes(jsonBody, "error")
|
||||
if errorVal.Exists() && errorVal.Value() != nil {
|
||||
if errorVal.Type == gjson.String && errorVal.String() == "" {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func writeMetric(ctx wrapper.HttpContext, config AIStatisticsConfig, body []byte) {
|
||||
// Generate usage metrics
|
||||
var ok bool
|
||||
var route, cluster, model string
|
||||
@@ -1357,6 +1423,29 @@ func writeMetric(ctx wrapper.HttpContext, config AIStatisticsConfig) {
|
||||
return
|
||||
}
|
||||
|
||||
// Get model for metric label (may be empty for error responses)
|
||||
modelStr := "-"
|
||||
if m := ctx.GetUserAttribute(tokenusage.CtxKeyModel); m != nil {
|
||||
if ms, ok := m.(string); ok {
|
||||
modelStr = ms
|
||||
}
|
||||
}
|
||||
// Fallback to request model for error responses where usage info is unavailable
|
||||
if modelStr == "-" {
|
||||
if rm, ok := ctx.GetContext(tokenusage.CtxKeyRequestModel).(string); ok && rm != "" {
|
||||
modelStr = rm
|
||||
}
|
||||
}
|
||||
|
||||
// Count failure before usage check, so error responses without usage info are still counted.
|
||||
// For streaming, also check the hasStreamError flag set during onHttpStreamingBody.
|
||||
// llm_failure_count is intentionally incremented regardless of disableOpenaiUsage,
|
||||
// because error responses carry no usage info and operators still need the failure
|
||||
// signal even when usage tracking is off.
|
||||
if isErrorResponse(body) || ctx.GetBoolContext("hasStreamError", false) {
|
||||
config.incrementCounter(generateMetricName(route, cluster, modelStr, consumer, LLMFailureCount), 1)
|
||||
}
|
||||
|
||||
if config.disableOpenaiUsage {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2167,3 +2167,222 @@ func TestConfigWithDefaultAttributes(t *testing.T) {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestIsErrorResponse(t *testing.T) {
|
||||
// Test error body detection (OpenAI/Anthropic format)
|
||||
t.Run("error object in body", func(t *testing.T) {
|
||||
body := []byte(`{"error": {"type": "api_error", "message": "Unauthorized"}}`)
|
||||
require.True(t, isErrorResponse(body))
|
||||
})
|
||||
|
||||
t.Run("error object with only code", func(t *testing.T) {
|
||||
body := []byte(`{"error": {"code": "invalid_api_key"}}`)
|
||||
require.True(t, isErrorResponse(body))
|
||||
})
|
||||
|
||||
t.Run("error string in body", func(t *testing.T) {
|
||||
body := []byte(`{"error": "Something went wrong"}`)
|
||||
require.True(t, isErrorResponse(body))
|
||||
})
|
||||
|
||||
t.Run("empty error string is not counted", func(t *testing.T) {
|
||||
body := []byte(`{"error": ""}`)
|
||||
require.False(t, isErrorResponse(body))
|
||||
})
|
||||
|
||||
t.Run("null error is not counted", func(t *testing.T) {
|
||||
body := []byte(`{"error": null}`)
|
||||
require.False(t, isErrorResponse(body))
|
||||
})
|
||||
|
||||
t.Run("no error field in body", func(t *testing.T) {
|
||||
body := []byte(`{"choices": [{"message": {"content": "Hi"}}], "model": "gpt-4"}`)
|
||||
require.False(t, isErrorResponse(body))
|
||||
})
|
||||
|
||||
t.Run("nested error field not at root", func(t *testing.T) {
|
||||
body := []byte(`{"choices": [{"error": "x"}]}`)
|
||||
require.False(t, isErrorResponse(body))
|
||||
})
|
||||
|
||||
// Empty body with HTTP status fallback is tested in TestIsErrorResponseWithHTTPStatus
|
||||
// (requires wasm test environment for proxywasm.GetHttpResponseHeader)
|
||||
}
|
||||
|
||||
func TestIsErrorResponseWithHTTPStatus(t *testing.T) {
|
||||
test.RunTest(t, func(t *testing.T) {
|
||||
t.Run("empty body with 401 status returns true", func(t *testing.T) {
|
||||
host, status := test.NewTestHost(basicConfig)
|
||||
defer host.Reset()
|
||||
require.Equal(t, types.OnPluginStartStatusOK, status)
|
||||
|
||||
host.CallOnHttpRequestHeaders([][2]string{
|
||||
{":authority", "example.com"},
|
||||
{":path", "/api/chat"},
|
||||
{":method", "POST"},
|
||||
})
|
||||
host.CallOnHttpResponseHeaders([][2]string{
|
||||
{":status", "401"},
|
||||
{"content-type", "application/json"},
|
||||
})
|
||||
|
||||
require.True(t, isErrorResponse([]byte{}))
|
||||
host.CompleteHttp()
|
||||
})
|
||||
|
||||
t.Run("empty body with 200 status returns false", func(t *testing.T) {
|
||||
host, status := test.NewTestHost(basicConfig)
|
||||
defer host.Reset()
|
||||
require.Equal(t, types.OnPluginStartStatusOK, status)
|
||||
|
||||
host.CallOnHttpRequestHeaders([][2]string{
|
||||
{":authority", "example.com"},
|
||||
{":path", "/api/chat"},
|
||||
{":method", "POST"},
|
||||
})
|
||||
host.CallOnHttpResponseHeaders([][2]string{
|
||||
{":status", "200"},
|
||||
{"content-type", "application/json"},
|
||||
})
|
||||
|
||||
require.False(t, isErrorResponse([]byte{}))
|
||||
host.CompleteHttp()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestFailureCountMetric(t *testing.T) {
|
||||
test.RunTest(t, func(t *testing.T) {
|
||||
t.Run("error response body increments failure count only", func(t *testing.T) {
|
||||
host, status := test.NewTestHost(basicConfig)
|
||||
defer host.Reset()
|
||||
require.Equal(t, types.OnPluginStartStatusOK, status)
|
||||
|
||||
host.SetRouteName("api-v1")
|
||||
host.SetClusterName("cluster-1")
|
||||
|
||||
host.CallOnHttpRequestHeaders([][2]string{
|
||||
{":authority", "example.com"},
|
||||
{":path", "/api/chat"},
|
||||
{":method", "POST"},
|
||||
{"x-mse-consumer", "user1"},
|
||||
})
|
||||
|
||||
requestBody := []byte(`{"model": "gpt-3.5-turbo", "messages": [{"role": "user", "content": "Hello"}]}`)
|
||||
host.CallOnHttpRequestBody(requestBody)
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
host.CallOnHttpResponseHeaders([][2]string{
|
||||
{":status", "401"},
|
||||
{"content-type", "application/json"},
|
||||
})
|
||||
errorBody := []byte(`{"error": {"type": "authentication_error", "message": "Invalid API key"}}`)
|
||||
host.CallOnHttpResponseBody(errorBody)
|
||||
|
||||
host.CompleteHttp()
|
||||
|
||||
// Verify llm_failure_count is incremented
|
||||
failureMetric := "route.api-v1.upstream.cluster-1.model.gpt-3.5-turbo.consumer.user1.metric.llm_failure_count"
|
||||
failureValue, err := host.GetCounterMetric(failureMetric)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, uint64(1), failureValue)
|
||||
|
||||
// Verify success metrics are NOT present (usage info unavailable in error response)
|
||||
durationCountMetric := "route.api-v1.upstream.cluster-1.model.gpt-3.5-turbo.consumer.user1.metric.llm_duration_count"
|
||||
_, err = host.GetCounterMetric(durationCountMetric)
|
||||
require.Error(t, err, "llm_duration_count should not exist for error response without usage")
|
||||
})
|
||||
|
||||
t.Run("success response does not increment failure count", func(t *testing.T) {
|
||||
host, status := test.NewTestHost(basicConfig)
|
||||
defer host.Reset()
|
||||
require.Equal(t, types.OnPluginStartStatusOK, status)
|
||||
|
||||
host.SetRouteName("api-v1")
|
||||
host.SetClusterName("cluster-1")
|
||||
|
||||
host.CallOnHttpRequestHeaders([][2]string{
|
||||
{":authority", "example.com"},
|
||||
{":path", "/api/chat"},
|
||||
{":method", "POST"},
|
||||
{"x-mse-consumer", "user1"},
|
||||
})
|
||||
|
||||
requestBody := []byte(`{"model": "gpt-4", "messages": [{"role": "user", "content": "Hello"}]}`)
|
||||
host.CallOnHttpRequestBody(requestBody)
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
host.CallOnHttpResponseHeaders([][2]string{
|
||||
{":status", "200"},
|
||||
{"content-type", "application/json"},
|
||||
})
|
||||
responseBody := []byte(`{
|
||||
"choices": [{"message": {"content": "Hello!"}}],
|
||||
"usage": {"prompt_tokens": 5, "completion_tokens": 3, "total_tokens": 8},
|
||||
"model": "gpt-4"
|
||||
}`)
|
||||
host.CallOnHttpResponseBody(responseBody)
|
||||
|
||||
host.CompleteHttp()
|
||||
|
||||
// Verify llm_failure_count is NOT present
|
||||
failureMetric := "route.api-v1.upstream.cluster-1.model.gpt-4.consumer.user1.metric.llm_failure_count"
|
||||
_, err := host.GetCounterMetric(failureMetric)
|
||||
require.Error(t, err, "llm_failure_count should not exist for successful response")
|
||||
|
||||
// Verify success metrics exist
|
||||
durationCountMetric := "route.api-v1.upstream.cluster-1.model.gpt-4.consumer.user1.metric.llm_duration_count"
|
||||
durationCountValue, err := host.GetCounterMetric(durationCountMetric)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, uint64(1), durationCountValue)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestStreamingFailureCountMetric(t *testing.T) {
|
||||
test.RunTest(t, func(t *testing.T) {
|
||||
t.Run("sse error in middle chunk before done", func(t *testing.T) {
|
||||
host, status := test.NewTestHost(streamingBodyConfig)
|
||||
defer host.Reset()
|
||||
require.Equal(t, types.OnPluginStartStatusOK, status)
|
||||
|
||||
host.SetRouteName("api-v1")
|
||||
host.SetClusterName("cluster-1")
|
||||
|
||||
host.CallOnHttpRequestHeaders([][2]string{
|
||||
{":authority", "example.com"},
|
||||
{":path", "/v1/chat/completions"},
|
||||
{":method", "POST"},
|
||||
{"x-mse-consumer", "user1"},
|
||||
})
|
||||
|
||||
requestBody := []byte(`{"model": "gpt-4", "messages": [{"role": "user", "content": "Hello"}]}`)
|
||||
host.CallOnHttpRequestBody(requestBody)
|
||||
|
||||
host.CallOnHttpResponseHeaders([][2]string{
|
||||
{":status", "200"},
|
||||
{"content-type", "text/event-stream"},
|
||||
})
|
||||
|
||||
// Simulate SSE stream: error appears in middle chunk, last chunk is [DONE]
|
||||
chunk1 := []byte(`data: {"choices":[{"delta":{"content":"Hello"}}]}`)
|
||||
host.CallOnHttpStreamingResponseBody(chunk1, false)
|
||||
|
||||
chunk2 := []byte(`data: {"error":{"type":"api_error","message":"Something went wrong"}}`)
|
||||
host.CallOnHttpStreamingResponseBody(chunk2, false)
|
||||
|
||||
chunk3 := []byte(`data: [DONE]`)
|
||||
host.CallOnHttpStreamingResponseBody(chunk3, true)
|
||||
|
||||
host.CompleteHttp()
|
||||
|
||||
// Verify llm_failure_count is incremented despite [DONE] being the last chunk
|
||||
failureMetric := "route.api-v1.upstream.cluster-1.model.gpt-4.consumer.user1.metric.llm_failure_count"
|
||||
failureValue, err := host.GetCounterMetric(failureMetric)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, uint64(1), failureValue)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,12 +4,23 @@ keywords: [ AI网关, AI token限流 ]
|
||||
description: AI Token限流插件配置参考
|
||||
---
|
||||
|
||||
> ⚠️ **行为变更提示(无版本号变化)**
|
||||
>
|
||||
> 自本次更新起,`rule_items` 的匹配语义从 **first-match-wins**(命中第一条即返回)改为 **all-match OR 叠加**(所有命中规则都评估,任一触发即拒绝)。同时解除 `global_threshold` 与 `rule_items` 的互斥约束,支持混合配置。
|
||||
>
|
||||
> - 老配置(单条 `rule_items` 或仅 `global_threshold`):行为不变
|
||||
> - 老配置(多条 `rule_items` 期望短路匹配):**行为会变** —— 所有命中规则都会评估
|
||||
> - Redis key 格式新增 `{rule_name}` hash tag 以兼容 Redis Cluster,老计数器数据不兼容
|
||||
>
|
||||
> 详见下方"功能说明"与"配置示例"。
|
||||
|
||||
## 功能说明
|
||||
|
||||
`ai-token-ratelimit`插件基于 Redis 实现了 AI Token 限流功能,支持以下两种限流模式:
|
||||
`ai-token-ratelimit`插件基于 Redis 实现了 AI Token 限流功能,支持以下三种限流模式:
|
||||
|
||||
- **规则级全局限流**:依据相同的`rule_name`与`global_threshold`配置,为自定义规则组设置全局 token 限流阈值
|
||||
- **Key 级动态限流**:根据请求中的动态 Key(包括 URL 参数、请求头、客户端 IP、Consumer 名称或 Cookie 字段等)进行分组 token 限流
|
||||
- **混合限流**:同时配置 `global_threshold`(全局兜底)和 `rule_items`(按维度细分),所有命中的规则**叠加生效**,任一触发即拒绝。
|
||||
|
||||
## 运行属性
|
||||
|
||||
@@ -21,8 +32,8 @@ description: AI Token限流插件配置参考
|
||||
| 配置项 | 类型 | 必填 | 默认值 | 说明 |
|
||||
| ----------------------- | ------ | ---- | ------ |---------------------------------------------------------------------------|
|
||||
| rule_name | string | 是 | - | 限流规则名称,根据限流规则名称+限流类型+限流key名称+限流key对应的实际值来拼装redis key |
|
||||
| global_threshold | Object | 否,`global_threshold` 或 `rule_items` 选填一项 | - | 对整个自定义规则组进行限流 |
|
||||
| rule_items | array of object | 否,`global_threshold` 或 `rule_items` 选填一项 | - | 限流规则项,按照rule_items下的排列顺序,匹配第一个rule_item后命中限流规则,后续规则将被忽略 |
|
||||
| global_threshold | Object | 否,至少一项;可同时配置 | - | 对整个自定义规则组进行限流 |
|
||||
| rule_items | array of object | 否,至少一项;可同时配置 | - | 限流规则项,最多支持 **10 条**。所有满足匹配条件的 `rule_item` 都会参与限流,规则之间是"或"关系,任一触发即拒绝;规则的执行顺序不影响最终结果。详见下方"配置说明"展开。 |
|
||||
| rejected_code | int | 否 | 429 | 请求被限流时,返回的HTTP状态码 |
|
||||
| rejected_msg | string | 否 | Too many requests | 请求被限流时,返回的响应体 |
|
||||
| redis | object | 是 | - | redis相关配置 |
|
||||
@@ -51,6 +62,14 @@ description: AI Token限流插件配置参考
|
||||
| limit_by_per_ip | string | 否,`limit_by_*`中选填一项 | - | 按规则匹配特定 IP,并对每个 IP 分别计算限流,配置获取限流键值的来源 IP 参数名称,从请求头获取,以`from-header-对应的header名`,示例:`from-header-x-forwarded-for`,直接获取对端socket ip,配置为`from-remote-addr` |
|
||||
| limit_keys | array of object | 是 | - | 配置匹配键值后的限流次数 |
|
||||
|
||||
#### `rule_items` 多规则匹配语义
|
||||
|
||||
`rule_items` 是一个数组,**所有满足匹配条件**的 `rule_item` 都会被评估(均发起 Redis 计数器检查),规则之间是"或"关系,**任一触发即拒绝**。
|
||||
|
||||
> **注意:** 拒绝时按 `global_threshold`(如有)→ `rule_items`(数组顺序)的优先级**取最先触发的规则**报告响应头(`X-RateLimit-Reset` 等),而非"所有触发规则叠加报告"。因此,若同时配置了 `global_threshold` 和 `rule_items`,`global_threshold` 触发时总会优先报告,不会被 `rule_items` 覆盖。
|
||||
|
||||
> `rule_items` 数组最多支持 **10 条**规则。每条 `rule_item` 会按命中的 `limit_keys` 产生独立的 Redis 计数器。
|
||||
|
||||
`limit_keys`中每一项的配置字段说明。
|
||||
|
||||
| 配置项 | 类型 | 必填 | 默认值 | 说明 |
|
||||
|
||||
@@ -4,12 +4,23 @@ keywords: [ AI Gateway, AI Token Rate Limiting ]
|
||||
description: AI Token Rate Limiting Plugin Configuration Reference
|
||||
---
|
||||
|
||||
> ⚠️ **Behavior Change Notice (no version bump)**
|
||||
>
|
||||
> As of this update, `rule_items` matching semantics changed from **first-match-wins** (returns on first hit) to **all-match OR overlay** (all matched rules are evaluated, any trigger rejects). The mutual exclusion between `global_threshold` and `rule_items` is also removed to support hybrid configuration.
|
||||
>
|
||||
> - Existing config (single `rule_items` or only `global_threshold`): behavior unchanged
|
||||
> - Existing config (multiple `rule_items` expecting short-circuit match): **behavior will change** — all matched rules are evaluated
|
||||
> - Redis key format adds `{rule_name}` hash tag for Redis Cluster compatibility; old counter data is incompatible
|
||||
>
|
||||
> See "Features" and "Configuration Examples" below for details.
|
||||
|
||||
## Function Description
|
||||
|
||||
The `ai-token-ratelimit` plugin implements AI Token rate limiting based on Redis, supporting the following two rate limiting modes:
|
||||
The `ai-token-ratelimit` plugin implements AI Token rate limiting based on Redis, supporting the following three rate limiting modes:
|
||||
|
||||
- **Rule-level Global Rate Limiting**: Sets a global token rate limit threshold for custom rule groups based on the same `rule_name` and `global_threshold` configurations.
|
||||
- **Key-level Dynamic Rate Limiting**: Performs grouped token rate limiting based on dynamic keys in requests (including URL parameters, request headers, client IP, Consumer name, or Cookie fields, etc.).
|
||||
- **Hybrid rate limiting**: Configure `global_threshold` (global fallback) and `rule_items` (per-dimension) simultaneously. All matched rules take effect together; any trigger rejects the request.
|
||||
|
||||
|
||||
## Runtime Properties
|
||||
@@ -23,8 +34,8 @@ Plugin execution priority: `600`
|
||||
| Configuration Item | Type | Required | Default Value | Description |
|
||||
|--------------------------|----------------|----------|---------------|-------------------------------------------------------------------------------------------------|
|
||||
| rule_name | string | Yes | - | Name of the rate limiting rule. The Redis key is assembled based on the rate limiting rule name + rate limiting type + rate limiting key name + actual value corresponding to the rate limiting key. |
|
||||
| global_threshold | Object | No, either `global_threshold` or `rule_items` is required | - | Rate limits the entire custom rule group |
|
||||
| rule_items | array of object| No, either `global_threshold` or `rule_items` is required | - | Rate limiting rule items. The first matching `rule_item` in the order of `rule_items` triggers the rate limiting rule, and subsequent rules are ignored. |
|
||||
| global_threshold | Object | No, at least one of them is required; can be configured simultaneously | - | Rate limits the entire custom rule group |
|
||||
| rule_items | array of object| No, at least one of them is required; can be configured simultaneously | - | Rate limiting rule items. Supports up to **10** rules. All matched `rule_item`s are evaluated and combined with an OR relationship; any trigger rejects the request. The execution order of rules does not affect the final result. See the expanded `rule_items` notes below for details. |
|
||||
| rejected_code | int | No | 429 | HTTP status code returned when a request is rate-limited |
|
||||
| rejected_msg | string | No | Too many requests | Response body returned when a request is rate-limited |
|
||||
| redis | object | Yes | - | Redis-related configurations |
|
||||
@@ -55,6 +66,14 @@ Plugin execution priority: `600`
|
||||
| limit_by_per_ip | string | No, one of `limit_by_*` is required | - | Matches specific IPs by rule and calculates rate limits for each IP separately. Configures the source of the rate limiting key value as the IP parameter name, obtained from the request header in the format `from-header-corresponding_header_name` (e.g., `from-header-x-forwarded-for`), or directly obtains the peer socket IP by configuring `from-remote-addr`. |
|
||||
| limit_keys | array of object | Yes | - | Configures the rate limiting count after matching the key value |
|
||||
|
||||
#### `rule_items` Multi-Rule Matching Semantics
|
||||
|
||||
`rule_items` is an array. **All** `rule_item`s whose match conditions are satisfied are evaluated (each triggers an independent Redis counter check), and the rules are combined with an OR relationship: **any trigger rejects the request**.
|
||||
|
||||
> **Note:** On rejection, the response headers (`X-RateLimit-Reset`, etc.) are reported from the **first triggered rule** in priority order: `global_threshold` (if present) → `rule_items` (in array order). This is **not** an "all triggered rules combined" report. Therefore, when both `global_threshold` and `rule_items` are configured, a triggered `global_threshold` always takes reporting priority over `rule_items`.
|
||||
|
||||
> The `rule_items` array supports up to **10** rules. Each `rule_item` produces independent Redis counters for each matched `limit_keys`.
|
||||
|
||||
|
||||
### Description of Configuration Fields in `limit_keys`
|
||||
|
||||
|
||||
@@ -46,6 +46,9 @@ const (
|
||||
SecondsPerMinute = 60 * Second
|
||||
SecondsPerHour = 60 * SecondsPerMinute
|
||||
SecondsPerDay = 24 * SecondsPerHour
|
||||
|
||||
// MaxRuleItems 限制 rule_items 数组最大长度,对齐商业版"最多同时命中 10 条规则"。
|
||||
MaxRuleItems = 10
|
||||
)
|
||||
|
||||
var timeWindows = map[string]int64{
|
||||
@@ -181,8 +184,6 @@ func initLimitRule(json gjson.Result, config *AiTokenRateLimitConfig) error {
|
||||
hasRule := ruleItemsResult.Exists()
|
||||
if !hasGlobal && !hasRule {
|
||||
return errors.New("at least one of 'global_threshold' or 'rule_items' must be set")
|
||||
} else if hasGlobal && hasRule {
|
||||
return errors.New("'global_threshold' and 'rule_items' cannot be set at the same time")
|
||||
}
|
||||
|
||||
// 处理全局限流配置
|
||||
@@ -192,14 +193,19 @@ func initLimitRule(json gjson.Result, config *AiTokenRateLimitConfig) error {
|
||||
return fmt.Errorf("failed to parse global_threshold: %w", err)
|
||||
}
|
||||
config.GlobalThreshold = threshold
|
||||
return nil
|
||||
}
|
||||
|
||||
// 处理条件限流规则
|
||||
if !hasRule {
|
||||
return nil
|
||||
}
|
||||
items := ruleItemsResult.Array()
|
||||
if len(items) == 0 {
|
||||
return errors.New("config rule_items cannot be empty")
|
||||
}
|
||||
if len(items) > MaxRuleItems {
|
||||
return fmt.Errorf("rule_items length %d exceeds maximum %d", len(items), MaxRuleItems)
|
||||
}
|
||||
|
||||
var ruleItems []LimitRuleItem
|
||||
// 用于记录已出现的LimitType和Key的组合
|
||||
@@ -298,7 +304,7 @@ func parseLimitRuleItem(item gjson.Result) (*LimitRuleItem, error) {
|
||||
}
|
||||
|
||||
if limitType == "" {
|
||||
return nil, errors.New("only one of 'limit_by_header' and 'limit_by_param' and 'limit_by_consumer' and 'limit_by_cookie' and 'limit_by_per_header' and 'limit_by_per_param' and 'limit_by_per_consumer' and 'limit_by_per_cookie' and 'limit_by_per_ip' can be set")
|
||||
return nil, errors.New("at least one of 'limit_by_header', 'limit_by_param', 'limit_by_consumer', 'limit_by_cookie', 'limit_by_per_header', 'limit_by_per_param', 'limit_by_per_consumer', 'limit_by_per_cookie', 'limit_by_per_ip' must be set")
|
||||
}
|
||||
ruleItem.LimitType = limitType
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -166,13 +168,40 @@ func TestParseAiTokenRateLimitConfig(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Conflict_GlobalThresholdAndRuleItems",
|
||||
name: "Both_ShouldSucceed",
|
||||
json: `{
|
||||
"rule_name": "test-conflict",
|
||||
"rule_name": "test-both",
|
||||
"global_threshold": {"token_per_second": 100},
|
||||
"rule_items": [{"limit_by_header": "x-test"}]
|
||||
"rule_items": [
|
||||
{
|
||||
"limit_by_header": "x-test",
|
||||
"limit_keys": [{"key": "k1", "token_per_minute": 10}]
|
||||
}
|
||||
]
|
||||
}`,
|
||||
expectedErr: errors.New("'global_threshold' and 'rule_items' cannot be set at the same time"),
|
||||
expected: AiTokenRateLimitConfig{
|
||||
RuleName: "test-both",
|
||||
GlobalThreshold: &GlobalThreshold{
|
||||
Count: 100,
|
||||
TimeWindow: Second,
|
||||
},
|
||||
RuleItems: []LimitRuleItem{
|
||||
{
|
||||
LimitType: LimitByHeaderType,
|
||||
Key: "x-test",
|
||||
ConfigItems: []LimitConfigItem{
|
||||
{
|
||||
ConfigType: ExactType,
|
||||
Key: "k1",
|
||||
Count: 10,
|
||||
TimeWindow: SecondsPerMinute,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
RejectedCode: DefaultRejectedCode,
|
||||
RejectedMsg: DefaultRejectedMsg,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Missing_GlobalThresholdAndRuleItems",
|
||||
@@ -199,6 +228,16 @@ func TestParseAiTokenRateLimitConfig(t *testing.T) {
|
||||
RejectedMsg: "Forbidden",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "LimitByType_AtLeastOne_Error",
|
||||
json: `{
|
||||
"rule_name": "no-limit-by",
|
||||
"rule_items": [
|
||||
{"limit_keys": [{"key": "k", "token_per_second": 1}]}
|
||||
]
|
||||
}`,
|
||||
expectedErr: errors.New("failed to parse rule_item in rule_items: at least one of 'limit_by_header', 'limit_by_param', 'limit_by_consumer', 'limit_by_cookie', 'limit_by_per_header', 'limit_by_per_param', 'limit_by_per_consumer', 'limit_by_per_cookie', 'limit_by_per_ip' must be set"),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -216,3 +255,32 @@ func TestParseAiTokenRateLimitConfig(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAiTokenRateLimitConfig_RuleItemsLimit(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
itemCnt int
|
||||
expectErr string
|
||||
}{
|
||||
{"Exactly10", 10, ""},
|
||||
{"Over10", 11, "rule_items length 11 exceeds maximum 10"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
items := make([]string, 0, tt.itemCnt)
|
||||
for i := 0; i < tt.itemCnt; i++ {
|
||||
items = append(items,
|
||||
fmt.Sprintf(`{"limit_by_header":"h%d","limit_keys":[{"key":"k","token_per_second":1}]}`, i+1))
|
||||
}
|
||||
jsonStr := fmt.Sprintf(`{"rule_name":"t","rule_items":[%s]}`, strings.Join(items, ","))
|
||||
var config AiTokenRateLimitConfig
|
||||
err := ParseAiTokenRateLimitConfig(gjson.Parse(jsonStr), &config)
|
||||
if tt.expectErr == "" {
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, config.RuleItems, tt.itemCnt)
|
||||
} else {
|
||||
assert.EqualError(t, err, tt.expectErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,6 @@ 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/proxy-wasm-go-sdk v0.0.0-20250822030947-8345453fddd0 h1:YGdj8KBzVjabU3STUfwMZghB+VlX6YLfJtLbrsWaOD0=
|
||||
github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20250822030947-8345453fddd0/go.mod h1:tRI2LfMudSkKHhyv1uex3BWzcice2s/l8Ah8axporfA=
|
||||
github.com/higress-group/wasm-go v1.0.2-0.20250821081215-b573359becf8 h1:rs+AH1wfZy4swzuAyiRXT7xPUm8gycXt9Gwy0tqOq0o=
|
||||
github.com/higress-group/wasm-go v1.0.2-0.20250821081215-b573359becf8/go.mod h1:9k7L730huS/q4V5iH9WLDgf5ZUHEtfhM/uXcegKDG/M=
|
||||
github.com/higress-group/wasm-go v1.0.6-0.20251103065747-41d65dbb2f9e h1:wYW/DXjyQniQLaB26c+J9NQk3+AhqByzS1r18NShvB4=
|
||||
github.com/higress-group/wasm-go v1.0.6-0.20251103065747-41d65dbb2f9e/go.mod h1:B8C6+OlpnyYyZUBEdUXA7tYZYD+uwZTNjfkE5FywA+A=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
|
||||
"ai-token-ratelimit/config"
|
||||
"ai-token-ratelimit/util"
|
||||
|
||||
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm"
|
||||
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm/types"
|
||||
"github.com/higress-group/wasm-go/pkg/log"
|
||||
@@ -45,55 +46,59 @@ func init() {
|
||||
|
||||
const (
|
||||
RedisKeyPrefix string = "higress-token-ratelimit"
|
||||
// AiTokenGlobalRateLimitFormat 全局限流模式 redis key 为 RedisKeyPrefix:限流规则名称:global_threshold:时间窗口
|
||||
AiTokenGlobalRateLimitFormat = RedisKeyPrefix + ":%s:global_threshold:%d"
|
||||
// AiTokenRateLimitFormat 规则限流模式 redis key 为 RedisKeyPrefix:限流规则名称:限流类型:时间窗口:限流key名称:限流key对应的实际值
|
||||
AiTokenRateLimitFormat = RedisKeyPrefix + ":%s:%s:%d:%s:%s"
|
||||
RequestPhaseFixedWindowScript = `
|
||||
local current = redis.call('get', KEYS[1])
|
||||
local ttl = redis.call('ttl', KEYS[1])
|
||||
local threshold = tonumber(ARGV[1])
|
||||
local window = tonumber(ARGV[2])
|
||||
|
||||
-- 键不存在时,返回初始状态(计数0,窗口时间为过期时间)
|
||||
if not current then
|
||||
return {threshold, 0, window}
|
||||
end
|
||||
// 使用 {rule_name} hash tag 让多规则多键操作在 Redis Cluster 下落到同一 slot
|
||||
// AiTokenGlobalRateLimitFormat 全局限流模式 redis key 为 RedisKeyPrefix:{限流规则名称}:global_threshold:时间窗口
|
||||
AiTokenGlobalRateLimitFormat = RedisKeyPrefix + ":{%s}:global_threshold:%d"
|
||||
// AiTokenRateLimitFormat 规则限流模式 redis key 为 RedisKeyPrefix:{限流规则名称}:限流类型:时间窗口:限流key名称:限流key对应的实际值
|
||||
AiTokenRateLimitFormat = RedisKeyPrefix + ":{%s}:%s:%d:%s:%s"
|
||||
// MultiKeyRequestPhaseScript 多规则请求阶段只读检查脚本
|
||||
// KEYS = [key1, ..., keyN]
|
||||
// ARGV = [threshold1, window1, threshold2, window2, ..., thresholdN, windowN]
|
||||
// 返回嵌套数组 {{threshold_i, current_i, ttl_i}, ...}
|
||||
MultiKeyRequestPhaseScript = `
|
||||
local results = {}
|
||||
for i = 1, #KEYS do
|
||||
local threshold = tonumber(ARGV[2*i - 1])
|
||||
local window = tonumber(ARGV[2*i])
|
||||
local current = redis.call('get', KEYS[i])
|
||||
local ttl = redis.call('ttl', KEYS[i])
|
||||
|
||||
-- 修复异常过期时间(确保窗口有效)
|
||||
if ttl < 0 then
|
||||
ttl = window
|
||||
-- 键不存在时,返回初始状态(计数0,窗口时间为过期时间)
|
||||
if not current then
|
||||
table.insert(results, {threshold, 0, window})
|
||||
else
|
||||
-- 修复异常过期时间(确保窗口有效)
|
||||
if ttl < 0 then
|
||||
ttl = window
|
||||
end
|
||||
-- 返回窗口状态:阈值、当前计数、剩余时间
|
||||
table.insert(results, {threshold, tonumber(current), ttl})
|
||||
end
|
||||
end
|
||||
|
||||
-- 返回窗口状态:阈值、当前计数、剩余时间
|
||||
return {threshold, tonumber(current), ttl}
|
||||
return results
|
||||
`
|
||||
// MultiKeyResponsePhaseScript 多规则响应阶段累加脚本(仅 ai-token-ratelimit 使用)
|
||||
// KEYS = [key1, ..., keyN]
|
||||
// ARGV = [threshold1, window1, count1, ..., thresholdN, windowN, countN]
|
||||
// 每条规则独立判断 current <= threshold 才累加;返回 KEYS 数量
|
||||
MultiKeyResponsePhaseScript = `
|
||||
for i = 1, #KEYS do
|
||||
local threshold = tonumber(ARGV[3*i - 2])
|
||||
local window = tonumber(ARGV[3*i - 1])
|
||||
local added = tonumber(ARGV[3*i])
|
||||
local current = tonumber(redis.call('get', KEYS[i]) or "0")
|
||||
if current <= threshold then
|
||||
current = redis.call('incrby', KEYS[i], added)
|
||||
if current == added then
|
||||
redis.call('expire', KEYS[i], window)
|
||||
else
|
||||
local ttl = redis.call('ttl', KEYS[i])
|
||||
if ttl < 0 then redis.call('expire', KEYS[i], window) end
|
||||
end
|
||||
end
|
||||
end
|
||||
return #KEYS
|
||||
`
|
||||
ResponsePhaseFixedWindowScript = `
|
||||
local key = KEYS[1]
|
||||
local threshold = tonumber(ARGV[1])
|
||||
local window = tonumber(ARGV[2])
|
||||
local added = tonumber(ARGV[3]) -- 需要累加的token数量
|
||||
|
||||
local current = tonumber(redis.call('get', key) or "0")
|
||||
|
||||
-- 只有当前计数未超过阈值时才执行累加
|
||||
if current <= threshold then
|
||||
current = redis.call('incrby', key, added)
|
||||
-- 第一次设置值时初始化过期时间
|
||||
if current == added then
|
||||
redis.call('expire', key, window)
|
||||
else
|
||||
-- 非首次设置时检查过期时间,确保窗口有效性
|
||||
local ttl = redis.call('ttl', key)
|
||||
if ttl < 0 then
|
||||
redis.call('expire', key, window)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- 返回当前窗口状态:阈值、当前计数、剩余时间
|
||||
return {threshold, current, redis.call('ttl', key)}
|
||||
`
|
||||
|
||||
LimitRedisContextKey = "LimitRedisContext"
|
||||
|
||||
@@ -110,10 +115,16 @@ type LimitContext struct {
|
||||
reset int
|
||||
}
|
||||
|
||||
// MatchedRule 表示请求阶段命中的单条限流规则(global 或 rule_item)
|
||||
type MatchedRule struct {
|
||||
key string // 完整 Redis key
|
||||
count int64 // 时间窗口内的限额(与 LimitConfigItem.Count / GlobalThreshold.Count 同义)
|
||||
window int64 // 时间窗口大小(秒)
|
||||
}
|
||||
|
||||
// LimitRedisContext 暂存请求阶段命中的全部规则,供响应阶段多键 INCRBY 使用
|
||||
type LimitRedisContext struct {
|
||||
key string
|
||||
count int64
|
||||
window int64
|
||||
rules []MatchedRule
|
||||
}
|
||||
|
||||
func parseConfig(json gjson.Result, cfg *config.AiTokenRateLimitConfig) error {
|
||||
@@ -132,59 +143,73 @@ func parseConfig(json gjson.Result, cfg *config.AiTokenRateLimitConfig) error {
|
||||
|
||||
func onHttpRequestHeaders(ctx wrapper.HttpContext, cfg config.AiTokenRateLimitConfig) types.Action {
|
||||
ctx.DisableReroute()
|
||||
limitKey, count, timeWindow := "", int64(0), int64(0)
|
||||
|
||||
if cfg.GlobalThreshold != nil {
|
||||
// 全局限流模式
|
||||
limitKey = fmt.Sprintf(AiTokenGlobalRateLimitFormat, cfg.RuleName, cfg.GlobalThreshold.TimeWindow)
|
||||
count = cfg.GlobalThreshold.Count
|
||||
timeWindow = cfg.GlobalThreshold.TimeWindow
|
||||
} else {
|
||||
// 规则限流模式
|
||||
val, ruleItem, configItem := checkRequestAgainstLimitRule(ctx, cfg.RuleItems)
|
||||
if ruleItem == nil || configItem == nil {
|
||||
// 没有匹配到限流规则直接返回
|
||||
return types.ActionContinue
|
||||
}
|
||||
|
||||
limitKey = fmt.Sprintf(AiTokenRateLimitFormat, cfg.RuleName, ruleItem.LimitType, configItem.TimeWindow, ruleItem.Key, val)
|
||||
count = configItem.Count
|
||||
timeWindow = configItem.TimeWindow
|
||||
matched := collectMatchedRules(ctx, cfg)
|
||||
if len(matched) == 0 {
|
||||
// 无任何规则命中:直接放行,不发起 Redis 调用
|
||||
log.Debugf("ai-token-ratelimit: no rule matched, path=%s host=%s", ctx.Path(), ctx.Host())
|
||||
return types.ActionContinue
|
||||
}
|
||||
|
||||
ctx.SetContext(LimitRedisContextKey, LimitRedisContext{
|
||||
key: limitKey,
|
||||
count: count,
|
||||
window: timeWindow,
|
||||
})
|
||||
log.Debugf("ai-token-ratelimit: request phase matched %d rule(s), path=%s host=%s",
|
||||
len(matched), ctx.Path(), ctx.Host())
|
||||
for i, m := range matched {
|
||||
log.Debugf("ai-token-ratelimit: rule[%d] key=%s threshold=%d window=%ds",
|
||||
i, m.key, m.count, m.window)
|
||||
}
|
||||
|
||||
// 执行限流逻辑
|
||||
keys := []interface{}{limitKey}
|
||||
args := []interface{}{count, timeWindow}
|
||||
err := cfg.RedisClient.Eval(RequestPhaseFixedWindowScript, 1, keys, args, func(response resp.Value) {
|
||||
resultArray := response.Array()
|
||||
if len(resultArray) != 3 {
|
||||
log.Errorf("redis response parse error, response: %v", response)
|
||||
proxywasm.ResumeHttpRequest()
|
||||
n := len(matched)
|
||||
keys := make([]interface{}, n)
|
||||
args := make([]interface{}, 0, n*2)
|
||||
for i, m := range matched {
|
||||
keys[i] = m.key
|
||||
args = append(args, m.count, m.window)
|
||||
}
|
||||
|
||||
// 暂存命中规则,供响应阶段多键 INCRBY 使用
|
||||
ctx.SetContext(LimitRedisContextKey, LimitRedisContext{rules: matched})
|
||||
|
||||
err := cfg.RedisClient.Eval(MultiKeyRequestPhaseScript, n, keys, args, func(response resp.Value) {
|
||||
arr := response.Array()
|
||||
if len(arr) != n {
|
||||
log.Errorf("redis response length mismatch: got %d, want %d", len(arr), n)
|
||||
_ = proxywasm.ResumeHttpRequest()
|
||||
return
|
||||
}
|
||||
|
||||
// 获取限流结果
|
||||
threshold, current, ttl := resultArray[0].Integer(), resultArray[1].Integer(), resultArray[2].Integer()
|
||||
context := LimitContext{
|
||||
count: threshold,
|
||||
remaining: threshold - current,
|
||||
reset: ttl,
|
||||
}
|
||||
if current > threshold {
|
||||
// 触发限流
|
||||
ctx.SetUserAttribute("token_ratelimit_status", "limited")
|
||||
ctx.WriteUserAttributeToLogWithKey(wrapper.AILogKey)
|
||||
rejected(cfg, context)
|
||||
} else {
|
||||
proxywasm.ResumeHttpRequest()
|
||||
// 单次遍历:触发即 return;未触发则放行。
|
||||
// ai-token-ratelimit 不对外暴露 LimitContext(与 cluster-key-ratelimit 不同,
|
||||
// 后者通过 X-RateLimit-* 头可观测 tightest 选择),因此此处不再写入 Context。
|
||||
for i, ruleResult := range arr {
|
||||
ruleState := ruleResult.Array()
|
||||
if len(ruleState) != 3 {
|
||||
log.Errorf("redis sub-array length mismatch: got %d, want 3", len(ruleState))
|
||||
_ = proxywasm.ResumeHttpRequest()
|
||||
return
|
||||
}
|
||||
threshold, current, ttl := ruleState[0].Integer(), ruleState[1].Integer(), ruleState[2].Integer()
|
||||
log.Debugf("ai-token-ratelimit: eval rule[%d] key=%s threshold=%d current=%d ttl=%ds",
|
||||
i, matched[i].key, threshold, current, ttl)
|
||||
|
||||
if current > threshold {
|
||||
// 命中触发的第一条规则(按 collectMatchedRules 顺序,global 优先)
|
||||
log.Debugf("ai-token-ratelimit: rule[%d] key=%s triggered (current=%d > threshold=%d), rejecting with code %d",
|
||||
i, matched[i].key, current, threshold, cfg.RejectedCode)
|
||||
ctx.SetUserAttribute("token_ratelimit_status", "limited")
|
||||
_ = ctx.WriteUserAttributeToLogWithKey(wrapper.AILogKey)
|
||||
rejected(cfg, LimitContext{
|
||||
count: threshold,
|
||||
remaining: threshold - current,
|
||||
reset: ttl,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
log.Debugf("ai-token-ratelimit: all %d rule(s) within threshold, allowing request to proceed", n)
|
||||
_ = proxywasm.ResumeHttpRequest()
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
log.Errorf("redis call failed: %v", err)
|
||||
return types.ActionContinue
|
||||
@@ -194,39 +219,90 @@ func onHttpRequestHeaders(ctx wrapper.HttpContext, cfg config.AiTokenRateLimitCo
|
||||
|
||||
func onHttpStreamingBody(ctx wrapper.HttpContext, cfg config.AiTokenRateLimitConfig, data []byte, endOfStream bool) []byte {
|
||||
if usage := tokenusage.GetTokenUsage(ctx, data); usage.TotalToken > 0 {
|
||||
log.Debugf("ai-token-ratelimit: token usage detected input=%d output=%d total=%d",
|
||||
usage.InputToken, usage.OutputToken, usage.TotalToken)
|
||||
ctx.SetContext(tokenusage.CtxKeyInputToken, usage.InputToken)
|
||||
ctx.SetContext(tokenusage.CtxKeyOutputToken, usage.OutputToken)
|
||||
}
|
||||
if endOfStream {
|
||||
if ctx.GetContext(tokenusage.CtxKeyInputToken) == nil || ctx.GetContext(tokenusage.CtxKeyOutputToken) == nil {
|
||||
return data
|
||||
}
|
||||
inputToken := ctx.GetContext(tokenusage.CtxKeyInputToken).(int64)
|
||||
outputToken := ctx.GetContext(tokenusage.CtxKeyOutputToken).(int64)
|
||||
limitRedisContext, ok := ctx.GetContext(LimitRedisContextKey).(LimitRedisContext)
|
||||
if !ok {
|
||||
return data
|
||||
}
|
||||
keys := []interface{}{limitRedisContext.key}
|
||||
args := []interface{}{limitRedisContext.count, limitRedisContext.window, inputToken + outputToken}
|
||||
err := cfg.RedisClient.Eval(ResponsePhaseFixedWindowScript, 1, keys, args, nil)
|
||||
if err != nil {
|
||||
log.Errorf("redis call failed: %v", err)
|
||||
}
|
||||
if !endOfStream {
|
||||
return data
|
||||
}
|
||||
|
||||
inputTokenRaw := ctx.GetContext(tokenusage.CtxKeyInputToken)
|
||||
outputTokenRaw := ctx.GetContext(tokenusage.CtxKeyOutputToken)
|
||||
if inputTokenRaw == nil || outputTokenRaw == nil {
|
||||
log.Debugf("ai-token-ratelimit: response phase end-of-stream reached but no token usage recorded, skipping")
|
||||
return data
|
||||
}
|
||||
inputToken, ok1 := inputTokenRaw.(int64)
|
||||
outputToken, ok2 := outputTokenRaw.(int64)
|
||||
if !ok1 || !ok2 {
|
||||
log.Errorf("ai-token-ratelimit: response phase token usage context has unexpected types: input=%T output=%T",
|
||||
inputTokenRaw, outputTokenRaw)
|
||||
return data
|
||||
}
|
||||
|
||||
limitRedisContextRaw := ctx.GetContext(LimitRedisContextKey)
|
||||
if limitRedisContextRaw == nil {
|
||||
log.Debugf("ai-token-ratelimit: response phase reached with no LimitRedisContext, skipping accumulation")
|
||||
return data
|
||||
}
|
||||
limitRedisContext, ok := limitRedisContextRaw.(LimitRedisContext)
|
||||
if !ok || len(limitRedisContext.rules) == 0 {
|
||||
log.Debugf("ai-token-ratelimit: response phase LimitRedisContext is empty, skipping accumulation")
|
||||
return data
|
||||
}
|
||||
|
||||
// 多键 INCRBY:每条规则一组 (threshold, window, added)
|
||||
n := len(limitRedisContext.rules)
|
||||
keys := make([]interface{}, n)
|
||||
args := make([]interface{}, 0, n*3)
|
||||
added := inputToken + outputToken
|
||||
log.Debugf("ai-token-ratelimit: response phase accumulating tokens input=%d output=%d total=%d across %d rule(s)",
|
||||
inputToken, outputToken, added, n)
|
||||
for i, r := range limitRedisContext.rules {
|
||||
keys[i] = r.key
|
||||
args = append(args, r.count, r.window, added)
|
||||
log.Debugf("ai-token-ratelimit: rule[%d] key=%s threshold=%d window=%ds added=%d",
|
||||
i, r.key, r.count, r.window, added)
|
||||
}
|
||||
|
||||
err := cfg.RedisClient.Eval(MultiKeyResponsePhaseScript, n, keys, args, func(response resp.Value) {
|
||||
incremented := response.Integer()
|
||||
log.Debugf("ai-token-ratelimit: response phase INCRBY done, %d/%d rule(s) updated", incremented, n)
|
||||
})
|
||||
if err != nil {
|
||||
log.Errorf("redis call failed: %v", err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func checkRequestAgainstLimitRule(ctx wrapper.HttpContext, ruleItems []config.LimitRuleItem) (string, *config.LimitRuleItem, *config.LimitConfigItem) {
|
||||
if len(ruleItems) > 0 {
|
||||
for _, rule := range ruleItems {
|
||||
val, ruleItem, configItem := hitRateRuleItem(ctx, rule)
|
||||
if ruleItem != nil && configItem != nil {
|
||||
return val, ruleItem, configItem
|
||||
}
|
||||
// collectMatchedRules 遍历 global_threshold 和 rule_items,返回所有命中规则。
|
||||
// 顺序:global_threshold(如有)→ rule_items 中所有命中项(按数组顺序追加)。
|
||||
// 该顺序决定了"触发时优先报告哪条规则"以及"未触发时 tightest 的选择范围"。
|
||||
func collectMatchedRules(ctx wrapper.HttpContext, cfg config.AiTokenRateLimitConfig) []MatchedRule {
|
||||
var matched []MatchedRule
|
||||
|
||||
if cfg.GlobalThreshold != nil {
|
||||
matched = append(matched, MatchedRule{
|
||||
key: fmt.Sprintf(AiTokenGlobalRateLimitFormat, cfg.RuleName, cfg.GlobalThreshold.TimeWindow),
|
||||
count: cfg.GlobalThreshold.Count,
|
||||
window: cfg.GlobalThreshold.TimeWindow,
|
||||
})
|
||||
}
|
||||
|
||||
for _, ruleItem := range cfg.RuleItems {
|
||||
val, hitRule, hitItem := hitRateRuleItem(ctx, ruleItem)
|
||||
if hitRule != nil && hitItem != nil {
|
||||
matched = append(matched, MatchedRule{
|
||||
key: fmt.Sprintf(AiTokenRateLimitFormat, cfg.RuleName, hitRule.LimitType, hitItem.TimeWindow, hitRule.Key, val),
|
||||
count: hitItem.Count,
|
||||
window: hitItem.TimeWindow,
|
||||
})
|
||||
}
|
||||
}
|
||||
return "", nil, nil
|
||||
|
||||
return matched
|
||||
}
|
||||
|
||||
func hitRateRuleItem(ctx wrapper.HttpContext, rule config.LimitRuleItem) (string, *config.LimitRuleItem, *config.LimitConfigItem) {
|
||||
|
||||
@@ -16,11 +16,14 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm/types"
|
||||
"github.com/higress-group/wasm-go/pkg/test"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/resp"
|
||||
)
|
||||
|
||||
// 测试配置:全局限流配置
|
||||
@@ -191,6 +194,58 @@ var regexpLimitConfig = func() json.RawMessage {
|
||||
return data
|
||||
}()
|
||||
|
||||
// 测试配置:混合限流(global_threshold + rule_items 同时配置)
|
||||
var hybridLimitConfig = func() json.RawMessage {
|
||||
data, _ := json.Marshal(map[string]interface{}{
|
||||
"rule_name": "ai-token-hybrid",
|
||||
"global_threshold": map[string]interface{}{
|
||||
"token_per_minute": 10000,
|
||||
},
|
||||
"rule_items": []map[string]interface{}{
|
||||
{
|
||||
"limit_by_header": "x-api-key",
|
||||
"limit_keys": []map[string]interface{}{
|
||||
{
|
||||
"key": "vip-key",
|
||||
"token_per_minute": 100,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"redis": map[string]interface{}{
|
||||
"service_name": "redis.static",
|
||||
"service_port": 6379,
|
||||
},
|
||||
})
|
||||
return data
|
||||
}()
|
||||
|
||||
// 测试配置:多条 rule_items 同时命中
|
||||
var multiRuleItemsConfig = func() json.RawMessage {
|
||||
data, _ := json.Marshal(map[string]interface{}{
|
||||
"rule_name": "ai-token-multi-items",
|
||||
"rule_items": []map[string]interface{}{
|
||||
{
|
||||
"limit_by_header": "x-api-key",
|
||||
"limit_keys": []map[string]interface{}{
|
||||
{"key": "k1", "token_per_minute": 100},
|
||||
},
|
||||
},
|
||||
{
|
||||
"limit_by_param": "apikey",
|
||||
"limit_keys": []map[string]interface{}{
|
||||
{"key": "k2", "token_per_minute": 50},
|
||||
},
|
||||
},
|
||||
},
|
||||
"redis": map[string]interface{}{
|
||||
"service_name": "redis.static",
|
||||
"service_port": 6379,
|
||||
},
|
||||
})
|
||||
return data
|
||||
}()
|
||||
|
||||
func TestParseConfig(t *testing.T) {
|
||||
test.RunGoTest(t, func(t *testing.T) {
|
||||
// 测试全局限流配置解析
|
||||
@@ -291,8 +346,8 @@ func TestOnHttpRequestHeaders(t *testing.T) {
|
||||
require.Equal(t, types.HeaderStopAllIterationAndWatermark, action)
|
||||
|
||||
// 模拟 Redis 调用响应(允许请求)
|
||||
// 返回 [threshold, current, ttl] 格式
|
||||
resp := test.CreateRedisRespArray([]interface{}{1000, 1, 60})
|
||||
// 返回 [[threshold, current, ttl]] 嵌套格式
|
||||
resp := multiRuleResp([3]int{1000, 1, 60})
|
||||
host.CallOnRedisCall(0, resp)
|
||||
|
||||
host.CompleteHttp()
|
||||
@@ -316,7 +371,7 @@ func TestOnHttpRequestHeaders(t *testing.T) {
|
||||
require.Equal(t, types.HeaderStopAllIterationAndWatermark, action)
|
||||
|
||||
// 模拟 Redis 调用响应(允许请求)
|
||||
resp := test.CreateRedisRespArray([]interface{}{100, 1, 60})
|
||||
resp := multiRuleResp([3]int{100, 1, 60})
|
||||
host.CallOnRedisCall(0, resp)
|
||||
|
||||
host.CompleteHttp()
|
||||
@@ -339,7 +394,7 @@ func TestOnHttpRequestHeaders(t *testing.T) {
|
||||
require.Equal(t, types.HeaderStopAllIterationAndWatermark, action)
|
||||
|
||||
// 模拟 Redis 调用响应(允许请求)
|
||||
resp := test.CreateRedisRespArray([]interface{}{50, 1, 60})
|
||||
resp := multiRuleResp([3]int{50, 1, 60})
|
||||
host.CallOnRedisCall(0, resp)
|
||||
|
||||
host.CompleteHttp()
|
||||
@@ -363,7 +418,7 @@ func TestOnHttpRequestHeaders(t *testing.T) {
|
||||
require.Equal(t, types.HeaderStopAllIterationAndWatermark, action)
|
||||
|
||||
// 模拟 Redis 调用响应(允许请求)
|
||||
resp := test.CreateRedisRespArray([]interface{}{200, 1, 60})
|
||||
resp := multiRuleResp([3]int{200, 1, 60})
|
||||
host.CallOnRedisCall(0, resp)
|
||||
|
||||
host.CompleteHttp()
|
||||
@@ -387,7 +442,7 @@ func TestOnHttpRequestHeaders(t *testing.T) {
|
||||
require.Equal(t, types.HeaderStopAllIterationAndWatermark, action)
|
||||
|
||||
// 模拟 Redis 调用响应(允许请求)
|
||||
resp := test.CreateRedisRespArray([]interface{}{75, 1, 60})
|
||||
resp := multiRuleResp([3]int{75, 1, 60})
|
||||
host.CallOnRedisCall(0, resp)
|
||||
|
||||
host.CompleteHttp()
|
||||
@@ -410,8 +465,8 @@ func TestOnHttpRequestHeaders(t *testing.T) {
|
||||
require.Equal(t, types.HeaderStopAllIterationAndWatermark, action)
|
||||
|
||||
// 模拟 Redis 调用响应(触发限流)
|
||||
// 返回 [threshold, current, ttl] 格式,current > threshold 表示触发限流
|
||||
resp := test.CreateRedisRespArray([]interface{}{1000, 1001, 60})
|
||||
// 返回 [[threshold, current, ttl]] 嵌套格式,current > threshold 表示触发限流
|
||||
resp := multiRuleResp([3]int{1000, 1001, 60})
|
||||
host.CallOnRedisCall(0, resp)
|
||||
|
||||
// 检查是否发送了限流响应
|
||||
@@ -440,6 +495,213 @@ func TestOnHttpRequestHeaders(t *testing.T) {
|
||||
// 应该返回 ActionContinue,因为没有匹配到限流规则
|
||||
require.Equal(t, types.ActionContinue, action)
|
||||
})
|
||||
|
||||
// 混合限流:global + rule_item 同时命中,返回 2-key Eval 响应
|
||||
t.Run("hybrid limit both match", func(t *testing.T) {
|
||||
host, status := test.NewTestHost(hybridLimitConfig)
|
||||
defer host.Reset()
|
||||
require.Equal(t, types.OnPluginStartStatusOK, status)
|
||||
|
||||
action := host.CallOnHttpRequestHeaders([][2]string{
|
||||
{":authority", "example.com"},
|
||||
{":path", "/api/test"},
|
||||
{":method", "POST"},
|
||||
{"x-api-key", "vip-key"},
|
||||
})
|
||||
require.Equal(t, types.HeaderStopAllIterationAndWatermark, action)
|
||||
|
||||
resp := multiRuleResp(
|
||||
[3]int{10000, 1, 60},
|
||||
[3]int{100, 1, 60},
|
||||
)
|
||||
host.CallOnRedisCall(0, resp)
|
||||
|
||||
host.CompleteHttp()
|
||||
})
|
||||
|
||||
// 多条 rule_items 同时命中
|
||||
t.Run("multi rule_items all match", func(t *testing.T) {
|
||||
host, status := test.NewTestHost(multiRuleItemsConfig)
|
||||
defer host.Reset()
|
||||
require.Equal(t, types.OnPluginStartStatusOK, status)
|
||||
|
||||
action := host.CallOnHttpRequestHeaders([][2]string{
|
||||
{":authority", "example.com"},
|
||||
{":path", "/api/test?apikey=k2"},
|
||||
{":method", "POST"},
|
||||
{"x-api-key", "k1"},
|
||||
})
|
||||
require.Equal(t, types.HeaderStopAllIterationAndWatermark, action)
|
||||
|
||||
resp := multiRuleResp(
|
||||
[3]int{100, 1, 60},
|
||||
[3]int{50, 1, 60},
|
||||
)
|
||||
host.CallOnRedisCall(0, resp)
|
||||
|
||||
host.CompleteHttp()
|
||||
})
|
||||
|
||||
// 混合限流:global 触发(current > threshold),rule_item 未触发
|
||||
t.Run("hybrid limit global triggered", func(t *testing.T) {
|
||||
host, status := test.NewTestHost(hybridLimitConfig)
|
||||
defer host.Reset()
|
||||
require.Equal(t, types.OnPluginStartStatusOK, status)
|
||||
|
||||
action := host.CallOnHttpRequestHeaders([][2]string{
|
||||
{":authority", "example.com"},
|
||||
{":path", "/api/test"},
|
||||
{":method", "POST"},
|
||||
{"x-api-key", "vip-key"},
|
||||
})
|
||||
require.Equal(t, types.HeaderStopAllIterationAndWatermark, action)
|
||||
|
||||
resp := multiRuleResp(
|
||||
[3]int{10000, 10001, 60},
|
||||
[3]int{100, 1, 60},
|
||||
)
|
||||
host.CallOnRedisCall(0, resp)
|
||||
|
||||
localResponse := host.GetLocalResponse()
|
||||
require.NotNil(t, localResponse)
|
||||
require.Equal(t, uint32(429), localResponse.StatusCode)
|
||||
|
||||
host.CompleteHttp()
|
||||
})
|
||||
|
||||
// Redis 响应数组长度与规则数不匹配
|
||||
t.Run("redis response length mismatch", func(t *testing.T) {
|
||||
host, status := test.NewTestHost(hybridLimitConfig)
|
||||
defer host.Reset()
|
||||
require.Equal(t, types.OnPluginStartStatusOK, status)
|
||||
|
||||
action := host.CallOnHttpRequestHeaders([][2]string{
|
||||
{":authority", "example.com"},
|
||||
{":path", "/api/test"},
|
||||
{":method", "POST"},
|
||||
{"x-api-key", "vip-key"},
|
||||
})
|
||||
require.Equal(t, types.HeaderStopAllIterationAndWatermark, action)
|
||||
|
||||
resp := multiRuleResp([3]int{10000, 1, 60})
|
||||
host.CallOnRedisCall(0, resp)
|
||||
|
||||
require.Nil(t, host.GetLocalResponse())
|
||||
|
||||
host.CompleteHttp()
|
||||
})
|
||||
|
||||
// Redis 子数组长度异常(少于 3)
|
||||
t.Run("redis sub-array length mismatch", func(t *testing.T) {
|
||||
host, status := test.NewTestHost(hybridLimitConfig)
|
||||
defer host.Reset()
|
||||
require.Equal(t, types.OnPluginStartStatusOK, status)
|
||||
|
||||
action := host.CallOnHttpRequestHeaders([][2]string{
|
||||
{":authority", "example.com"},
|
||||
{":path", "/api/test"},
|
||||
{":method", "POST"},
|
||||
{"x-api-key", "vip-key"},
|
||||
})
|
||||
require.Equal(t, types.HeaderStopAllIterationAndWatermark, action)
|
||||
|
||||
badResp, err := resp.ArrayValue([]resp.Value{
|
||||
resp.ArrayValue([]resp.Value{
|
||||
resp.IntegerValue(10000),
|
||||
resp.IntegerValue(1),
|
||||
}),
|
||||
}).MarshalRESP()
|
||||
require.NoError(t, err)
|
||||
host.CallOnRedisCall(0, badResp)
|
||||
|
||||
require.Nil(t, host.GetLocalResponse())
|
||||
|
||||
host.CompleteHttp()
|
||||
})
|
||||
|
||||
// 混合配置:global 命中,rule_items 不命中(请求头不含 x-api-key)
|
||||
t.Run("hybrid limit rule_items no match", func(t *testing.T) {
|
||||
host, status := test.NewTestHost(hybridLimitConfig)
|
||||
defer host.Reset()
|
||||
require.Equal(t, types.OnPluginStartStatusOK, status)
|
||||
|
||||
action := host.CallOnHttpRequestHeaders([][2]string{
|
||||
{":authority", "example.com"},
|
||||
{":path", "/api/test"},
|
||||
{":method", "POST"},
|
||||
})
|
||||
require.Equal(t, types.HeaderStopAllIterationAndWatermark, action)
|
||||
|
||||
resp := multiRuleResp([3]int{10000, 1, 60})
|
||||
host.CallOnRedisCall(0, resp)
|
||||
|
||||
require.Nil(t, host.GetLocalResponse())
|
||||
|
||||
host.CompleteHttp()
|
||||
})
|
||||
|
||||
// 多规则同时触发,验证 rejected 报告 global(reset=60 而非 rule_item 的 30)
|
||||
t.Run("hybrid limit both triggered reports global first", func(t *testing.T) {
|
||||
host, status := test.NewTestHost(hybridLimitConfig)
|
||||
defer host.Reset()
|
||||
require.Equal(t, types.OnPluginStartStatusOK, status)
|
||||
|
||||
action := host.CallOnHttpRequestHeaders([][2]string{
|
||||
{":authority", "example.com"},
|
||||
{":path", "/api/test"},
|
||||
{":method", "POST"},
|
||||
{"x-api-key", "vip-key"},
|
||||
})
|
||||
require.Equal(t, types.HeaderStopAllIterationAndWatermark, action)
|
||||
|
||||
resp := multiRuleResp(
|
||||
[3]int{10000, 10001, 60},
|
||||
[3]int{100, 101, 30},
|
||||
)
|
||||
host.CallOnRedisCall(0, resp)
|
||||
|
||||
localResponse := host.GetLocalResponse()
|
||||
require.NotNil(t, localResponse)
|
||||
require.Equal(t, uint32(429), localResponse.StatusCode)
|
||||
|
||||
// LocalHttpResponse.Headers 类型为 [][2]string,按切片迭代查找目标头
|
||||
var resetHeader string
|
||||
for _, h := range localResponse.Headers {
|
||||
if strings.EqualFold(h[0], RateLimitResetHeader) && h[1] != "" {
|
||||
resetHeader = h[1]
|
||||
break
|
||||
}
|
||||
}
|
||||
require.Equal(t, "60", resetHeader, "应报告 global 规则的 reset 时间")
|
||||
|
||||
host.CompleteHttp()
|
||||
})
|
||||
|
||||
// 多规则未触发:ai-token-ratelimit 不对外暴露 tightest 选择(无 X-RateLimit-* 头),
|
||||
// 此处仅验证不触发拒绝;tightest 行为由 cluster-key-ratelimit 的测试覆盖。
|
||||
t.Run("multi-rule no trigger does not reject", func(t *testing.T) {
|
||||
host, status := test.NewTestHost(hybridLimitConfig)
|
||||
defer host.Reset()
|
||||
require.Equal(t, types.OnPluginStartStatusOK, status)
|
||||
|
||||
action := host.CallOnHttpRequestHeaders([][2]string{
|
||||
{":authority", "example.com"},
|
||||
{":path", "/api/test"},
|
||||
{":method", "POST"},
|
||||
{"x-api-key", "vip-key"},
|
||||
})
|
||||
require.Equal(t, types.HeaderStopAllIterationAndWatermark, action)
|
||||
|
||||
resp := multiRuleResp(
|
||||
[3]int{10000, 9000, 60},
|
||||
[3]int{100, 10, 60},
|
||||
)
|
||||
host.CallOnRedisCall(0, resp)
|
||||
|
||||
require.Nil(t, host.GetLocalResponse())
|
||||
|
||||
host.CompleteHttp()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -459,7 +721,7 @@ func TestOnHttpStreamingBody(t *testing.T) {
|
||||
})
|
||||
|
||||
// 模拟 Redis 调用响应
|
||||
resp := test.CreateRedisRespArray([]interface{}{1000, 1, 60})
|
||||
resp := multiRuleResp([3]int{1000, 1, 60})
|
||||
host.CallOnRedisCall(0, resp)
|
||||
|
||||
// 处理流式响应体
|
||||
@@ -499,7 +761,7 @@ func TestOnHttpStreamingBody(t *testing.T) {
|
||||
})
|
||||
|
||||
// 模拟 Redis 调用响应
|
||||
resp := test.CreateRedisRespArray([]interface{}{1000, 1, 60})
|
||||
resp := multiRuleResp([3]int{1000, 1, 60})
|
||||
host.CallOnRedisCall(0, resp)
|
||||
|
||||
// 处理流式响应体
|
||||
@@ -514,6 +776,72 @@ func TestOnHttpStreamingBody(t *testing.T) {
|
||||
|
||||
host.CompleteHttp()
|
||||
})
|
||||
|
||||
// 多规则下响应阶段应触发 2-key INCRBY。
|
||||
// Strengthened: 用 GetRedisCalloutAttributes() 断言响应阶段确实发起了一次 Eval。
|
||||
// Callout 计数模式:0 → 1 (请求阶段) → 0 (CallOnRedisCall 消费) → 1 (响应阶段)
|
||||
// 如果 response 阶段被改回 1-key 或不调用 Redis,最终计数会停在 0 而非 1。
|
||||
t.Run("streaming body multi-rule incrby", func(t *testing.T) {
|
||||
host, status := test.NewTestHost(hybridLimitConfig)
|
||||
defer host.Reset()
|
||||
require.Equal(t, types.OnPluginStartStatusOK, status)
|
||||
|
||||
require.Equal(t, 0, len(host.GetRedisCalloutAttributes()),
|
||||
"请求前应无 Redis 调用")
|
||||
|
||||
action := host.CallOnHttpRequestHeaders([][2]string{
|
||||
{":authority", "example.com"},
|
||||
{":path", "/api/test"},
|
||||
{":method", "POST"},
|
||||
{"x-api-key", "vip-key"},
|
||||
})
|
||||
require.Equal(t, types.HeaderStopAllIterationAndWatermark, action)
|
||||
require.Equal(t, 1, len(host.GetRedisCalloutAttributes()),
|
||||
"请求阶段应发起 1 次多键 Eval")
|
||||
|
||||
resp := multiRuleResp(
|
||||
[3]int{10000, 1, 60},
|
||||
[3]int{100, 1, 60},
|
||||
)
|
||||
host.CallOnRedisCall(0, resp)
|
||||
|
||||
body := []byte(`{"choices":[{"message":{"content":"hi"}}],"usage":{"prompt_tokens":10,"completion_tokens":15,"total_tokens":25}}`)
|
||||
// 注意:ai-token 的 onHttpStreamingBody 注册为 ProcessStreamingResponseBody,
|
||||
// 对应测试方法 CallOnHttpStreamingResponseBody(不是 CallOnHttpStreamingRequestBody)。
|
||||
// 这是因为 ai-token 处理的是 LLM 的流式响应体(usage 字段在响应里)。
|
||||
streamAction := host.CallOnHttpStreamingResponseBody(body, true)
|
||||
require.Equal(t, types.ActionContinue, streamAction)
|
||||
// 响应阶段应再发起 1 次多键 INCRBY(callout 数 0 → 1)。
|
||||
// 如果 response 阶段被改回 1-key 或不调用 Redis,此断言会失败。
|
||||
require.Equal(t, 1, len(host.GetRedisCalloutAttributes()),
|
||||
"响应阶段应再发起 1 次多键 INCRBY(callout 数 0 → 1)")
|
||||
|
||||
host.CompleteHttp()
|
||||
})
|
||||
|
||||
// 流式响应体处理时无前置 context(覆盖 line 216-225, 227-234):
|
||||
// 使用 headerLimitConfig(仅 x-api-key 规则),ensureContextInitialized 自动触发的
|
||||
// onHttpRequestHeaders 因为缺少 x-api-key 头而 matched 为空,返回 ActionContinue,
|
||||
// 此时 token context / LimitRedisContext 均未被设置。后续 streaming body 应通过
|
||||
// GetContext 判空分支安全返回 data,不 panic、不调用 Redis。
|
||||
t.Run("streaming body without prior context", func(t *testing.T) {
|
||||
host, status := test.NewTestHost(headerLimitConfig)
|
||||
defer host.Reset()
|
||||
require.Equal(t, types.OnPluginStartStatusOK, status)
|
||||
|
||||
// 直接处理流式响应体(缺少 x-api-key,matched 为空,SetContext 未调用)
|
||||
body := []byte(`{"choices":[{"message":{"content":"hi"}}],"usage":{"prompt_tokens":10,"completion_tokens":15,"total_tokens":25}}`)
|
||||
streamAction := host.CallOnHttpStreamingResponseBody(body, true)
|
||||
|
||||
// 应安全返回 ActionContinue
|
||||
require.Equal(t, types.ActionContinue, streamAction)
|
||||
|
||||
// 不应触发任何 Redis callout(前置 context 缺失时跳过响应阶段)
|
||||
require.Equal(t, 0, len(host.GetRedisCalloutAttributes()),
|
||||
"missing token context should skip response-phase INCRBY")
|
||||
|
||||
host.CompleteHttp()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -537,7 +865,7 @@ func TestCompleteFlow(t *testing.T) {
|
||||
require.Equal(t, types.HeaderStopAllIterationAndWatermark, action)
|
||||
|
||||
// 2. 模拟 Redis 调用响应
|
||||
resp := test.CreateRedisRespArray([]interface{}{100, 1, 60})
|
||||
resp := multiRuleResp([3]int{100, 1, 60})
|
||||
host.CallOnRedisCall(0, resp)
|
||||
|
||||
// 3. 处理流式响应体
|
||||
@@ -555,3 +883,24 @@ func TestCompleteFlow(t *testing.T) {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// multiRuleResp 构建多规则嵌套 Redis 响应(RESP wire format)。
|
||||
// test.CreateRedisRespArray 不支持嵌套数组,因此直接用 resp.Writer 构造。
|
||||
// 每个 [3]int 元组为 {threshold, current, ttl}。
|
||||
// 注意:不能使用 resp.Value.Bytes()——它返回的是显示用字符串,不是 RESP wire 格式。
|
||||
// 必须用 resp.Writer.WriteArray 或 Value.MarshalRESP() 输出真正的 RESP 字节流。
|
||||
func multiRuleResp(items ...[3]int) []byte {
|
||||
values := make([]resp.Value, len(items))
|
||||
for i, it := range items {
|
||||
values[i] = resp.ArrayValue([]resp.Value{
|
||||
resp.IntegerValue(it[0]),
|
||||
resp.IntegerValue(it[1]),
|
||||
resp.IntegerValue(it[2]),
|
||||
})
|
||||
}
|
||||
b, err := resp.ArrayValue(values).MarshalRESP()
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("failed to marshal multiRuleResp: %v", err))
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
@@ -53,8 +53,8 @@ func ExtractCookieValueByKey(cookie string, key string) (value string) {
|
||||
pairs := strings.Split(cookie, ";")
|
||||
for _, pair := range pairs {
|
||||
pair = strings.TrimSpace(pair)
|
||||
kv := strings.Split(pair, "=")
|
||||
if kv[0] == key {
|
||||
kv := strings.SplitN(pair, "=", 2)
|
||||
if len(kv) == 2 && kv[0] == key {
|
||||
value = kv[1]
|
||||
break
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package util
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestExtractCookieValueByKey(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cookie string
|
||||
key string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "extracts matching cookie value",
|
||||
cookie: "user=alice; other=value",
|
||||
key: "user",
|
||||
want: "alice",
|
||||
},
|
||||
{
|
||||
name: "skips segment without equals sign",
|
||||
cookie: "user; other=value",
|
||||
key: "user",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "keeps equals signs in cookie value",
|
||||
cookie: "user=alice=admin; other=value",
|
||||
key: "user",
|
||||
want: "alice=admin",
|
||||
},
|
||||
{
|
||||
name: "empty cookie returns empty",
|
||||
cookie: "",
|
||||
key: "user",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "key not present returns empty",
|
||||
cookie: "user=alice; other=value",
|
||||
key: "missing",
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := ExtractCookieValueByKey(tt.cookie, tt.key); got != tt.want {
|
||||
t.Fatalf("ExtractCookieValueByKey() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
1
plugins/wasm-go/extensions/basic-auth/.buildrc
Normal file
1
plugins/wasm-go/extensions/basic-auth/.buildrc
Normal file
@@ -0,0 +1 @@
|
||||
IMAGE_NAME=go-basic-auth
|
||||
1
plugins/wasm-go/extensions/bot-detect/.buildrc
Normal file
1
plugins/wasm-go/extensions/bot-detect/.buildrc
Normal file
@@ -0,0 +1 @@
|
||||
IMAGE_NAME=go-bot-detect
|
||||
@@ -1 +1 @@
|
||||
1.0.0
|
||||
1.0.0-alpha
|
||||
@@ -74,7 +74,7 @@ func onHttpResponseHeaders(ctx wrapper.HttpContext, config CacheControlConfig, l
|
||||
if hit {
|
||||
if config.expires == "max" {
|
||||
proxywasm.AddHttpResponseHeader("Expires", "Thu, 31 Dec 2037 23:55:55 GMT")
|
||||
proxywasm.AddHttpResponseHeader("Cache-Control", "maxAge=315360000")
|
||||
proxywasm.AddHttpResponseHeader("Cache-Control", "max-age=315360000")
|
||||
} else if config.expires == "epoch" {
|
||||
proxywasm.AddHttpResponseHeader("Expires", "Thu, 01 Jan 1970 00:00:01 GMT")
|
||||
proxywasm.AddHttpResponseHeader("Cache-Control", "no-cache")
|
||||
@@ -83,7 +83,7 @@ func onHttpResponseHeaders(ctx wrapper.HttpContext, config CacheControlConfig, l
|
||||
currentTime := time.Now()
|
||||
expireTime := currentTime.Add(time.Duration(maxAge) * time.Second)
|
||||
proxywasm.AddHttpResponseHeader("Expires", expireTime.UTC().Format(http.TimeFormat))
|
||||
proxywasm.AddHttpResponseHeader("Cache-Control", "maxAge="+strconv.FormatInt(maxAge, 10))
|
||||
proxywasm.AddHttpResponseHeader("Cache-Control", "max-age="+strconv.FormatInt(maxAge, 10))
|
||||
}
|
||||
}
|
||||
return types.ActionContinue
|
||||
|
||||
@@ -234,7 +234,7 @@ func TestOnHttpResponseHeaders(t *testing.T) {
|
||||
// 验证是否添加了缓存控制头
|
||||
responseHeaders := host.GetResponseHeaders()
|
||||
require.True(t, test.HasHeader(responseHeaders, "expires"))
|
||||
require.True(t, test.HasHeaderWithValue(responseHeaders, "cache-control", "maxAge=3600"))
|
||||
require.True(t, test.HasHeaderWithValue(responseHeaders, "cache-control", "max-age=3600"))
|
||||
|
||||
host.CompleteHttp()
|
||||
})
|
||||
@@ -264,7 +264,7 @@ func TestOnHttpResponseHeaders(t *testing.T) {
|
||||
// 验证是否添加了缓存控制头
|
||||
responseHeaders := host.GetResponseHeaders()
|
||||
require.True(t, test.HasHeader(responseHeaders, "expires"))
|
||||
require.True(t, test.HasHeaderWithValue(responseHeaders, "cache-control", "maxAge=315360000"))
|
||||
require.True(t, test.HasHeaderWithValue(responseHeaders, "cache-control", "max-age=315360000"))
|
||||
|
||||
host.CompleteHttp()
|
||||
})
|
||||
@@ -354,7 +354,7 @@ func TestOnHttpResponseHeaders(t *testing.T) {
|
||||
// 验证是否添加了缓存控制头
|
||||
responseHeaders := host.GetResponseHeaders()
|
||||
require.True(t, test.HasHeader(responseHeaders, "expires"))
|
||||
require.True(t, test.HasHeaderWithValue(responseHeaders, "cache-control", "maxAge=7200"))
|
||||
require.True(t, test.HasHeaderWithValue(responseHeaders, "cache-control", "max-age=7200"))
|
||||
|
||||
host.CompleteHttp()
|
||||
})
|
||||
@@ -384,7 +384,7 @@ func TestOnHttpResponseHeaders(t *testing.T) {
|
||||
// 验证是否添加了缓存控制头
|
||||
responseHeaders := host.GetResponseHeaders()
|
||||
require.True(t, test.HasHeader(responseHeaders, "expires"))
|
||||
require.True(t, test.HasHeaderWithValue(responseHeaders, "cache-control", "maxAge=1800"))
|
||||
require.True(t, test.HasHeaderWithValue(responseHeaders, "cache-control", "max-age=1800"))
|
||||
|
||||
host.CompleteHttp()
|
||||
})
|
||||
@@ -414,7 +414,7 @@ func TestOnHttpResponseHeaders(t *testing.T) {
|
||||
// 验证是否添加了缓存控制头
|
||||
responseHeaders := host.GetResponseHeaders()
|
||||
require.True(t, test.HasHeader(responseHeaders, "expires"))
|
||||
require.True(t, test.HasHeaderWithValue(responseHeaders, "cache-control", "maxAge=3600"))
|
||||
require.True(t, test.HasHeaderWithValue(responseHeaders, "cache-control", "max-age=3600"))
|
||||
|
||||
host.CompleteHttp()
|
||||
})
|
||||
@@ -452,7 +452,7 @@ func TestCompleteFlow(t *testing.T) {
|
||||
|
||||
// 验证是否添加了必要的缓存控制响应头
|
||||
require.True(t, test.HasHeader(responseHeaders, "expires"))
|
||||
require.True(t, test.HasHeaderWithValue(responseHeaders, "cache-control", "maxAge=3600"))
|
||||
require.True(t, test.HasHeaderWithValue(responseHeaders, "cache-control", "max-age=3600"))
|
||||
|
||||
host.CompleteHttp()
|
||||
})
|
||||
|
||||
@@ -4,14 +4,25 @@ keywords: [higress, rate-limit]
|
||||
description: 基于 Key 集群限流插件配置参考
|
||||
---
|
||||
|
||||
> ⚠️ **行为变更提示(无版本号变化)**
|
||||
>
|
||||
> 自本次更新起,`rule_items` 的匹配语义从 **first-match-wins**(命中第一条即返回)改为 **all-match OR 叠加**(所有命中规则都评估,任一触发即拒绝)。同时解除 `global_threshold` 与 `rule_items` 的互斥约束,支持混合配置。
|
||||
>
|
||||
> - 老配置(单条 `rule_items` 或仅 `global_threshold`):行为不变
|
||||
> - 老配置(多条 `rule_items` 期望短路匹配):**行为会变** —— 所有命中规则都会评估
|
||||
> - Redis key 格式新增 `{rule_name}` hash tag 以兼容 Redis Cluster,老计数器数据不兼容
|
||||
>
|
||||
> 详见下方"功能说明"与"配置示例"。
|
||||
|
||||
## 功能说明
|
||||
|
||||
`cluster-key-rate-limit` 插件基于 Redis 实现**集群级限流**,适用于需要跨多个 Higress Gateway 实例进行**全局一致速率限制**的场景。
|
||||
|
||||
支持两种限流模式:
|
||||
支持三种限流模式:
|
||||
|
||||
- **规则级全局限流**:基于相同的 `rule_name` 和 `global_threshold` 配置,对自定义规则组设置全局限流阈值
|
||||
- **Key 级动态限流**:根据请求中的动态 Key(如 URL 参数、请求头、客户端 IP、Consumer 名称或 Cookie 字段)进行分组限流
|
||||
- **混合限流**:同时配置 `global_threshold`(全局兜底)和 `rule_items`(按维度细分),所有命中的规则**叠加生效**,任一触发即拒绝。
|
||||
|
||||
## 运行属性
|
||||
|
||||
@@ -23,8 +34,8 @@ description: 基于 Key 集群限流插件配置参考
|
||||
| 配置项 | 类型 | 必填 | 默认值 | 说明 |
|
||||
| ----------------------- | ------ | ---- | ------ |-----------------------------------------------------------------------------|
|
||||
| rule_name | string | 是 | - | 限流规则名称,根据限流规则名称 + 限流类型 + 限流 key 名称 + 限流 key 对应的实际值来拼装 redis key |
|
||||
| global_threshold | Object | 否,`global_threshold` 或 `rule_items` 选填一项 | - | 对整个自定义规则组进行限流 |
|
||||
| rule_items | array of object | 否,`global_threshold` 或 `rule_items` 选填一项 | - | 限流规则项,按照 rule_items 下的排列顺序,匹配第一个 rule_item 后命中限流规则,后续规则将被忽略 |
|
||||
| global_threshold | Object | 否,至少一项;可同时配置 | - | 对整个自定义规则组进行限流 |
|
||||
| rule_items | array of object | 否,至少一项;可同时配置 | - | 限流规则项,最多支持 **10 条**。所有满足匹配条件的 `rule_item` 都会参与限流,规则之间是"或"关系,任一触发即拒绝;规则的执行顺序不影响最终结果。详见下方"配置说明"展开。 |
|
||||
| show_limit_quota_header | bool | 否 | false | 响应头中是否显示 `X-RateLimit-Limit`(限制的总请求数)和 `X-RateLimit-Remaining`(剩余还可以发送的请求数) |
|
||||
| rejected_code | int | 否 | 429 | 请求被限流时,返回的 HTTP 状态码 |
|
||||
| rejected_msg | string | 否 | Too many requests | 请求被限流时,返回的响应体 |
|
||||
@@ -54,6 +65,18 @@ description: 基于 Key 集群限流插件配置参考
|
||||
| limit_by_per_ip | string | 否,`limit_by_*` 中选填一项 | - | 按规则匹配特定 IP,并对每个 IP 分别计算限流,配置获取限流键值的来源 IP 参数名称,从请求头获取,以 `from-header-对应的header名`,示例:`from-header-x-forwarded-for`,直接获取对端 socket ip,配置为 `from-remote-addr` |
|
||||
| limit_keys | array of object | 是 | - | 配置匹配键值后的限流次数 |
|
||||
|
||||
#### `rule_items` 多规则匹配语义
|
||||
|
||||
`rule_items` 是一个数组,**所有满足匹配条件**的 `rule_item` 都会被评估,规则之间是"或"关系,**任一触发即拒绝**。规则的执行顺序不影响最终结果。
|
||||
|
||||
> `rule_items` 数组最多支持 **10 条**规则。每条 `rule_item` 会按命中的 `limit_keys` 产生独立的 Redis 计数器。
|
||||
|
||||
##### 多规则场景下的 X-RateLimit-* 头
|
||||
|
||||
当多条规则同时未触发、配置 `show_limit_quota_header: true` 时:
|
||||
- `X-RateLimit-Limit` / `X-RateLimit-Remaining`:取剩余比例最小(最紧约束)的命中规则
|
||||
- `X-RateLimit-Reset`(触发限流时返回):取第一条触发的规则(按 `rule_items` 数组顺序,全局优先)
|
||||
|
||||
`limit_keys` 中每一项的配置字段说明。
|
||||
|
||||
| 配置项 | 类型 | 必填 | 默认值 | 说明 |
|
||||
|
||||
@@ -5,15 +5,26 @@ description: Configuration reference for the Key-based cluster rate limiting plu
|
||||
|
||||
---
|
||||
|
||||
> ⚠️ **Behavior Change Notice (no version bump)**
|
||||
>
|
||||
> As of this update, `rule_items` matching semantics changed from **first-match-wins** (returns on first hit) to **all-match OR overlay** (all matched rules are evaluated, any trigger rejects). The mutual exclusion between `global_threshold` and `rule_items` is also removed to support hybrid configuration.
|
||||
>
|
||||
> - Existing config (single `rule_items` or only `global_threshold`): behavior unchanged
|
||||
> - Existing config (multiple `rule_items` expecting short-circuit match): **behavior will change** — all matched rules are evaluated
|
||||
> - Redis key format adds `{rule_name}` hash tag for Redis Cluster compatibility; old counter data is incompatible
|
||||
>
|
||||
> See "Function Description" and "Configuration Examples" below for details.
|
||||
|
||||
## Function Description
|
||||
|
||||
The `cluster-key-rate-limit` plugin implements **cluster-level rate limiting** based on Redis, suitable for scenarios
|
||||
requiring **globally consistent rate limiting across multiple Higress Gateway instances**.
|
||||
|
||||
It supports two rate limiting modes:
|
||||
It supports three rate limiting modes:
|
||||
|
||||
- **Rule-Level Global Rate Limiting**: Applies a unified rate limit threshold to custom rule groups based on identical `rule_name` and `global_threshold` configurations.
|
||||
- **Key-Level Dynamic Rate Limiting**: Groups and limits requests by dynamic keys extracted from requests, such as URL parameters, request headers, client IPs, consumer names, or cookie fields.
|
||||
- **Hybrid rate limiting**: Configure `global_threshold` (global fallback) and `rule_items` (per-dimension) simultaneously. All matched rules take effect together; any trigger rejects the request.
|
||||
|
||||
## Operational Attributes
|
||||
|
||||
@@ -25,8 +36,8 @@ It supports two rate limiting modes:
|
||||
| Configuration Item | Type | Required | Default Value | Description |
|
||||
|--------------------------|---------------|-------------------------------------------|---------------------|----------------------------------------------------------------------------|
|
||||
| rule_name | string | Yes | - | Name of the rate limiting rule. Used to construct the Redis key in the format: `rule_name:rate_limit_type:key_name:key_value`. |
|
||||
| global_threshold | Object | No (choose either `global_threshold` or `rule_items`) | - | Apply rate limiting to the entire custom rule group.|
|
||||
| rule_items | array of object | No (choose either `global_threshold` or `rule_items`) | - | Rate limiting rule items. Rules are matched in the order of the array; once the first matching rule is hit, subsequent rules are ignored. |
|
||||
| global_threshold | Object | No, at least one of them is required; can be configured simultaneously | - | Apply rate limiting to the entire custom rule group.|
|
||||
| rule_items | array of object | No, at least one of them is required; can be configured simultaneously | - | Rate limiting rule items. Supports up to **10** rules. All matched `rule_item`s are evaluated and combined with an OR relationship; any trigger rejects the request. The execution order of rules does not affect the final result. See the expanded `rule_items` notes below for details. |
|
||||
| show_limit_quota_header | bool | No | false | Whether to display `X-RateLimit-Limit` (total allowed requests) and `X-RateLimit-Remaining` (remaining allowed requests) in the response header. |
|
||||
| rejected_code | int | No | 429 | HTTP status code returned when a request is rate-limited. |
|
||||
| rejected_msg | string | No | Too many requests | Response body returned when a request is rate-limited. |
|
||||
@@ -56,6 +67,18 @@ It supports two rate limiting modes:
|
||||
| limit_by_per_ip | string | No (choose one of `limit_by_*` fields) | - | Matches specific IPs by rule and calculates rate limits for each IP. The IP can be extracted from a request header (formatted as `from-header-<header_name>`, e.g., `from-header-x-forwarded-for`) or directly from the peer socket IP (configured as `from-remote-addr`). |
|
||||
| limit_keys | array of object | Yes | - | Configures the rate limits for matched key values. |
|
||||
|
||||
#### `rule_items` Multi-Rule Matching Semantics
|
||||
|
||||
`rule_items` is an array. **All** `rule_item`s whose match conditions are satisfied are evaluated, and the rules are combined with an OR relationship: **any trigger rejects the request**. The execution order of rules does not affect the final result.
|
||||
|
||||
> The `rule_items` array supports up to **10** rules. Each `rule_item` produces independent Redis counters for each matched `limit_keys`.
|
||||
|
||||
##### X-RateLimit-* headers in multi-rule scenarios
|
||||
|
||||
When multiple rules are matched and none trigger (with `show_limit_quota_header: true`):
|
||||
- `X-RateLimit-Limit` / `X-RateLimit-Remaining`: from the matched rule with the smallest remaining ratio (tightest constraint)
|
||||
- `X-RateLimit-Reset` (returned when triggered): from the first triggered rule (in `rule_items` array order, global first)
|
||||
|
||||
### Configuration Fields for `limit_keys`
|
||||
|
||||
| Configuration Item | Type | Required | Default Value | Description |
|
||||
|
||||
@@ -47,6 +47,9 @@ const (
|
||||
SecondsPerMinute = 60 * Second
|
||||
SecondsPerHour = 60 * SecondsPerMinute
|
||||
SecondsPerDay = 24 * SecondsPerHour
|
||||
|
||||
// MaxRuleItems 限制 rule_items 数组最大长度
|
||||
MaxRuleItems = 10
|
||||
)
|
||||
|
||||
var timeWindows = map[string]int64{
|
||||
@@ -169,8 +172,6 @@ func initLimitRule(json gjson.Result, config *ClusterKeyRateLimitConfig) error {
|
||||
hasRule := ruleItemsResult.Exists()
|
||||
if !hasGlobal && !hasRule {
|
||||
return errors.New("at least one of 'global_threshold' or 'rule_items' must be set")
|
||||
} else if hasGlobal && hasRule {
|
||||
return errors.New("'global_threshold' and 'rule_items' cannot be set at the same time")
|
||||
}
|
||||
|
||||
// 处理全局限流配置
|
||||
@@ -180,14 +181,19 @@ func initLimitRule(json gjson.Result, config *ClusterKeyRateLimitConfig) error {
|
||||
return fmt.Errorf("failed to parse global_threshold: %w", err)
|
||||
}
|
||||
config.GlobalThreshold = threshold
|
||||
return nil
|
||||
}
|
||||
|
||||
// 处理条件限流规则
|
||||
if !hasRule {
|
||||
return nil
|
||||
}
|
||||
items := ruleItemsResult.Array()
|
||||
if len(items) == 0 {
|
||||
return errors.New("config rule_items cannot be empty")
|
||||
}
|
||||
if len(items) > MaxRuleItems {
|
||||
return fmt.Errorf("rule_items length %d exceeds maximum %d", len(items), MaxRuleItems)
|
||||
}
|
||||
|
||||
var ruleItems []LimitRuleItem
|
||||
// 用于记录已出现的LimitType和Key的组合
|
||||
@@ -286,7 +292,7 @@ func parseLimitRuleItem(item gjson.Result) (*LimitRuleItem, error) {
|
||||
}
|
||||
|
||||
if limitType == "" {
|
||||
return nil, errors.New("only one of 'limit_by_header' and 'limit_by_param' and 'limit_by_consumer' and 'limit_by_cookie' and 'limit_by_per_header' and 'limit_by_per_param' and 'limit_by_per_consumer' and 'limit_by_per_cookie' and 'limit_by_per_ip' can be set")
|
||||
return nil, errors.New("at least one of 'limit_by_header', 'limit_by_param', 'limit_by_consumer', 'limit_by_cookie', 'limit_by_per_header', 'limit_by_per_param', 'limit_by_per_consumer', 'limit_by_per_cookie', 'limit_by_per_ip' must be set")
|
||||
}
|
||||
ruleItem.LimitType = limitType
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -166,13 +168,41 @@ func TestParseClusterKeyRateLimitConfig(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Conflict_GlobalThresholdAndRuleItems",
|
||||
name: "Both_ShouldSucceed",
|
||||
json: `{
|
||||
"rule_name": "test-conflict",
|
||||
"rule_name": "test-both",
|
||||
"global_threshold": {"query_per_second": 100},
|
||||
"rule_items": [{"limit_by_header": "x-test"}]
|
||||
"rule_items": [
|
||||
{
|
||||
"limit_by_header": "x-test",
|
||||
"limit_keys": [{"key": "k1", "query_per_minute": 10}]
|
||||
}
|
||||
]
|
||||
}`,
|
||||
expectedErr: errors.New("'global_threshold' and 'rule_items' cannot be set at the same time"),
|
||||
expected: ClusterKeyRateLimitConfig{
|
||||
RuleName: "test-both",
|
||||
GlobalThreshold: &GlobalThreshold{
|
||||
Count: 100,
|
||||
TimeWindow: Second,
|
||||
},
|
||||
RuleItems: []LimitRuleItem{
|
||||
{
|
||||
LimitType: LimitByHeaderType,
|
||||
Key: "x-test",
|
||||
ConfigItems: []LimitConfigItem{
|
||||
{
|
||||
ConfigType: ExactType,
|
||||
Key: "k1",
|
||||
Count: 10,
|
||||
TimeWindow: SecondsPerMinute,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
ShowLimitQuotaHeader: false,
|
||||
RejectedCode: DefaultRejectedCode,
|
||||
RejectedMsg: DefaultRejectedMsg,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Missing_GlobalThresholdAndRuleItems",
|
||||
@@ -217,6 +247,16 @@ func TestParseClusterKeyRateLimitConfig(t *testing.T) {
|
||||
RejectedMsg: DefaultRejectedMsg,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "LimitByType_AtLeastOne_Error",
|
||||
json: `{
|
||||
"rule_name": "no-limit-by",
|
||||
"rule_items": [
|
||||
{"limit_keys": [{"key": "k", "query_per_second": 1}]}
|
||||
]
|
||||
}`,
|
||||
expectedErr: errors.New("failed to parse rule_item in rule_items: at least one of 'limit_by_header', 'limit_by_param', 'limit_by_consumer', 'limit_by_cookie', 'limit_by_per_header', 'limit_by_per_param', 'limit_by_per_consumer', 'limit_by_per_cookie', 'limit_by_per_ip' must be set"),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -234,3 +274,32 @@ func TestParseClusterKeyRateLimitConfig(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseClusterKeyRateLimitConfig_RuleItemsLimit(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
itemCnt int
|
||||
expectErr string
|
||||
}{
|
||||
{"Exactly10", 10, ""},
|
||||
{"Over10", 11, "rule_items length 11 exceeds maximum 10"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
items := make([]string, 0, tt.itemCnt)
|
||||
for i := 0; i < tt.itemCnt; i++ {
|
||||
items = append(items,
|
||||
fmt.Sprintf(`{"limit_by_header":"h%d","limit_keys":[{"key":"k","query_per_second":1}]}`, i+1))
|
||||
}
|
||||
jsonStr := fmt.Sprintf(`{"rule_name":"t","rule_items":[%s]}`, strings.Join(items, ","))
|
||||
var config ClusterKeyRateLimitConfig
|
||||
err := ParseClusterKeyRateLimitConfig(gjson.Parse(jsonStr), &config)
|
||||
if tt.expectErr == "" {
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, config.RuleItems, tt.itemCnt)
|
||||
} else {
|
||||
assert.EqualError(t, err, tt.expectErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"net"
|
||||
"net/url"
|
||||
"strconv"
|
||||
@@ -46,30 +47,37 @@ func init() {
|
||||
const (
|
||||
// RedisKeyPrefix 集群限流插件在 Redis 中 key 的统一前缀
|
||||
RedisKeyPrefix = "higress-cluster-key-rate-limit"
|
||||
// 使用 {rule_name} hash tag 让多规则多键操作在 Redis Cluster 下落到同一 slot
|
||||
// ClusterGlobalRateLimitFormat 全局限流模式 redis key 为 RedisKeyPrefix:限流规则名称:global_threshold:时间窗口
|
||||
ClusterGlobalRateLimitFormat = RedisKeyPrefix + ":%s:global_threshold:%d"
|
||||
ClusterGlobalRateLimitFormat = RedisKeyPrefix + ":{%s}:global_threshold:%d"
|
||||
// ClusterRateLimitFormat 规则限流模式 redis key 为 RedisKeyPrefix:限流规则名称:限流类型:时间窗口:限流key名称:限流key对应的实际值
|
||||
ClusterRateLimitFormat = RedisKeyPrefix + ":%s:%s:%d:%s:%s"
|
||||
FixedWindowScript = `
|
||||
local key = KEYS[1]
|
||||
local threshold = tonumber(ARGV[1])
|
||||
local window = tonumber(ARGV[2])
|
||||
|
||||
local current = tonumber(redis.call('get', key) or "0")
|
||||
|
||||
-- 只有超过阈值时才停止累加,达到阈值时仍允许(此时是最后一次允许)
|
||||
if current > threshold then
|
||||
return {threshold, current, redis.call('ttl', key)}
|
||||
ClusterRateLimitFormat = RedisKeyPrefix + ":{%s}:%s:%d:%s:%s"
|
||||
// MultiKeyFixedWindowScript 多规则请求阶段 check + incr 合一脚本(cluster-key-rate-limit 使用)
|
||||
// KEYS = [key1, ..., keyN]
|
||||
// ARGV = [threshold1, window1, ..., thresholdN, windowN]
|
||||
// 返回嵌套数组 {{threshold_i, current_i, ttl_i}, ...}(每个 key 独立判断是否 incr)
|
||||
MultiKeyFixedWindowScript = `
|
||||
local results = {}
|
||||
for i = 1, #KEYS do
|
||||
local threshold = tonumber(ARGV[2*i - 1])
|
||||
local window = tonumber(ARGV[2*i])
|
||||
local current = tonumber(redis.call('get', KEYS[i]) or "0")
|
||||
local ttl = redis.call('ttl', KEYS[i])
|
||||
if ttl < 0 then ttl = window end
|
||||
|
||||
if current > threshold then
|
||||
-- 已超阈值,不再 incr
|
||||
table.insert(results, {threshold, current, ttl})
|
||||
else
|
||||
-- 未超阈值,原子 incr
|
||||
current = redis.call('incr', KEYS[i])
|
||||
if current == 1 then
|
||||
redis.call('expire', KEYS[i], window)
|
||||
end
|
||||
table.insert(results, {threshold, current, redis.call('ttl', KEYS[i])})
|
||||
end
|
||||
end
|
||||
|
||||
-- 计数未超过阈值,执行累加
|
||||
current = redis.call('incr', key)
|
||||
-- 第一次累加时设置过期时间
|
||||
if current == 1 then
|
||||
redis.call('expire', key, window)
|
||||
end
|
||||
|
||||
return {threshold, current, redis.call('ttl', key)}
|
||||
return results
|
||||
`
|
||||
|
||||
LimitContextKey = "LimitContext" // 限流上下文信息
|
||||
@@ -87,6 +95,13 @@ type LimitContext struct {
|
||||
reset int
|
||||
}
|
||||
|
||||
// MatchedRule 表示请求阶段命中的单条限流规则(global 或 rule_item)
|
||||
type MatchedRule struct {
|
||||
key string // 完整 Redis key
|
||||
count int64 // 时间窗口内的限额
|
||||
window int64 // 时间窗口大小(秒)
|
||||
}
|
||||
|
||||
func parseConfig(json gjson.Result, cfg *config.ClusterKeyRateLimitConfig) error {
|
||||
err := config.InitRedisClusterClient(json, cfg)
|
||||
if err != nil {
|
||||
@@ -101,51 +116,82 @@ func parseConfig(json gjson.Result, cfg *config.ClusterKeyRateLimitConfig) error
|
||||
|
||||
func onHttpRequestHeaders(ctx wrapper.HttpContext, cfg config.ClusterKeyRateLimitConfig) types.Action {
|
||||
ctx.DisableReroute()
|
||||
limitKey, count, timeWindow := "", int64(0), int64(0)
|
||||
|
||||
if cfg.GlobalThreshold != nil {
|
||||
// 全局限流模式
|
||||
limitKey = fmt.Sprintf(ClusterGlobalRateLimitFormat, cfg.RuleName, cfg.GlobalThreshold.TimeWindow)
|
||||
count = cfg.GlobalThreshold.Count
|
||||
timeWindow = cfg.GlobalThreshold.TimeWindow
|
||||
} else {
|
||||
// 规则限流模式
|
||||
val, ruleItem, configItem := checkRequestAgainstLimitRule(ctx, cfg.RuleItems)
|
||||
if ruleItem == nil || configItem == nil {
|
||||
// 没有匹配到限流规则直接返回
|
||||
return types.ActionContinue
|
||||
}
|
||||
|
||||
limitKey = fmt.Sprintf(ClusterRateLimitFormat, cfg.RuleName, ruleItem.LimitType, configItem.TimeWindow, ruleItem.Key, val)
|
||||
count = configItem.Count
|
||||
timeWindow = configItem.TimeWindow
|
||||
matched := collectMatchedRules(ctx, cfg)
|
||||
if len(matched) == 0 {
|
||||
// 无任何规则命中:直接放行,不发起 Redis 调用
|
||||
log.Debugf("cluster-key-rate-limit: no rule matched, path=%s host=%s", ctx.Path(), ctx.Host())
|
||||
return types.ActionContinue
|
||||
}
|
||||
|
||||
// 执行限流逻辑
|
||||
keys := []interface{}{limitKey}
|
||||
args := []interface{}{count, timeWindow}
|
||||
err := cfg.RedisClient.Eval(FixedWindowScript, 1, keys, args, func(response resp.Value) {
|
||||
resultArray := response.Array()
|
||||
if len(resultArray) != 3 {
|
||||
log.Errorf("redis response parse error, response: %v", response)
|
||||
proxywasm.ResumeHttpRequest()
|
||||
log.Debugf("cluster-key-rate-limit: request phase matched %d rule(s), path=%s host=%s",
|
||||
len(matched), ctx.Path(), ctx.Host())
|
||||
for i, m := range matched {
|
||||
log.Debugf("cluster-key-rate-limit: rule[%d] key=%s threshold=%d window=%ds",
|
||||
i, m.key, m.count, m.window)
|
||||
}
|
||||
|
||||
n := len(matched)
|
||||
keys := make([]interface{}, n)
|
||||
args := make([]interface{}, 0, n*2)
|
||||
for i, m := range matched {
|
||||
keys[i] = m.key
|
||||
args = append(args, m.count, m.window)
|
||||
}
|
||||
|
||||
err := cfg.RedisClient.Eval(MultiKeyFixedWindowScript, n, keys, args, func(response resp.Value) {
|
||||
arr := response.Array()
|
||||
if len(arr) != n {
|
||||
log.Errorf("redis response length mismatch: got %d, want %d", len(arr), n)
|
||||
_ = proxywasm.ResumeHttpRequest()
|
||||
return
|
||||
}
|
||||
|
||||
// 获取限流结果
|
||||
threshold, current, ttl := resultArray[0].Integer(), resultArray[1].Integer(), resultArray[2].Integer()
|
||||
context := LimitContext{
|
||||
count: threshold,
|
||||
remaining: threshold - current,
|
||||
reset: ttl,
|
||||
}
|
||||
if current > threshold {
|
||||
// 触发限流
|
||||
rejected(cfg, context)
|
||||
} else {
|
||||
ctx.SetContext(LimitContextKey, context)
|
||||
proxywasm.ResumeHttpRequest()
|
||||
// 使用 math.MaxFloat64 初始化,避免在 arr[0] 校验前预先读取
|
||||
tightestIdx := 0
|
||||
tightestRatio := math.MaxFloat64
|
||||
|
||||
for i, sub := range arr {
|
||||
a := sub.Array()
|
||||
if len(a) != 3 {
|
||||
log.Errorf("redis sub-array length mismatch: got %d, want 3", len(a))
|
||||
_ = proxywasm.ResumeHttpRequest()
|
||||
return
|
||||
}
|
||||
threshold, current, ttl := a[0].Integer(), a[1].Integer(), a[2].Integer()
|
||||
log.Debugf("cluster-key-rate-limit: eval rule[%d] key=%s threshold=%d current=%d ttl=%ds",
|
||||
i, matched[i].key, threshold, current, ttl)
|
||||
|
||||
if current > threshold {
|
||||
// 命中触发的第一条规则(按 collectMatchedRules 顺序,global 优先)
|
||||
log.Debugf("cluster-key-rate-limit: rule[%d] key=%s triggered (current=%d > threshold=%d), rejecting with code %d",
|
||||
i, matched[i].key, current, threshold, cfg.RejectedCode)
|
||||
rejected(cfg, LimitContext{
|
||||
count: threshold,
|
||||
remaining: threshold - current,
|
||||
reset: ttl,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if ratio := float64(threshold-current) / float64(threshold); ratio < tightestRatio {
|
||||
tightestIdx = i
|
||||
tightestRatio = ratio
|
||||
}
|
||||
}
|
||||
|
||||
// 未触发:写入 tightest 规则到 LimitContext,供 onHttpResponseHeaders 读取 X-RateLimit-* 头
|
||||
tightSub := arr[tightestIdx].Array()
|
||||
tightThreshold, tightCurrent, tightTtl := tightSub[0].Integer(), tightSub[1].Integer(), tightSub[2].Integer()
|
||||
log.Debugf("cluster-key-rate-limit: all %d rule(s) within threshold, tightest rule[%d] key=%s (count=%d remaining=%d reset=%ds)",
|
||||
n, tightestIdx, matched[tightestIdx].key, tightThreshold, tightThreshold-tightCurrent, tightTtl)
|
||||
ctx.SetContext(LimitContextKey, LimitContext{
|
||||
count: tightThreshold,
|
||||
remaining: tightThreshold - tightCurrent,
|
||||
reset: tightTtl,
|
||||
})
|
||||
|
||||
_ = proxywasm.ResumeHttpRequest()
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
@@ -158,25 +204,43 @@ func onHttpRequestHeaders(ctx wrapper.HttpContext, cfg config.ClusterKeyRateLimi
|
||||
func onHttpResponseHeaders(ctx wrapper.HttpContext, config config.ClusterKeyRateLimitConfig) types.Action {
|
||||
limitContext, ok := ctx.GetContext(LimitContextKey).(LimitContext)
|
||||
if !ok {
|
||||
log.Debugf("cluster-key-rate-limit: response phase reached with no LimitContext, skipping X-RateLimit-* headers")
|
||||
return types.ActionContinue
|
||||
}
|
||||
if config.ShowLimitQuotaHeader {
|
||||
_ = proxywasm.ReplaceHttpResponseHeader(RateLimitLimitHeader, strconv.Itoa(limitContext.count))
|
||||
_ = proxywasm.ReplaceHttpResponseHeader(RateLimitRemainingHeader, strconv.Itoa(limitContext.remaining))
|
||||
log.Debugf("cluster-key-rate-limit: response phase wrote X-RateLimit-Limit=%d X-RateLimit-Remaining=%d",
|
||||
limitContext.count, limitContext.remaining)
|
||||
}
|
||||
return types.ActionContinue
|
||||
}
|
||||
|
||||
func checkRequestAgainstLimitRule(ctx wrapper.HttpContext, ruleItems []config.LimitRuleItem) (string, *config.LimitRuleItem, *config.LimitConfigItem) {
|
||||
if len(ruleItems) > 0 {
|
||||
for _, rule := range ruleItems {
|
||||
val, ruleItem, configItem := hitRateRuleItem(ctx, rule)
|
||||
if ruleItem != nil && configItem != nil {
|
||||
return val, ruleItem, configItem
|
||||
}
|
||||
// collectMatchedRules 遍历 global_threshold 和 rule_items,返回所有命中规则。
|
||||
// 顺序:global_threshold(如有)→ rule_items 中所有命中项(按数组顺序追加)。
|
||||
func collectMatchedRules(ctx wrapper.HttpContext, cfg config.ClusterKeyRateLimitConfig) []MatchedRule {
|
||||
var matched []MatchedRule
|
||||
|
||||
if cfg.GlobalThreshold != nil {
|
||||
matched = append(matched, MatchedRule{
|
||||
key: fmt.Sprintf(ClusterGlobalRateLimitFormat, cfg.RuleName, cfg.GlobalThreshold.TimeWindow),
|
||||
count: cfg.GlobalThreshold.Count,
|
||||
window: cfg.GlobalThreshold.TimeWindow,
|
||||
})
|
||||
}
|
||||
|
||||
for _, ruleItem := range cfg.RuleItems {
|
||||
val, hitRule, hitItem := hitRateRuleItem(ctx, ruleItem)
|
||||
if hitRule != nil && hitItem != nil {
|
||||
matched = append(matched, MatchedRule{
|
||||
key: fmt.Sprintf(ClusterRateLimitFormat, cfg.RuleName, hitRule.LimitType, hitItem.TimeWindow, hitRule.Key, val),
|
||||
count: hitItem.Count,
|
||||
window: hitItem.TimeWindow,
|
||||
})
|
||||
}
|
||||
}
|
||||
return "", nil, nil
|
||||
|
||||
return matched
|
||||
}
|
||||
|
||||
func hitRateRuleItem(ctx wrapper.HttpContext, rule config.LimitRuleItem) (string, *config.LimitRuleItem, *config.LimitConfigItem) {
|
||||
|
||||
@@ -16,6 +16,8 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cluster-key-rate-limit/config"
|
||||
@@ -23,6 +25,7 @@ import (
|
||||
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm/types"
|
||||
"github.com/higress-group/wasm-go/pkg/test"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/resp"
|
||||
)
|
||||
|
||||
// 测试配置:全局限流配置
|
||||
@@ -222,6 +225,56 @@ var regexpLimitConfig = func() json.RawMessage {
|
||||
return data
|
||||
}()
|
||||
|
||||
// 测试配置:混合限流(global + rule_items 同时生效)
|
||||
var hybridLimitConfig = func() json.RawMessage {
|
||||
data, _ := json.Marshal(map[string]interface{}{
|
||||
"rule_name": "cluster-hybrid",
|
||||
"global_threshold": map[string]interface{}{
|
||||
"query_per_minute": 10000,
|
||||
},
|
||||
"rule_items": []map[string]interface{}{
|
||||
{
|
||||
"limit_by_header": "x-api-key",
|
||||
"limit_keys": []map[string]interface{}{
|
||||
{"key": "vip-key", "query_per_minute": 100},
|
||||
},
|
||||
},
|
||||
},
|
||||
"redis": map[string]interface{}{
|
||||
"service_name": "redis.static",
|
||||
"service_port": 6379,
|
||||
},
|
||||
"show_limit_quota_header": true,
|
||||
})
|
||||
return data
|
||||
}()
|
||||
|
||||
// 测试配置:多条 rule_items 同时命中
|
||||
var multiRuleItemsConfig = func() json.RawMessage {
|
||||
data, _ := json.Marshal(map[string]interface{}{
|
||||
"rule_name": "cluster-multi-items",
|
||||
"rule_items": []map[string]interface{}{
|
||||
{
|
||||
"limit_by_header": "x-api-key",
|
||||
"limit_keys": []map[string]interface{}{
|
||||
{"key": "k1", "query_per_minute": 100},
|
||||
},
|
||||
},
|
||||
{
|
||||
"limit_by_param": "apikey",
|
||||
"limit_keys": []map[string]interface{}{
|
||||
{"key": "k2", "query_per_minute": 50},
|
||||
},
|
||||
},
|
||||
},
|
||||
"redis": map[string]interface{}{
|
||||
"service_name": "redis.static",
|
||||
"service_port": 6379,
|
||||
},
|
||||
})
|
||||
return data
|
||||
}()
|
||||
|
||||
func TestParseConfig(t *testing.T) {
|
||||
test.RunGoTest(t, func(t *testing.T) {
|
||||
// 测试全局限流配置解析
|
||||
@@ -385,7 +438,7 @@ func TestOnHttpRequestHeaders(t *testing.T) {
|
||||
// 由于需要调用 Redis,应该返回 HeaderStopAllIterationAndWatermark
|
||||
require.Equal(t, types.HeaderStopAllIterationAndWatermark, action)
|
||||
|
||||
resp := test.CreateRedisRespArray([]interface{}{1000, 999, 60})
|
||||
resp := multiRuleResp([3]int{1000, 999, 60})
|
||||
// 模拟 Redis 调用响应(允许请求)
|
||||
host.CallOnRedisCall(0, resp)
|
||||
|
||||
@@ -409,7 +462,7 @@ func TestOnHttpRequestHeaders(t *testing.T) {
|
||||
require.Equal(t, types.HeaderStopAllIterationAndWatermark, action)
|
||||
|
||||
// 模拟 Redis 调用响应(允许请求)
|
||||
resp := test.CreateRedisRespArray([]interface{}{10, 9, 60})
|
||||
resp := multiRuleResp([3]int{10, 9, 60})
|
||||
host.CallOnRedisCall(0, resp)
|
||||
|
||||
host.CompleteHttp()
|
||||
@@ -433,7 +486,7 @@ func TestOnHttpRequestHeaders(t *testing.T) {
|
||||
require.Equal(t, types.HeaderStopAllIterationAndWatermark, action)
|
||||
|
||||
// 模拟 Redis 调用响应(允许请求)
|
||||
resp := test.CreateRedisRespArray([]interface{}{10, 9, 60})
|
||||
resp := multiRuleResp([3]int{10, 9, 60})
|
||||
host.CallOnRedisCall(0, resp)
|
||||
|
||||
host.CompleteHttp()
|
||||
@@ -457,7 +510,7 @@ func TestOnHttpRequestHeaders(t *testing.T) {
|
||||
require.Equal(t, types.HeaderStopAllIterationAndWatermark, action)
|
||||
|
||||
// 模拟 Redis 调用响应(允许请求)
|
||||
resp := test.CreateRedisRespArray([]interface{}{10, 9, 1})
|
||||
resp := multiRuleResp([3]int{10, 9, 1})
|
||||
host.CallOnRedisCall(0, resp)
|
||||
|
||||
host.CompleteHttp()
|
||||
@@ -481,7 +534,7 @@ func TestOnHttpRequestHeaders(t *testing.T) {
|
||||
require.Equal(t, types.HeaderStopAllIterationAndWatermark, action)
|
||||
|
||||
// 模拟 Redis 调用响应(允许请求)
|
||||
resp := test.CreateRedisRespArray([]interface{}{10, 9, 60})
|
||||
resp := multiRuleResp([3]int{10, 9, 60})
|
||||
host.CallOnRedisCall(0, resp)
|
||||
|
||||
host.CompleteHttp()
|
||||
@@ -505,7 +558,7 @@ func TestOnHttpRequestHeaders(t *testing.T) {
|
||||
require.Equal(t, types.HeaderStopAllIterationAndWatermark, action)
|
||||
|
||||
// 模拟 Redis 调用响应(允许请求)
|
||||
resp := test.CreateRedisRespArray([]interface{}{10, 9, 86400})
|
||||
resp := multiRuleResp([3]int{10, 9, 86400})
|
||||
host.CallOnRedisCall(0, resp)
|
||||
|
||||
host.CompleteHttp()
|
||||
@@ -529,7 +582,7 @@ func TestOnHttpRequestHeaders(t *testing.T) {
|
||||
|
||||
// 模拟 Redis 调用响应(触发限流)
|
||||
// 当前请求数(1001)超过阈值(1000),触发限流
|
||||
resp := test.CreateRedisRespArray([]interface{}{1000, 1001, 60})
|
||||
resp := multiRuleResp([3]int{1000, 1001, 60})
|
||||
host.CallOnRedisCall(0, resp)
|
||||
|
||||
// 检查是否发送了限流响应
|
||||
@@ -540,6 +593,224 @@ func TestOnHttpRequestHeaders(t *testing.T) {
|
||||
|
||||
host.CompleteHttp()
|
||||
})
|
||||
|
||||
// 混合限流:global + rule_item 同时命中
|
||||
t.Run("hybrid limit both match", func(t *testing.T) {
|
||||
host, status := test.NewTestHost(hybridLimitConfig)
|
||||
defer host.Reset()
|
||||
require.Equal(t, types.OnPluginStartStatusOK, status)
|
||||
|
||||
action := host.CallOnHttpRequestHeaders([][2]string{
|
||||
{":authority", "example.com"},
|
||||
{":path", "/api/test"},
|
||||
{":method", "GET"},
|
||||
{"x-api-key", "vip-key"},
|
||||
})
|
||||
require.Equal(t, types.HeaderStopAllIterationAndWatermark, action)
|
||||
|
||||
resp := multiRuleResp(
|
||||
[3]int{10000, 1, 60},
|
||||
[3]int{100, 1, 60},
|
||||
)
|
||||
host.CallOnRedisCall(0, resp)
|
||||
|
||||
host.CompleteHttp()
|
||||
})
|
||||
|
||||
// 多条 rule_items 同时命中
|
||||
t.Run("multi rule_items all match", func(t *testing.T) {
|
||||
host, status := test.NewTestHost(multiRuleItemsConfig)
|
||||
defer host.Reset()
|
||||
require.Equal(t, types.OnPluginStartStatusOK, status)
|
||||
|
||||
action := host.CallOnHttpRequestHeaders([][2]string{
|
||||
{":authority", "example.com"},
|
||||
{":path", "/api/test?apikey=k2"},
|
||||
{":method", "GET"},
|
||||
{"x-api-key", "k1"},
|
||||
})
|
||||
require.Equal(t, types.HeaderStopAllIterationAndWatermark, action)
|
||||
|
||||
resp := multiRuleResp(
|
||||
[3]int{100, 1, 60},
|
||||
[3]int{50, 1, 60},
|
||||
)
|
||||
host.CallOnRedisCall(0, resp)
|
||||
|
||||
host.CompleteHttp()
|
||||
})
|
||||
|
||||
// 混合限流:global 触发
|
||||
t.Run("hybrid limit global triggered", func(t *testing.T) {
|
||||
host, status := test.NewTestHost(hybridLimitConfig)
|
||||
defer host.Reset()
|
||||
require.Equal(t, types.OnPluginStartStatusOK, status)
|
||||
|
||||
action := host.CallOnHttpRequestHeaders([][2]string{
|
||||
{":authority", "example.com"},
|
||||
{":path", "/api/test"},
|
||||
{":method", "GET"},
|
||||
{"x-api-key", "vip-key"},
|
||||
})
|
||||
require.Equal(t, types.HeaderStopAllIterationAndWatermark, action)
|
||||
|
||||
resp := multiRuleResp(
|
||||
[3]int{10000, 10001, 60},
|
||||
[3]int{100, 1, 60},
|
||||
)
|
||||
host.CallOnRedisCall(0, resp)
|
||||
|
||||
localResponse := host.GetLocalResponse()
|
||||
require.NotNil(t, localResponse)
|
||||
require.Equal(t, uint32(429), localResponse.StatusCode)
|
||||
|
||||
host.CompleteHttp()
|
||||
})
|
||||
|
||||
// Redis 响应数组长度与规则数不匹配
|
||||
t.Run("redis response length mismatch", func(t *testing.T) {
|
||||
host, status := test.NewTestHost(hybridLimitConfig)
|
||||
defer host.Reset()
|
||||
require.Equal(t, types.OnPluginStartStatusOK, status)
|
||||
|
||||
host.CallOnHttpRequestHeaders([][2]string{
|
||||
{":authority", "example.com"},
|
||||
{":path", "/api/test"},
|
||||
{":method", "GET"},
|
||||
{"x-api-key", "vip-key"},
|
||||
})
|
||||
|
||||
resp := multiRuleResp([3]int{10000, 1, 60}) // 期望 2 条,返回 1 条
|
||||
host.CallOnRedisCall(0, resp)
|
||||
|
||||
require.Nil(t, host.GetLocalResponse())
|
||||
host.CompleteHttp()
|
||||
})
|
||||
|
||||
// Redis 子数组长度异常(少于 3)
|
||||
t.Run("redis sub-array length mismatch", func(t *testing.T) {
|
||||
host, status := test.NewTestHost(hybridLimitConfig)
|
||||
defer host.Reset()
|
||||
require.Equal(t, types.OnPluginStartStatusOK, status)
|
||||
|
||||
host.CallOnHttpRequestHeaders([][2]string{
|
||||
{":authority", "example.com"},
|
||||
{":path", "/api/test"},
|
||||
{":method", "GET"},
|
||||
{"x-api-key", "vip-key"},
|
||||
})
|
||||
|
||||
badResp, err := resp.ArrayValue([]resp.Value{
|
||||
resp.ArrayValue([]resp.Value{
|
||||
resp.IntegerValue(10000),
|
||||
resp.IntegerValue(1),
|
||||
}),
|
||||
}).MarshalRESP() // 关键:用 MarshalRESP() 而非 Bytes()
|
||||
require.NoError(t, err)
|
||||
host.CallOnRedisCall(0, badResp)
|
||||
|
||||
require.Nil(t, host.GetLocalResponse())
|
||||
host.CompleteHttp()
|
||||
})
|
||||
|
||||
// 混合配置:global 命中,rule_items 不命中
|
||||
t.Run("hybrid limit rule_items no match", func(t *testing.T) {
|
||||
host, status := test.NewTestHost(hybridLimitConfig)
|
||||
defer host.Reset()
|
||||
require.Equal(t, types.OnPluginStartStatusOK, status)
|
||||
|
||||
action := host.CallOnHttpRequestHeaders([][2]string{
|
||||
{":authority", "example.com"},
|
||||
{":path", "/api/test"},
|
||||
{":method", "GET"},
|
||||
})
|
||||
require.Equal(t, types.HeaderStopAllIterationAndWatermark, action)
|
||||
|
||||
resp := multiRuleResp([3]int{10000, 1, 60})
|
||||
host.CallOnRedisCall(0, resp)
|
||||
|
||||
require.Nil(t, host.GetLocalResponse())
|
||||
host.CompleteHttp()
|
||||
})
|
||||
|
||||
// 多规则同时触发,验证 rejected 报告 global(reset=60 而非 rule_item 的 30)
|
||||
t.Run("hybrid limit both triggered reports global first", func(t *testing.T) {
|
||||
host, status := test.NewTestHost(hybridLimitConfig)
|
||||
defer host.Reset()
|
||||
require.Equal(t, types.OnPluginStartStatusOK, status)
|
||||
|
||||
host.CallOnHttpRequestHeaders([][2]string{
|
||||
{":authority", "example.com"},
|
||||
{":path", "/api/test"},
|
||||
{":method", "GET"},
|
||||
{"x-api-key", "vip-key"},
|
||||
})
|
||||
|
||||
resp := multiRuleResp(
|
||||
[3]int{10000, 10001, 60},
|
||||
[3]int{100, 101, 30},
|
||||
)
|
||||
host.CallOnRedisCall(0, resp)
|
||||
|
||||
localResponse := host.GetLocalResponse()
|
||||
require.NotNil(t, localResponse)
|
||||
require.Equal(t, uint32(429), localResponse.StatusCode)
|
||||
|
||||
// LocalHttpResponse.Headers 类型为 [][2]string,按切片迭代查找目标头
|
||||
var resetHeader string
|
||||
for _, h := range localResponse.Headers {
|
||||
if strings.EqualFold(h[0], "X-RateLimit-Reset") {
|
||||
resetHeader = h[1]
|
||||
break
|
||||
}
|
||||
}
|
||||
require.Equal(t, "60", resetHeader, "应报告 global 规则的 reset 时间")
|
||||
|
||||
host.CompleteHttp()
|
||||
})
|
||||
|
||||
// 多规则未触发,剩余比例不同 → LimitContext 取 tightest
|
||||
// cluster-key 可以通过 X-RateLimit-* 响应头验证(ai-token 不能)
|
||||
t.Run("multi-rule no trigger takes tightest", func(t *testing.T) {
|
||||
host, status := test.NewTestHost(hybridLimitConfig)
|
||||
defer host.Reset()
|
||||
require.Equal(t, types.OnPluginStartStatusOK, status)
|
||||
|
||||
host.CallOnHttpRequestHeaders([][2]string{
|
||||
{":authority", "example.com"},
|
||||
{":path", "/api/test"},
|
||||
{":method", "GET"},
|
||||
{"x-api-key", "vip-key"},
|
||||
})
|
||||
|
||||
resp := multiRuleResp(
|
||||
[3]int{10000, 9000, 60}, // global: 剩余 10%
|
||||
[3]int{100, 10, 60}, // rule_item: 剩余 90%
|
||||
)
|
||||
host.CallOnRedisCall(0, resp)
|
||||
|
||||
require.Nil(t, host.GetLocalResponse())
|
||||
|
||||
host.CallOnHttpResponseHeaders([][2]string{
|
||||
{":status", "200"},
|
||||
{"content-type", "application/json"},
|
||||
})
|
||||
|
||||
responseHeaders := host.GetResponseHeaders()
|
||||
require.True(t, test.HasHeader(responseHeaders, "x-ratelimit-limit"))
|
||||
require.True(t, test.HasHeader(responseHeaders, "x-ratelimit-remaining"))
|
||||
|
||||
for _, h := range responseHeaders {
|
||||
if strings.EqualFold(h[0], "x-ratelimit-limit") {
|
||||
require.Equal(t, "10000", h[1], "X-RateLimit-Limit 应为 tightest(global) 的 threshold")
|
||||
}
|
||||
if strings.EqualFold(h[0], "x-ratelimit-remaining") {
|
||||
require.Equal(t, "1000", h[1], "X-RateLimit-Remaining 应为 tightest(global) 的 threshold-current")
|
||||
}
|
||||
}
|
||||
|
||||
host.CompleteHttp()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -559,7 +830,7 @@ func TestOnHttpResponseHeaders(t *testing.T) {
|
||||
})
|
||||
|
||||
// 模拟 Redis 调用响应
|
||||
resp := test.CreateRedisRespArray([]interface{}{1000, 999, 60})
|
||||
resp := multiRuleResp([3]int{1000, 999, 60})
|
||||
host.CallOnRedisCall(0, resp)
|
||||
|
||||
// 处理响应头
|
||||
@@ -609,7 +880,7 @@ func TestOnHttpResponseHeaders(t *testing.T) {
|
||||
})
|
||||
|
||||
// 模拟 Redis 调用响应
|
||||
resp := test.CreateRedisRespArray([]interface{}{1000, 999, 60})
|
||||
resp := multiRuleResp([3]int{1000, 999, 60})
|
||||
host.CallOnRedisCall(0, resp)
|
||||
|
||||
// 处理响应头
|
||||
@@ -628,6 +899,88 @@ func TestOnHttpResponseHeaders(t *testing.T) {
|
||||
|
||||
host.CompleteHttp()
|
||||
})
|
||||
|
||||
// 测试响应头处理时 context 中无 LimitContext(覆盖 GetContext 返回 nil 的分支)。
|
||||
// 场景:使用 headerLimitConfig(仅 x-ca-key 规则),ensureContextInitialized 调用的
|
||||
// onHttpRequestHeaders 因为缺少 x-ca-key 头而 matched 为空,返回 ActionContinue,
|
||||
// 此时 SetContext 未被调用。后续 onHttpResponseHeaders 应通过类型断言失败分支安全返回。
|
||||
t.Run("no prior context", func(t *testing.T) {
|
||||
host, status := test.NewTestHost(headerLimitConfig)
|
||||
defer host.Reset()
|
||||
require.Equal(t, types.OnPluginStartStatusOK, status)
|
||||
|
||||
// 直接处理响应头:测试框架会通过 ensureContextInitialized 触发默认请求头处理,
|
||||
// 但缺少 x-ca-key,因此 collectMatchedRules 返回空,SetContext 未调用。
|
||||
action := host.CallOnHttpResponseHeaders([][2]string{
|
||||
{":status", "200"},
|
||||
{"content-type", "application/json"},
|
||||
})
|
||||
|
||||
// 应该返回 ActionContinue,且不 panic
|
||||
require.Equal(t, types.ActionContinue, action)
|
||||
|
||||
// 不应写入限流配额响应头
|
||||
responseHeaders := host.GetResponseHeaders()
|
||||
require.False(t, test.HasHeader(responseHeaders, "x-ratelimit-limit"))
|
||||
require.False(t, test.HasHeader(responseHeaders, "x-ratelimit-remaining"))
|
||||
|
||||
host.CompleteHttp()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// TestRejectedHidesQuotaHeadersWhenDisabled 验证 rejected() 在 show_limit_quota_header=false
|
||||
// 时只设置 X-RateLimit-Reset,不设置 X-RateLimit-Limit / X-RateLimit-Remaining(覆盖 line 339-341)。
|
||||
func TestRejectedHidesQuotaHeadersWhenDisabled(t *testing.T) {
|
||||
test.RunTest(t, func(t *testing.T) {
|
||||
hideQuotaConfig := func() json.RawMessage {
|
||||
data, _ := json.Marshal(map[string]interface{}{
|
||||
"rule_name": "routeA-rejected-hide-quota",
|
||||
"global_threshold": map[string]interface{}{
|
||||
"query_per_minute": 1,
|
||||
},
|
||||
"redis": map[string]interface{}{
|
||||
"service_name": "redis.static",
|
||||
"service_port": 6379,
|
||||
},
|
||||
"show_limit_quota_header": false,
|
||||
"rejected_code": 429,
|
||||
"rejected_msg": "Too many requests",
|
||||
})
|
||||
return data
|
||||
}()
|
||||
|
||||
host, status := test.NewTestHost(hideQuotaConfig)
|
||||
defer host.Reset()
|
||||
require.Equal(t, types.OnPluginStartStatusOK, status)
|
||||
|
||||
action := host.CallOnHttpRequestHeaders([][2]string{
|
||||
{":authority", "example.com"},
|
||||
{":path", "/api/test"},
|
||||
{":method", "GET"},
|
||||
})
|
||||
require.Equal(t, types.HeaderStopAllIterationAndWatermark, action)
|
||||
|
||||
// 触发限流:current (2) > threshold (1)
|
||||
resp := multiRuleResp([3]int{1, 2, 60})
|
||||
host.CallOnRedisCall(0, resp)
|
||||
|
||||
// 应返回 429 本地响应
|
||||
localResponse := host.GetLocalResponse()
|
||||
require.NotNil(t, localResponse)
|
||||
require.Equal(t, uint32(429), localResponse.StatusCode)
|
||||
|
||||
// X-RateLimit-Reset 应保留(始终设置)
|
||||
require.True(t, test.HasHeader(localResponse.Headers, "x-ratelimit-reset"),
|
||||
"X-RateLimit-Reset should always be present on rejection")
|
||||
|
||||
// X-RateLimit-Limit / X-RateLimit-Remaining 应缺失(show_limit_quota_header=false)
|
||||
require.False(t, test.HasHeader(localResponse.Headers, "x-ratelimit-limit"),
|
||||
"X-RateLimit-Limit should be hidden when show_limit_quota_header=false")
|
||||
require.False(t, test.HasHeader(localResponse.Headers, "x-ratelimit-remaining"),
|
||||
"X-RateLimit-Remaining should be hidden when show_limit_quota_header=false")
|
||||
|
||||
host.CompleteHttp()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -649,7 +1002,7 @@ func TestCompleteFlow(t *testing.T) {
|
||||
require.Equal(t, types.HeaderStopAllIterationAndWatermark, action)
|
||||
|
||||
// 2. 模拟 Redis 调用响应
|
||||
resp := test.CreateRedisRespArray([]interface{}{1000, 1, 60})
|
||||
resp := multiRuleResp([3]int{1000, 1, 60})
|
||||
host.CallOnRedisCall(0, resp)
|
||||
|
||||
// 3. 处理响应头
|
||||
@@ -672,3 +1025,24 @@ func TestCompleteFlow(t *testing.T) {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// multiRuleResp 构建多规则嵌套 Redis 响应(RESP wire format)。
|
||||
// test.CreateRedisRespArray 不支持嵌套数组,因此直接用 resp 包构造。
|
||||
// 每个 [3]int 元组为 {threshold, current, ttl}。
|
||||
// 注意:不能使用 resp.Value.Bytes()——它返回的是显示用字符串,不是 RESP wire 格式。
|
||||
// 必须用 resp.Writer.WriteArray 或 Value.MarshalRESP() 输出真正的 RESP 字节流。
|
||||
func multiRuleResp(items ...[3]int) []byte {
|
||||
values := make([]resp.Value, len(items))
|
||||
for i, it := range items {
|
||||
values[i] = resp.ArrayValue([]resp.Value{
|
||||
resp.IntegerValue(it[0]),
|
||||
resp.IntegerValue(it[1]),
|
||||
resp.IntegerValue(it[2]),
|
||||
})
|
||||
}
|
||||
b, err := resp.ArrayValue(values).MarshalRESP()
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("failed to marshal multiRuleResp: %v", err))
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
@@ -10,8 +10,25 @@ description: 跨域资源共享插件配置参考
|
||||
|
||||
## 运行属性
|
||||
|
||||
插件执行阶段:`授权阶段`
|
||||
插件执行优先级:`340`
|
||||
插件执行阶段:`认证阶段`
|
||||
插件执行优先级:`2000`
|
||||
|
||||
## 版本说明
|
||||
|
||||
### 2.0.1
|
||||
|
||||
相比 `2.0.0`,本版本对齐浏览器 CORS 语义,并将插件执行优先级调整为 `2000`。
|
||||
|
||||
具体变更和升级注意事项:
|
||||
|
||||
* 实际 CORS 请求的 Origin 或 Method 不匹配时,插件不再直接返回 `403`,而是继续转发到后端;插件不会添加 CORS 允许响应头,并会移除后端返回的 CORS policy 响应头,由浏览器按 CORS 规则拦截。若已有监控或客户端逻辑依赖网关直接返回 `403`,升级后需要相应调整。
|
||||
* CORS 预检请求统一由插件直接返回 `204 No Content`。非法预检请求不会返回 `Access-Control-Allow-*`、`Access-Control-Expose-Headers`、`Access-Control-Allow-Credentials` 或 `Access-Control-Max-Age`,浏览器会判定预检失败;若已有逻辑依赖非法预检返回 `403`,升级后需要相应调整。
|
||||
* 同源 `OPTIONS` 请求即使携带类似预检的请求头,也会继续转发到后端,避免被 CORS 插件误拦截。
|
||||
* `allow_methods: ["*"]` 会在预检响应中回显本次请求的 `Access-Control-Request-Method`;`allow_headers: ["*"]` 会回显规范化后的 `Access-Control-Request-Headers`,没有请求头时不返回 `Access-Control-Allow-Headers`。
|
||||
* 默认 Method/Header 解析会按逗号拆分并去除空格,避免默认值被当作单个不可匹配的条目。
|
||||
* Origin 模式匹配会锚定完整 Origin,避免类似 `http://api.example.com.evil.com` 误匹配 `http://*.example.com`;如果历史配置依赖非完整匹配,需要调整为明确的 Origin 模式。
|
||||
* 当 `Access-Control-Allow-Origin` 返回具体 Origin 而不是 `*` 时,插件会合并返回 `Vary: Origin`,避免缓存复用错误的跨域响应。
|
||||
* `expose_headers: ["*"]` 在携带凭据的请求中仍按兼容方式接受配置,但浏览器会把 `Access-Control-Expose-Headers: *` 当作字面量 Header 名称,而不是暴露所有 Header。
|
||||
|
||||
## 配置字段
|
||||
|
||||
@@ -19,16 +36,18 @@ description: 跨域资源共享插件配置参考
|
||||
|-----------------------|-----------------|----------|----------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| allow_origins | array of string | 选填 | * | 允许跨域访问的 Origin,格式为 scheme://host:port,示例如 http://example.com:8081。当 allow_credentials 为 false 时,可以使用 * 来表示允许所有 Origin 通过 |
|
||||
| allow_origin_patterns | array of string | 选填 | - | 允许跨域访问的 Origin 模式匹配, 用 * 匹配域名或者端口, <br/>比如 http://*.example.com -- 匹配域名, http://*.example.com:[8080,9090] -- 匹配域名和指定端口, http://*.example.com:[*] -- 匹配域名和所有端口。单独 * 表示匹配所有域名和端口 |
|
||||
| allow_methods | array of string | 选填 | GET, PUT, POST, DELETE, PATCH, OPTIONS | 允许跨域访问的 Method,比如:GET,POST 等。可以使用 * 来表示允许所有 Method。 |
|
||||
| allow_headers | array of string | 选填 | DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,<br/>If-Modified-Since,Cache-Control,Content-Type,Authorization | 允许跨域访问时请求方携带哪些非 CORS 规范以外的 Header。可以使用 * 来表示允许任意 Header。 |
|
||||
| expose_headers | array of string | 选填 | - | 允许跨域访问时响应方携带哪些非 CORS 规范以外的 Header。可以使用 * 来表示允许任意 Header。 |
|
||||
| allow_methods | array of string | 选填 | GET, PUT, POST, DELETE, PATCH, OPTIONS | 允许跨域访问的 Method,比如:GET,POST 等。可以使用 * 来表示允许所有 Method;预检响应会回显本次请求的 `Access-Control-Request-Method`。 |
|
||||
| allow_headers | array of string | 选填 | DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,<br/>If-Modified-Since,Cache-Control,Content-Type,Authorization | 允许跨域访问时请求方携带哪些非 CORS 规范以外的 Header。可以使用 * 来表示允许任意 Header;预检响应会回显规范化后的 `Access-Control-Request-Headers`,没有请求头时不返回 `Access-Control-Allow-Headers`。 |
|
||||
| expose_headers | array of string | 选填 | - | 允许跨域访问时响应方携带哪些非 CORS 规范以外的 Header。可以使用 *;但当请求携带凭据时,浏览器会把 `Access-Control-Expose-Headers: *` 当作字面量 Header 名称,而不是暴露所有 Header。 |
|
||||
| allow_credentials | bool | 选填 | false | 是否允许跨域访问的请求方携带凭据(如 Cookie 等)。根据 CORS 规范,如果设置该选项为 true,在 allow_origins 不能使用 *, 替换成使用 allow_origin_patterns * |
|
||||
| max_age | number | 选填 | 86400秒 | 浏览器缓存 CORS 结果的最大时间,单位为秒。<br/>在这个时间范围内,浏览器会复用上一次的检查结果 |
|
||||
|
||||
> 注意
|
||||
> * allow_credentials 是一个很敏感的选项,请谨慎开启。开启之后,allow_credentials 和 allow_origins 为 * 不能同时使用,同时设置时, allow_origins 值为 "*" 生效。
|
||||
> * allow_origins 和 allow_origin_patterns 可以同时设置, 先检查 allow_origins 是否匹配,然后再检查 allow_origin_patterns 是否匹配
|
||||
> * 非法 CORS 请求, HTTP 状态码返回是 403, 返回体内容为 "Invalid CORS request"
|
||||
> * 对于实际 CORS 请求,如果 Origin 或 Method 不匹配配置,插件会继续转发到后端,但不会添加 CORS 响应头,并会移除后端返回的 CORS policy 响应头,由浏览器根据 CORS 规则拦截结果。
|
||||
> * 对于 CORS 预检请求,合法请求由插件直接返回 `204 No Content` 和配置对应的 CORS 响应头;非法请求由插件直接返回 `204 No Content`,但不返回 `Access-Control-Allow-*`、`Access-Control-Expose-Headers`、`Access-Control-Allow-Credentials` 或 `Access-Control-Max-Age`,浏览器会判定预检失败。
|
||||
> * 当 `Access-Control-Allow-Origin` 返回具体 Origin 而不是 `*` 时,插件会合并返回 `Vary: Origin`,避免缓存复用错误的跨域响应。
|
||||
|
||||
## 配置示例
|
||||
|
||||
@@ -150,7 +169,7 @@ spec:
|
||||
configDisable: false
|
||||
ingress:
|
||||
- ingress-cors-httpbin
|
||||
url: oci://higress-registry.cn-hangzhou.cr.aliyuncs.com/plugins/cors:1.0.0
|
||||
url: oci://higress-registry.cn-hangzhou.cr.aliyuncs.com/plugins/cors:2.0.1
|
||||
imagePullPolicy: Always
|
||||
```
|
||||
|
||||
@@ -161,10 +180,11 @@ spec:
|
||||
curl -v -H "Origin: http://httpbin2.example.org:9090" -H "Host: httpbin.example.com" http://127.0.0.1/anything/get\?foo\=1
|
||||
|
||||
< HTTP/1.1 200 OK
|
||||
> x-cors-version: 1.0.0
|
||||
> x-cors-version: 2.0.1
|
||||
> access-control-allow-origin: http://httpbin2.example.org:9090
|
||||
> access-control-expose-headers: X-Custom-Header,X-Env-UTM
|
||||
> access-control-allow-credentials: true
|
||||
> vary: Origin
|
||||
```
|
||||
|
||||
#### 预检请求
|
||||
@@ -172,14 +192,15 @@ curl -v -H "Origin: http://httpbin2.example.org:9090" -H "Host: httpbin.example
|
||||
```shell
|
||||
curl -v -X OPTIONS -H "Origin: http://httpbin2.example.org:9090" -H "Host: httpbin.example.com" -H "Access-Control-Request-Method: POST" -H "Access-Control-Request-Headers: Content-Type, Token" http://127.0.0.1/anything/get\?foo\=1
|
||||
|
||||
< HTTP/1.1 200 OK
|
||||
< x-cors-version: 1.0.0
|
||||
< HTTP/1.1 204 No Content
|
||||
< x-cors-trace: trace
|
||||
< access-control-allow-origin: http://httpbin2.example.org:9090
|
||||
< access-control-allow-methods: GET,POST,PATCH
|
||||
< access-control-allow-headers: Content-Type,Token,Authorization
|
||||
< access-control-expose-headers: X-Custom-Header,X-Env-UTM
|
||||
< access-control-allow-credentials: true
|
||||
< access-control-max-age: 3600
|
||||
< vary: Origin
|
||||
< date: Tue, 23 May 2023 11:41:28 GMT
|
||||
< server: istio-envoy
|
||||
< content-length: 0
|
||||
@@ -190,18 +211,18 @@ curl -v -X OPTIONS -H "Origin: http://httpbin2.example.org:9090" -H "Host: http
|
||||
|
||||
#### 非法 CORS Origin 预检请求
|
||||
|
||||
非法预检请求会返回 `204 No Content`,但不包含 `access-control-allow-*` 等 CORS policy 响应头,浏览器会判定预检失败。
|
||||
|
||||
```shell
|
||||
curl -v -X OPTIONS -H "Origin: http://httpbin2.example.org" -H "Host: httpbin.example.com" -H "Access-Control-Request-Method: GET" http://127.0.0.1/anything/get\?foo\=1
|
||||
|
||||
HTTP/1.1 403 Forbidden
|
||||
< content-length: 70
|
||||
< content-type: text/plain
|
||||
< x-cors-version: 1.0.0
|
||||
< HTTP/1.1 204 No Content
|
||||
< x-cors-trace: trace
|
||||
< date: Tue, 23 May 2023 11:27:01 GMT
|
||||
< server: istio-envoy
|
||||
< content-length: 0
|
||||
<
|
||||
* Connection #0 to host 127.0.0.1 left intact
|
||||
Invalid CORS request
|
||||
```
|
||||
|
||||
#### 非法 CORS Method 预检请求
|
||||
@@ -209,15 +230,13 @@ Invalid CORS request
|
||||
```shell
|
||||
curl -v -X OPTIONS -H "Origin: http://httpbin2.example.org:9090" -H "Host: httpbin.example.com" -H "Access-Control-Request-Method: DELETE" http://127.0.0.1/anything/get\?foo\=1
|
||||
|
||||
< HTTP/1.1 403 Forbidden
|
||||
< content-length: 49
|
||||
< content-type: text/plain
|
||||
< x-cors-version: 1.0.0
|
||||
< HTTP/1.1 204 No Content
|
||||
< x-cors-trace: trace
|
||||
< date: Tue, 23 May 2023 11:28:51 GMT
|
||||
< server: istio-envoy
|
||||
< content-length: 0
|
||||
<
|
||||
* Connection #0 to host 127.0.0.1 left intact
|
||||
Invalid CORS request
|
||||
```
|
||||
|
||||
#### 非法 CORS Header 预检请求
|
||||
@@ -225,15 +244,13 @@ Invalid CORS request
|
||||
```shell
|
||||
curl -v -X OPTIONS -H "Origin: http://httpbin2.example.org:9090" -H "Host: httpbin.example.com" -H "Access-Control-Request-Method: GET" -H "Access-Control-Request-Headers: TokenView" http://127.0.0.1/anything/get\?foo\=1
|
||||
|
||||
< HTTP/1.1 403 Forbidden
|
||||
< content-length: 52
|
||||
< content-type: text/plain
|
||||
< x-cors-version: 1.0.0
|
||||
< HTTP/1.1 204 No Content
|
||||
< x-cors-trace: trace
|
||||
< date: Tue, 23 May 2023 11:31:03 GMT
|
||||
< server: istio-envoy
|
||||
< content-length: 0
|
||||
<
|
||||
* Connection #0 to host 127.0.0.1 left intact
|
||||
Invalid CORS request
|
||||
```
|
||||
|
||||
## 参考文档
|
||||
|
||||
@@ -7,23 +7,42 @@ description: Cross-Origin Resource Sharing plugin configuration reference
|
||||
The `cors` plugin can enable CORS (Cross-Origin Resource Sharing) HTTP response headers for the server.
|
||||
|
||||
## Execution Attributes
|
||||
Plugin execution phase: `Authorization Phase`
|
||||
Plugin execution priority: `340`
|
||||
Plugin execution phase: `AUTHN`
|
||||
Plugin execution priority: `2000`
|
||||
|
||||
## Release Notes
|
||||
|
||||
### 2.0.1
|
||||
|
||||
Compared with `2.0.0`, this version aligns the plugin behavior with browser CORS semantics and changes the plugin execution priority to `2000`.
|
||||
|
||||
Changes and upgrade notes:
|
||||
|
||||
* When the Origin or Method of an actual CORS request does not match the configuration, the plugin no longer returns a direct `403`. The request continues upstream; the plugin does not add CORS allow response headers and removes upstream CORS policy response headers, so browsers block the result according to CORS rules. If monitoring or client logic depends on a gateway-level `403`, adjust it before upgrading.
|
||||
* CORS preflight requests are answered directly with `204 No Content`. Invalid preflight responses omit `Access-Control-Allow-*`, `Access-Control-Expose-Headers`, `Access-Control-Allow-Credentials`, and `Access-Control-Max-Age`, so browsers treat the preflight as failed. If existing logic depends on invalid preflights returning `403`, adjust it before upgrading.
|
||||
* Same-origin `OPTIONS` requests continue upstream even when they carry preflight-like request headers, preventing the CORS plugin from intercepting them incorrectly.
|
||||
* `allow_methods: ["*"]` echoes the current `Access-Control-Request-Method` in preflight responses; `allow_headers: ["*"]` echoes normalized `Access-Control-Request-Headers` and omits `Access-Control-Allow-Headers` when no request headers were requested.
|
||||
* Default Method/Header values are split by comma and trimmed, preventing defaults from being treated as one unmatchable item.
|
||||
* Origin pattern matching is anchored to the full Origin value, avoiding accidental matches such as `http://api.example.com.evil.com` for `http://*.example.com`. If an existing configuration relied on partial matching, update it to an explicit Origin pattern.
|
||||
* When `Access-Control-Allow-Origin` is a specific Origin instead of `*`, the plugin merges `Vary: Origin` into the response to prevent cache reuse with the wrong CORS headers.
|
||||
* `expose_headers: ["*"]` remains accepted for compatibility when credentials are allowed, but browsers treat `Access-Control-Expose-Headers: *` as a literal header name for credentialed requests instead of exposing all headers.
|
||||
|
||||
## Configuration Fields
|
||||
| Name | Data Type | Required | Default Value | Description |
|
||||
|-----------------------|------------------|----------|-----------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| allow_origins | array of string | Optional | * | Allowed Origins for cross-origin access, formatted as scheme://host:port, for example, http://example.com:8081. When allow_credentials is false, * can be used to allow all Origins through. |
|
||||
| allow_origin_patterns | array of string | Optional | - | Patterns for matching allowed Origins for cross-origin access, using * to match domain or port, <br/>for example http://*.example.com -- matches domain, http://*.example.com:[8080,9090] -- matches domain and specified ports, http://*.example.com:[*] -- matches domain and all ports. A single * indicates matching all domains and ports. |
|
||||
| allow_methods | array of string | Optional | GET, PUT, POST, DELETE, PATCH, OPTIONS | Allowed Methods for cross-origin access, for example: GET, POST, etc. * can be used to indicate all Methods are allowed. |
|
||||
| allow_headers | array of string | Optional | DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,<br/>If-Modified-Since,Cache-Control,Content-Type,Authorization | Allowed Headers for the requester to carry that are not part of CORS specifications during cross-origin access. * can be used to indicate any Header is allowed. |
|
||||
| expose_headers | array of string | Optional | - | Allowed Headers for the responder to carry that are not part of CORS specifications during cross-origin access. * can be used to indicate any Header is allowed. |
|
||||
| allow_methods | array of string | Optional | GET, PUT, POST, DELETE, PATCH, OPTIONS | Allowed Methods for cross-origin access, for example: GET, POST, etc. `*` can be used to indicate all Methods are allowed; preflight responses echo the current `Access-Control-Request-Method`. |
|
||||
| allow_headers | array of string | Optional | DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,<br/>If-Modified-Since,Cache-Control,Content-Type,Authorization | Allowed Headers for the requester to carry that are not part of CORS specifications during cross-origin access. `*` can be used to indicate any Header is allowed; preflight responses echo normalized `Access-Control-Request-Headers`, and omit `Access-Control-Allow-Headers` when no request headers were requested. |
|
||||
| expose_headers | array of string | Optional | - | Allowed Headers for the responder to expose during cross-origin access. `*` can be used, but for credentialed requests browsers treat `Access-Control-Expose-Headers: *` as a literal header name instead of exposing all headers. |
|
||||
| allow_credentials | bool | Optional | false | Whether to allow the requester to carry credentials (e.g. Cookies) during cross-origin access. According to CORS specifications, if this option is set to true, * cannot be used for allow_origins, replace it with allow_origin_patterns. |
|
||||
| max_age | number | Optional | 86400 seconds | Maximum time for browsers to cache CORS results, in seconds. <br/>Within this time frame, browsers will reuse the previous inspection results. |
|
||||
> Note
|
||||
> * allow_credentials is a very sensitive option, please enable it with caution. Once enabled, allow_credentials and allow_origins cannot both be *, if both are set, the allow_origins value of "*" takes effect.
|
||||
> * allow_origins and allow_origin_patterns can be set simultaneously. First, check if allow_origins matches, then check if allow_origin_patterns matches.
|
||||
> * Illegal CORS requests will return HTTP status code 403, with the response body content as "Invalid CORS request".
|
||||
> * For actual CORS requests, if the Origin or Method does not match the configuration, the plugin continues the request upstream, does not add CORS response headers, and removes upstream CORS policy response headers. Browsers then block the result according to CORS rules.
|
||||
> * For CORS preflight requests, valid requests are answered directly with `204 No Content` and the configured CORS response headers. Invalid requests are answered directly with `204 No Content`, but without `Access-Control-Allow-*`, `Access-Control-Expose-Headers`, `Access-Control-Allow-Credentials`, or `Access-Control-Max-Age`; browsers treat the preflight as failed.
|
||||
> * When `Access-Control-Allow-Origin` is a specific Origin instead of `*`, the plugin merges `Vary: Origin` into the response to prevent cache reuse with the wrong CORS headers.
|
||||
|
||||
## Configuration Examples
|
||||
### Allow all cross-origin access, without allowing the requester to carry credentials
|
||||
@@ -142,7 +161,7 @@ spec:
|
||||
configDisable: false
|
||||
ingress:
|
||||
- ingress-cors-httpbin
|
||||
url: oci://higress-registry.cn-hangzhou.cr.aliyuncs.com/plugins/cors:1.0.0
|
||||
url: oci://higress-registry.cn-hangzhou.cr.aliyuncs.com/plugins/cors:2.0.1
|
||||
imagePullPolicy: Always
|
||||
```
|
||||
|
||||
@@ -151,23 +170,25 @@ spec:
|
||||
```shell
|
||||
curl -v -H "Origin: http://httpbin2.example.org:9090" -H "Host: httpbin.example.com" http://127.0.0.1/anything/get\?foo\=1
|
||||
< HTTP/1.1 200 OK
|
||||
> x-cors-version: 1.0.0
|
||||
> x-cors-version: 2.0.1
|
||||
> access-control-allow-origin: http://httpbin2.example.org:9090
|
||||
> access-control-expose-headers: X-Custom-Header,X-Env-UTM
|
||||
> access-control-allow-credentials: true
|
||||
> vary: Origin
|
||||
```
|
||||
|
||||
#### Preflight Request
|
||||
```shell
|
||||
curl -v -X OPTIONS -H "Origin: http://httpbin2.example.org:9090" -H "Host: httpbin.example.com" -H "Access-Control-Request-Method: POST" -H "Access-Control-Request-Headers: Content-Type, Token" http://127.0.0.1/anything/get\?foo\=1
|
||||
< HTTP/1.1 200 OK
|
||||
< x-cors-version: 1.0.0
|
||||
< HTTP/1.1 204 No Content
|
||||
< x-cors-trace: trace
|
||||
< access-control-allow-origin: http://httpbin2.example.org:9090
|
||||
< access-control-allow-methods: GET,POST,PATCH
|
||||
< access-control-allow-headers: Content-Type,Token,Authorization
|
||||
< access-control-expose-headers: X-Custom-Header,X-Env-UTM
|
||||
< access-control-allow-credentials: true
|
||||
< access-control-max-age: 3600
|
||||
< vary: Origin
|
||||
< date: Tue, 23 May 2023 11:41:28 GMT
|
||||
< server: istio-envoy
|
||||
< content-length: 0
|
||||
@@ -177,45 +198,41 @@ curl -v -X OPTIONS -H "Origin: http://httpbin2.example.org:9090" -H "Host: httpb
|
||||
```
|
||||
|
||||
#### Illegal CORS Origin Preflight Request
|
||||
Invalid preflight requests return `204 No Content` without `access-control-allow-*` CORS policy response headers, so browsers treat the preflight as failed.
|
||||
|
||||
```shell
|
||||
curl -v -X OPTIONS -H "Origin: http://httpbin2.example.org" -H "Host: httpbin.example.com" -H "Access-Control-Request-Method: GET" http://127.0.0.1/anything/get\?foo\=1
|
||||
HTTP/1.1 403 Forbidden
|
||||
< content-length: 70
|
||||
< content-type: text/plain
|
||||
< x-cors-version: 1.0.0
|
||||
< HTTP/1.1 204 No Content
|
||||
< x-cors-trace: trace
|
||||
< date: Tue, 23 May 2023 11:27:01 GMT
|
||||
< server: istio-envoy
|
||||
< content-length: 0
|
||||
<
|
||||
* Connection #0 to host 127.0.0.1 left intact
|
||||
Invalid CORS request
|
||||
```
|
||||
|
||||
#### Illegal CORS Method Preflight Request
|
||||
```shell
|
||||
curl -v -X OPTIONS -H "Origin: http://httpbin2.example.org:9090" -H "Host: httpbin.example.com" -H "Access-Control-Request-Method: DELETE" http://127.0.0.1/anything/get\?foo\=1
|
||||
< HTTP/1.1 403 Forbidden
|
||||
< content-length: 49
|
||||
< content-type: text/plain
|
||||
< x-cors-version: 1.0.0
|
||||
< HTTP/1.1 204 No Content
|
||||
< x-cors-trace: trace
|
||||
< date: Tue, 23 May 2023 11:28:51 GMT
|
||||
< server: istio-envoy
|
||||
< content-length: 0
|
||||
<
|
||||
* Connection #0 to host 127.0.0.1 left intact
|
||||
Invalid CORS request
|
||||
```
|
||||
|
||||
#### Illegal CORS Header Preflight Request
|
||||
```shell
|
||||
curl -v -X OPTIONS -H "Origin: http://httpbin2.example.org:9090" -H "Host: httpbin.example.com" -H "Access-Control-Request-Method: GET" -H "Access-Control-Request-Headers: TokenView" http://127.0.0.1/anything/get\?foo\=1
|
||||
< HTTP/1.1 403 Forbidden
|
||||
< content-length: 52
|
||||
< content-type: text/plain
|
||||
< x-cors-version: 1.0.0
|
||||
< HTTP/1.1 204 No Content
|
||||
< x-cors-trace: trace
|
||||
< date: Tue, 23 May 2023 11:31:03 GMT
|
||||
< server: istio-envoy
|
||||
< content-length: 0
|
||||
<
|
||||
* Connection #0 to host 127.0.0.1 left intact
|
||||
Invalid CORS request
|
||||
```
|
||||
|
||||
## Reference Documents
|
||||
|
||||
@@ -1 +1 @@
|
||||
1.0.0
|
||||
2.0.1
|
||||
|
||||
@@ -24,10 +24,9 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
defaultMatchAll = "*"
|
||||
defaultAllowMethods = "GET, PUT, POST, DELETE, PATCH, OPTIONS"
|
||||
defaultAllAllowMethods = "GET, PUT, POST, DELETE, PATCH, OPTIONS, HEAD, TRACE, CONNECT"
|
||||
defaultAllowHeaders = "DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With," +
|
||||
defaultMatchAll = "*"
|
||||
defaultAllowMethods = "GET, PUT, POST, DELETE, PATCH, OPTIONS"
|
||||
defaultAllowHeaders = "DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With," +
|
||||
"If-Modified-Since,Cache-Control,Content-Type,Authorization"
|
||||
defaultMaxAge = 86400
|
||||
protocolHttpName = "http"
|
||||
@@ -80,6 +79,7 @@ func newOriginPatternFromString(declaredPattern string) OriginPattern {
|
||||
patternValue += ":(" + strings.ReplaceAll(portList, ",", "|") + ")"
|
||||
}
|
||||
}
|
||||
patternValue = "^" + patternValue + "$"
|
||||
|
||||
return OriginPattern{
|
||||
declaredPattern: declaredPattern,
|
||||
@@ -143,7 +143,7 @@ type HttpCorsContext struct {
|
||||
}
|
||||
|
||||
func (c *CorsConfig) GetVersion() string {
|
||||
return "1.0.0"
|
||||
return "2.0.1"
|
||||
}
|
||||
|
||||
func (c *CorsConfig) FillDefaultValues() {
|
||||
@@ -151,10 +151,10 @@ func (c *CorsConfig) FillDefaultValues() {
|
||||
c.allowOrigins = []string{defaultMatchAll}
|
||||
}
|
||||
if len(c.allowHeaders) == 0 {
|
||||
c.allowHeaders = []string{defaultAllowHeaders}
|
||||
c.allowHeaders = splitCommaSeparatedValues(defaultAllowHeaders)
|
||||
}
|
||||
if len(c.allowMethods) == 0 {
|
||||
c.allowMethods = strings.Split(defaultAllowMethods, ",")
|
||||
c.allowMethods = splitCommaSeparatedValues(defaultAllowMethods)
|
||||
}
|
||||
if c.maxAge == 0 {
|
||||
c.maxAge = defaultMaxAge
|
||||
@@ -360,21 +360,29 @@ func (c *CorsConfig) checkHeaders(requestHeaders string) (string, bool) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
allowHeaders := splitCommaSeparatedValues(strings.Join(c.allowHeaders, ","))
|
||||
if len(allowHeaders) == 0 {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// Return normalized request headers when allowHeaders contains *.
|
||||
if containsMatchAll(allowHeaders) {
|
||||
return strings.Join(splitCommaSeparatedValues(requestHeaders), ","), true
|
||||
}
|
||||
|
||||
if len(requestHeaders) == 0 {
|
||||
return strings.Join(c.allowHeaders, ","), true
|
||||
return strings.Join(allowHeaders, ","), true
|
||||
}
|
||||
|
||||
// Return all request headers when allowHeaders contains *
|
||||
if c.allowHeaders[0] == defaultMatchAll {
|
||||
return requestHeaders, true
|
||||
checkHeaders := splitCommaSeparatedValues(requestHeaders)
|
||||
if len(checkHeaders) == 0 {
|
||||
return strings.Join(allowHeaders, ","), true
|
||||
}
|
||||
|
||||
checkHeaders := strings.Split(requestHeaders, ",")
|
||||
// Each request header should be existed in allowHeaders configuration
|
||||
for _, h := range checkHeaders {
|
||||
isExist := false
|
||||
for _, allowHeader := range c.allowHeaders {
|
||||
if strings.ToLower(h) == strings.ToLower(allowHeader) {
|
||||
for _, allowHeader := range allowHeaders {
|
||||
if strings.EqualFold(h, allowHeader) {
|
||||
isExist = true
|
||||
break
|
||||
}
|
||||
@@ -391,22 +399,48 @@ func (c *CorsConfig) checkMethods(requestMethod string) (string, bool) {
|
||||
if len(requestMethod) == 0 {
|
||||
return "", false
|
||||
}
|
||||
requestMethod = strings.TrimSpace(requestMethod)
|
||||
if len(requestMethod) == 0 {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// Find method existed in allowMethods configuration
|
||||
for _, method := range c.allowMethods {
|
||||
allowMethods := splitCommaSeparatedValues(strings.Join(c.allowMethods, ","))
|
||||
for _, method := range allowMethods {
|
||||
if method == defaultMatchAll {
|
||||
return defaultAllAllowMethods, true
|
||||
return requestMethod, true
|
||||
}
|
||||
if strings.ToLower(method) == strings.ToLower(requestMethod) {
|
||||
return strings.Join(c.allowMethods, ","), true
|
||||
if strings.EqualFold(method, requestMethod) {
|
||||
return strings.Join(allowMethods, ","), true
|
||||
}
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
|
||||
func (c *CorsConfig) isPreFlight(origin, method, controllerRequestMethod string) bool {
|
||||
return len(origin) > 0 && strings.ToLower(method) == strings.ToLower(HttpMethodOptions) && len(controllerRequestMethod) > 0
|
||||
func splitCommaSeparatedValues(values string) []string {
|
||||
parts := strings.Split(values, ",")
|
||||
result := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if len(part) > 0 {
|
||||
result = append(result, part)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func containsMatchAll(values []string) bool {
|
||||
for _, value := range values {
|
||||
if value == defaultMatchAll {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *CorsConfig) isPreFlight(origin, method, controlRequestMethod string) bool {
|
||||
return len(origin) > 0 && strings.ToLower(method) == strings.ToLower(HttpMethodOptions) && len(controlRequestMethod) > 0
|
||||
}
|
||||
|
||||
func (c *CorsConfig) isCorsRequest(scheme, host, origin string) bool {
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
// Copyright (c) 2022 Alibaba Group Holding Ltd.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCorsConfigIssue1743FillDefaultValuesSplitsMethodsAndHeaders(t *testing.T) {
|
||||
c := &CorsConfig{}
|
||||
c.FillDefaultValues()
|
||||
|
||||
assert.Equal(t, []string{"GET", "PUT", "POST", "DELETE", "PATCH", "OPTIONS"}, c.allowMethods)
|
||||
assert.Contains(t, c.allowHeaders, "Content-Type")
|
||||
assert.Contains(t, c.allowHeaders, "Authorization")
|
||||
assert.NotContains(t, c.allowHeaders, defaultAllowHeaders)
|
||||
|
||||
allowMethods, methodOk := c.checkMethods("POST")
|
||||
assert.True(t, methodOk)
|
||||
assert.Equal(t, "GET,PUT,POST,DELETE,PATCH,OPTIONS", allowMethods)
|
||||
|
||||
allowHeaders, headerOk := c.checkHeaders("Content-Type, Authorization")
|
||||
assert.True(t, headerOk)
|
||||
assert.Contains(t, allowHeaders, "Content-Type")
|
||||
assert.Contains(t, allowHeaders, "Authorization")
|
||||
}
|
||||
|
||||
func TestCorsConfigIssue1743CheckHeadersTrimsRequestTokens(t *testing.T) {
|
||||
c := &CorsConfig{
|
||||
allowHeaders: []string{"Content-Type", "Authorization"},
|
||||
}
|
||||
|
||||
allowHeaders, ok := c.checkHeaders("Content-Type, Authorization")
|
||||
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "Content-Type,Authorization", allowHeaders)
|
||||
}
|
||||
|
||||
func TestCorsConfigIssue1743WildcardMethodsEchoRequestedMethod(t *testing.T) {
|
||||
c := &CorsConfig{
|
||||
allowMethods: []string{"*"},
|
||||
}
|
||||
|
||||
allowMethods, ok := c.checkMethods("PROPFIND")
|
||||
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "PROPFIND", allowMethods)
|
||||
}
|
||||
|
||||
func TestCorsConfigIssue1743WildcardHeadersEchoTrimmedRequestHeaders(t *testing.T) {
|
||||
c := &CorsConfig{
|
||||
allowHeaders: []string{"*"},
|
||||
}
|
||||
|
||||
allowHeaders, ok := c.checkHeaders(" Content-Type, Authorization, ")
|
||||
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "Content-Type,Authorization", allowHeaders)
|
||||
}
|
||||
|
||||
func TestCorsConfigIssue1743WildcardHeadersOmitAllowHeadersWhenNoRequestHeaders(t *testing.T) {
|
||||
c := &CorsConfig{
|
||||
allowHeaders: []string{"*"},
|
||||
}
|
||||
|
||||
allowHeaders, ok := c.checkHeaders("")
|
||||
|
||||
assert.True(t, ok)
|
||||
assert.Empty(t, allowHeaders)
|
||||
}
|
||||
|
||||
func TestCorsConfigIssue1743ProcessWildcardPreflight(t *testing.T) {
|
||||
c := &CorsConfig{
|
||||
allowOrigins: []string{"*"},
|
||||
allowMethods: []string{"*"},
|
||||
allowHeaders: []string{"*"},
|
||||
}
|
||||
|
||||
httpCorsContext, err := c.Process("https", "api.example.com", "OPTIONS", [][2]string{
|
||||
{HeaderOrigin, "https://client.example.com"},
|
||||
{HeaderControlRequestMethod, "PROPFIND"},
|
||||
{HeaderControlRequestHeaders, " Content-Type, Authorization, "},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, httpCorsContext.IsValid)
|
||||
assert.True(t, httpCorsContext.IsPreFlight)
|
||||
assert.True(t, httpCorsContext.IsCorsRequest)
|
||||
assert.Equal(t, "PROPFIND", httpCorsContext.AllowMethods)
|
||||
assert.Equal(t, "Content-Type,Authorization", httpCorsContext.AllowHeaders)
|
||||
}
|
||||
|
||||
func TestCorsConfigIssue1743ProcessWildcardPreflightOmitsAllowHeadersWithoutRequestHeaders(t *testing.T) {
|
||||
c := &CorsConfig{
|
||||
allowOrigins: []string{"*"},
|
||||
allowMethods: []string{"*"},
|
||||
allowHeaders: []string{"*"},
|
||||
}
|
||||
|
||||
httpCorsContext, err := c.Process("https", "api.example.com", "OPTIONS", [][2]string{
|
||||
{HeaderOrigin, "https://client.example.com"},
|
||||
{HeaderControlRequestMethod, "PROPFIND"},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, httpCorsContext.IsValid)
|
||||
assert.Equal(t, "PROPFIND", httpCorsContext.AllowMethods)
|
||||
assert.Empty(t, httpCorsContext.AllowHeaders)
|
||||
}
|
||||
|
||||
func TestCorsConfigIssue1743OriginPatternAnchored(t *testing.T) {
|
||||
c := &CorsConfig{
|
||||
allowOriginPatterns: []OriginPattern{
|
||||
newOriginPatternFromString("http://*.example.com"),
|
||||
},
|
||||
}
|
||||
|
||||
allowOrigin, ok := c.checkOrigin("http://api.example.com")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "http://api.example.com", allowOrigin)
|
||||
|
||||
allowOrigin, ok = c.checkOrigin("http://api.example.com.evil.com")
|
||||
assert.False(t, ok)
|
||||
assert.Empty(t, allowOrigin)
|
||||
}
|
||||
|
||||
func TestCorsConfigIssue1743OriginPatternWithPortsAnchored(t *testing.T) {
|
||||
c := &CorsConfig{
|
||||
allowOriginPatterns: []OriginPattern{
|
||||
newOriginPatternFromString("http://*.example.com:[8080,9090]"),
|
||||
},
|
||||
}
|
||||
|
||||
allowOrigin, ok := c.checkOrigin("http://api.example.com:8080")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "http://api.example.com:8080", allowOrigin)
|
||||
|
||||
allowOrigin, ok = c.checkOrigin("http://api.example.com:8080.evil.com")
|
||||
assert.False(t, ok)
|
||||
assert.Empty(t, allowOrigin)
|
||||
}
|
||||
|
||||
func TestCorsConfigIssue1743ExposeHeadersWildcardWithCredentialsAccepted(t *testing.T) {
|
||||
c := &CorsConfig{}
|
||||
|
||||
c.AddExposeHeader("*")
|
||||
err := c.SetAllowCredentials(true)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"*"}, c.exposeHeaders)
|
||||
assert.True(t, c.allowCredentials)
|
||||
}
|
||||
@@ -143,35 +143,35 @@ func TestCorsConfig_isPreFlight(t *testing.T) {
|
||||
name string
|
||||
origin string
|
||||
method string
|
||||
controllerRequestMethod string
|
||||
controlRequestMethod string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "blank case",
|
||||
origin: "",
|
||||
method: "",
|
||||
controllerRequestMethod: "",
|
||||
controlRequestMethod:"",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "normal case",
|
||||
origin: "http://httpbin.example.com",
|
||||
method: "Options",
|
||||
controllerRequestMethod: "PUT",
|
||||
controlRequestMethod:"PUT",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "bad case with diff method",
|
||||
origin: "http://httpbin.example.com",
|
||||
method: "GET",
|
||||
controllerRequestMethod: "PUT",
|
||||
controlRequestMethod:"PUT",
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
c := &CorsConfig{}
|
||||
assert.Equalf(t, tt.want, c.isPreFlight(tt.origin, tt.method, tt.controllerRequestMethod), "isPreFlight(%v, %v, %v)", tt.origin, tt.method, tt.controllerRequestMethod)
|
||||
assert.Equalf(t, tt.want, c.isPreFlight(tt.origin, tt.method, tt.controlRequestMethod), "isPreFlight(%v, %v, %v)", tt.origin, tt.method, tt.controlRequestMethod)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -188,7 +188,7 @@ func TestCorsConfig_checkMethods(t *testing.T) {
|
||||
name: "default *",
|
||||
allowMethods: []string{"*"},
|
||||
requestMethod: "GET",
|
||||
wantMethods: defaultAllAllowMethods,
|
||||
wantMethods: "GET",
|
||||
wantOk: true,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -63,5 +63,5 @@ spec:
|
||||
configDisable: false
|
||||
ingress:
|
||||
- ingress-cors-httpbin
|
||||
url: oci://docker.io/2456868764/cors:1.0.0
|
||||
imagePullPolicy: Always
|
||||
url: oci://higress-registry.cn-hangzhou.cr.aliyuncs.com/plugins/cors:2.0.1
|
||||
imagePullPolicy: Always
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"cors/config"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm"
|
||||
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm/types"
|
||||
@@ -28,6 +29,11 @@ import (
|
||||
|
||||
func main() {}
|
||||
|
||||
const (
|
||||
headerVary = "Vary"
|
||||
varyOrigin = "Origin"
|
||||
)
|
||||
|
||||
func init() {
|
||||
wrapper.SetCtx(
|
||||
"cors",
|
||||
@@ -94,19 +100,17 @@ func onHttpRequestHeaders(ctx wrapper.HttpContext, corsConfig config.CorsConfig,
|
||||
// Set HttpContext
|
||||
ctx.SetContext(config.HttpContextKey, httpCorsContext)
|
||||
|
||||
// Response forbidden when it is not valid cors request
|
||||
if !httpCorsContext.IsValid {
|
||||
headers := make([][2]string, 0)
|
||||
headers = append(headers, [2]string{config.HeaderPluginTrace, "trace"})
|
||||
proxywasm.SendHttpResponseWithDetail(http.StatusForbidden, "cors.forbidden", headers, []byte("Invalid CORS request"), -1)
|
||||
return types.ActionPause
|
||||
}
|
||||
|
||||
// Response directly when it is cors preflight request
|
||||
if httpCorsContext.IsPreFlight {
|
||||
if httpCorsContext.IsCorsRequest && httpCorsContext.IsPreFlight {
|
||||
headers := make([][2]string, 0)
|
||||
headers = append(headers, [2]string{config.HeaderPluginTrace, "trace"})
|
||||
proxywasm.SendHttpResponseWithDetail(http.StatusOK, "cores.preflight", headers, nil, -1)
|
||||
if !httpCorsContext.IsValid {
|
||||
proxywasm.SendHttpResponseWithDetail(http.StatusNoContent, "cors.preflight.invalid", headers, nil, -1)
|
||||
return types.ActionPause
|
||||
}
|
||||
headers = append(headers, buildCorsHeaders(httpCorsContext)...)
|
||||
headers = appendVaryOriginHeader(headers, httpCorsContext.AllowOrigin)
|
||||
proxywasm.SendHttpResponseWithDetail(http.StatusNoContent, "cors.preflight", headers, nil, -1)
|
||||
return types.ActionPause
|
||||
}
|
||||
|
||||
@@ -120,6 +124,7 @@ func onHttpResponseHeaders(ctx wrapper.HttpContext, corsConfig config.CorsConfig
|
||||
// Remove upstream cors response headers if existed
|
||||
proxywasm.RemoveHttpResponseHeader(config.HeaderAccessControlAllowOrigin)
|
||||
proxywasm.RemoveHttpResponseHeader(config.HeaderAccessControlAllowMethods)
|
||||
proxywasm.RemoveHttpResponseHeader(config.HeaderAccessControlAllowHeaders)
|
||||
proxywasm.RemoveHttpResponseHeader(config.HeaderAccessControlExposeHeaders)
|
||||
proxywasm.RemoveHttpResponseHeader(config.HeaderAccessControlAllowCredentials)
|
||||
proxywasm.RemoveHttpResponseHeader(config.HeaderAccessControlMaxAge)
|
||||
@@ -140,24 +145,52 @@ func onHttpResponseHeaders(ctx wrapper.HttpContext, corsConfig config.CorsConfig
|
||||
}
|
||||
|
||||
// Add Cors headers when it is cors and valid request
|
||||
if len(httpCorsContext.AllowOrigin) > 0 {
|
||||
proxywasm.AddHttpResponseHeader(config.HeaderAccessControlAllowOrigin, httpCorsContext.AllowOrigin)
|
||||
}
|
||||
if len(httpCorsContext.AllowMethods) > 0 {
|
||||
proxywasm.AddHttpResponseHeader(config.HeaderAccessControlAllowMethods, httpCorsContext.AllowMethods)
|
||||
}
|
||||
if len(httpCorsContext.AllowHeaders) > 0 {
|
||||
proxywasm.AddHttpResponseHeader(config.HeaderAccessControlAllowHeaders, httpCorsContext.AllowHeaders)
|
||||
}
|
||||
if len(httpCorsContext.ExposeHeaders) > 0 {
|
||||
proxywasm.AddHttpResponseHeader(config.HeaderAccessControlExposeHeaders, httpCorsContext.ExposeHeaders)
|
||||
}
|
||||
if httpCorsContext.AllowCredentials {
|
||||
proxywasm.AddHttpResponseHeader(config.HeaderAccessControlAllowCredentials, "true")
|
||||
}
|
||||
if httpCorsContext.MaxAge > 0 {
|
||||
proxywasm.AddHttpResponseHeader(config.HeaderAccessControlMaxAge, fmt.Sprintf("%d", httpCorsContext.MaxAge))
|
||||
for _, header := range buildCorsHeaders(httpCorsContext) {
|
||||
proxywasm.AddHttpResponseHeader(header[0], header[1])
|
||||
}
|
||||
addVaryOriginHeader(httpCorsContext.AllowOrigin)
|
||||
|
||||
return types.ActionContinue
|
||||
}
|
||||
|
||||
func buildCorsHeaders(httpCorsContext config.HttpCorsContext) [][2]string {
|
||||
headers := make([][2]string, 0, 6)
|
||||
if len(httpCorsContext.AllowOrigin) > 0 {
|
||||
headers = append(headers, [2]string{config.HeaderAccessControlAllowOrigin, httpCorsContext.AllowOrigin})
|
||||
}
|
||||
if len(httpCorsContext.AllowMethods) > 0 {
|
||||
headers = append(headers, [2]string{config.HeaderAccessControlAllowMethods, httpCorsContext.AllowMethods})
|
||||
}
|
||||
if len(httpCorsContext.AllowHeaders) > 0 {
|
||||
headers = append(headers, [2]string{config.HeaderAccessControlAllowHeaders, httpCorsContext.AllowHeaders})
|
||||
}
|
||||
if len(httpCorsContext.ExposeHeaders) > 0 {
|
||||
headers = append(headers, [2]string{config.HeaderAccessControlExposeHeaders, httpCorsContext.ExposeHeaders})
|
||||
}
|
||||
if httpCorsContext.AllowCredentials {
|
||||
headers = append(headers, [2]string{config.HeaderAccessControlAllowCredentials, "true"})
|
||||
}
|
||||
if httpCorsContext.MaxAge > 0 {
|
||||
headers = append(headers, [2]string{config.HeaderAccessControlMaxAge, fmt.Sprintf("%d", httpCorsContext.MaxAge)})
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
func appendVaryOriginHeader(headers [][2]string, allowOrigin string) [][2]string {
|
||||
if !shouldVaryOrigin(allowOrigin) {
|
||||
return headers
|
||||
}
|
||||
return append(headers, [2]string{headerVary, varyOrigin})
|
||||
}
|
||||
|
||||
func addVaryOriginHeader(allowOrigin string) {
|
||||
if !shouldVaryOrigin(allowOrigin) {
|
||||
return
|
||||
}
|
||||
proxywasm.AddHttpResponseHeader(headerVary, varyOrigin)
|
||||
}
|
||||
|
||||
func shouldVaryOrigin(allowOrigin string) bool {
|
||||
allowOrigin = strings.TrimSpace(allowOrigin)
|
||||
return len(allowOrigin) > 0 && allowOrigin != "*"
|
||||
}
|
||||
|
||||
154
plugins/wasm-go/extensions/cors/main_issue1743_test.go
Normal file
154
plugins/wasm-go/extensions/cors/main_issue1743_test.go
Normal file
@@ -0,0 +1,154 @@
|
||||
// Copyright (c) 2022 Alibaba Group Holding Ltd.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"cors/config"
|
||||
|
||||
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm/types"
|
||||
"github.com/higress-group/wasm-go/pkg/test"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestIssue1743InvalidActualCorsRequestContinuesUpstream(t *testing.T) {
|
||||
test.RunTest(t, func(t *testing.T) {
|
||||
host, status := test.NewTestHost(basicCorsConfig)
|
||||
defer host.Reset()
|
||||
require.Equal(t, types.OnPluginStartStatusOK, status)
|
||||
|
||||
action := host.CallOnHttpRequestHeaders([][2]string{
|
||||
{":scheme", "http"},
|
||||
{":authority", "example.com"},
|
||||
{":path", "/api/test"},
|
||||
{":method", "GET"},
|
||||
{"origin", "http://invalid.com"},
|
||||
})
|
||||
|
||||
require.Equal(t, types.ActionContinue, action)
|
||||
require.Nil(t, host.GetLocalResponse())
|
||||
|
||||
action = host.CallOnHttpResponseHeaders([][2]string{
|
||||
{":status", "200"},
|
||||
{config.HeaderAccessControlAllowOrigin, "http://invalid.com"},
|
||||
{config.HeaderAccessControlAllowMethods, "GET,POST"},
|
||||
{config.HeaderAccessControlAllowHeaders, "X-Upstream"},
|
||||
{config.HeaderAccessControlExposeHeaders, "X-Upstream-Expose"},
|
||||
{config.HeaderAccessControlAllowCredentials, "true"},
|
||||
{config.HeaderAccessControlMaxAge, "600"},
|
||||
})
|
||||
|
||||
require.Equal(t, types.ActionContinue, action)
|
||||
responseHeaders := host.GetResponseHeaders()
|
||||
require.False(t, test.HasHeader(responseHeaders, config.HeaderAccessControlAllowOrigin))
|
||||
require.False(t, test.HasHeader(responseHeaders, config.HeaderAccessControlAllowMethods))
|
||||
require.False(t, test.HasHeader(responseHeaders, config.HeaderAccessControlAllowHeaders))
|
||||
require.False(t, test.HasHeader(responseHeaders, config.HeaderAccessControlExposeHeaders))
|
||||
require.False(t, test.HasHeader(responseHeaders, config.HeaderAccessControlAllowCredentials))
|
||||
require.False(t, test.HasHeader(responseHeaders, config.HeaderAccessControlMaxAge))
|
||||
|
||||
host.CompleteHttp()
|
||||
})
|
||||
}
|
||||
|
||||
func TestIssue1743SameOriginOptionsWithPreflightHeadersContinuesUpstream(t *testing.T) {
|
||||
test.RunTest(t, func(t *testing.T) {
|
||||
host, status := test.NewTestHost(basicCorsConfig)
|
||||
defer host.Reset()
|
||||
require.Equal(t, types.OnPluginStartStatusOK, status)
|
||||
|
||||
action := host.CallOnHttpRequestHeaders([][2]string{
|
||||
{":scheme", "http"},
|
||||
{":authority", "example.com"},
|
||||
{":path", "/api/test"},
|
||||
{":method", "OPTIONS"},
|
||||
{"origin", "http://example.com"},
|
||||
{"access-control-request-method", "POST"},
|
||||
})
|
||||
|
||||
require.Equal(t, types.ActionContinue, action)
|
||||
require.Nil(t, host.GetLocalResponse())
|
||||
|
||||
host.CompleteHttp()
|
||||
})
|
||||
}
|
||||
|
||||
func TestIssue1743ValidPreflightReturnsNoContentWithCorsHeaders(t *testing.T) {
|
||||
test.RunTest(t, func(t *testing.T) {
|
||||
host, status := test.NewTestHost(basicCorsConfig)
|
||||
defer host.Reset()
|
||||
require.Equal(t, types.OnPluginStartStatusOK, status)
|
||||
|
||||
action := host.CallOnHttpRequestHeaders([][2]string{
|
||||
{":scheme", "http"},
|
||||
{":authority", "api.example.com"},
|
||||
{":path", "/api/test"},
|
||||
{":method", "OPTIONS"},
|
||||
{"origin", "http://example.com"},
|
||||
{"access-control-request-method", "POST"},
|
||||
{"access-control-request-headers", "Content-Type"},
|
||||
})
|
||||
|
||||
require.Equal(t, types.ActionPause, action)
|
||||
localResponse := host.GetLocalResponse()
|
||||
require.NotNil(t, localResponse)
|
||||
require.Equal(t, uint32(http.StatusNoContent), localResponse.StatusCode)
|
||||
require.Equal(t, "cors.preflight", localResponse.StatusCodeDetail)
|
||||
require.Empty(t, localResponse.Data)
|
||||
require.True(t, test.HasHeaderWithValue(localResponse.Headers, config.HeaderPluginTrace, "trace"))
|
||||
require.True(t, test.HasHeaderWithValue(localResponse.Headers, config.HeaderAccessControlAllowOrigin, "http://example.com"))
|
||||
require.True(t, test.HasHeaderWithValue(localResponse.Headers, config.HeaderAccessControlAllowMethods, "GET,POST,OPTIONS"))
|
||||
require.True(t, test.HasHeaderWithValue(localResponse.Headers, config.HeaderAccessControlAllowHeaders, "Content-Type,Authorization"))
|
||||
require.True(t, test.HasHeaderWithValue(localResponse.Headers, config.HeaderAccessControlExposeHeaders, "X-Custom-Header"))
|
||||
require.True(t, test.HasHeaderWithValue(localResponse.Headers, config.HeaderAccessControlMaxAge, "3600"))
|
||||
|
||||
host.CompleteHttp()
|
||||
})
|
||||
}
|
||||
|
||||
func TestIssue1743InvalidPreflightReturnsNoContentWithoutCorsHeaders(t *testing.T) {
|
||||
test.RunTest(t, func(t *testing.T) {
|
||||
host, status := test.NewTestHost(basicCorsConfig)
|
||||
defer host.Reset()
|
||||
require.Equal(t, types.OnPluginStartStatusOK, status)
|
||||
|
||||
action := host.CallOnHttpRequestHeaders([][2]string{
|
||||
{":scheme", "http"},
|
||||
{":authority", "api.example.com"},
|
||||
{":path", "/api/test"},
|
||||
{":method", "OPTIONS"},
|
||||
{"origin", "http://invalid.com"},
|
||||
{"access-control-request-method", "POST"},
|
||||
})
|
||||
|
||||
require.Equal(t, types.ActionPause, action)
|
||||
localResponse := host.GetLocalResponse()
|
||||
require.NotNil(t, localResponse)
|
||||
require.Equal(t, uint32(http.StatusNoContent), localResponse.StatusCode)
|
||||
require.Equal(t, "cors.preflight.invalid", localResponse.StatusCodeDetail)
|
||||
require.Empty(t, localResponse.Data)
|
||||
require.True(t, test.HasHeaderWithValue(localResponse.Headers, config.HeaderPluginTrace, "trace"))
|
||||
require.False(t, test.HasHeader(localResponse.Headers, config.HeaderAccessControlAllowOrigin))
|
||||
require.False(t, test.HasHeader(localResponse.Headers, config.HeaderAccessControlAllowMethods))
|
||||
require.False(t, test.HasHeader(localResponse.Headers, config.HeaderAccessControlAllowHeaders))
|
||||
require.False(t, test.HasHeader(localResponse.Headers, config.HeaderAccessControlExposeHeaders))
|
||||
require.False(t, test.HasHeader(localResponse.Headers, config.HeaderAccessControlAllowCredentials))
|
||||
require.False(t, test.HasHeader(localResponse.Headers, config.HeaderAccessControlMaxAge))
|
||||
|
||||
host.CompleteHttp()
|
||||
})
|
||||
}
|
||||
@@ -243,8 +243,8 @@ func TestOnHttpRequestHeaders(t *testing.T) {
|
||||
{"origin", "http://invalid.com"},
|
||||
})
|
||||
|
||||
// 无效的 CORS 请求应该返回 ActionPause
|
||||
require.Equal(t, types.ActionPause, action)
|
||||
// 无效的实际 CORS 请求应该继续转发,由浏览器根据响应头执行 CORS 结果
|
||||
require.Equal(t, types.ActionContinue, action)
|
||||
|
||||
host.CompleteHttp()
|
||||
})
|
||||
|
||||
107
plugins/wasm-go/extensions/cors/main_vary_test.go
Normal file
107
plugins/wasm-go/extensions/cors/main_vary_test.go
Normal file
@@ -0,0 +1,107 @@
|
||||
// Copyright (c) 2022 Alibaba Group Holding Ltd.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cors/config"
|
||||
|
||||
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm/types"
|
||||
"github.com/higress-group/wasm-go/pkg/test"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestIssue1743SpecificOriginResponseAddsVaryOrigin(t *testing.T) {
|
||||
test.RunTest(t, func(t *testing.T) {
|
||||
host, status := test.NewTestHost(basicCorsConfig)
|
||||
defer host.Reset()
|
||||
require.Equal(t, types.OnPluginStartStatusOK, status)
|
||||
|
||||
action := host.CallOnHttpRequestHeaders([][2]string{
|
||||
{":scheme", "http"},
|
||||
{":authority", "api.example.com"},
|
||||
{":path", "/api/test"},
|
||||
{":method", "GET"},
|
||||
{"origin", "http://example.com"},
|
||||
})
|
||||
require.Equal(t, types.ActionContinue, action)
|
||||
|
||||
action = host.CallOnHttpResponseHeaders([][2]string{
|
||||
{":status", "200"},
|
||||
{"content-type", "application/json"},
|
||||
})
|
||||
require.Equal(t, types.ActionContinue, action)
|
||||
|
||||
responseHeaders := host.GetResponseHeaders()
|
||||
require.True(t, test.HasHeaderWithValue(responseHeaders, config.HeaderAccessControlAllowOrigin, "http://example.com"))
|
||||
require.True(t, test.HasHeaderWithValue(responseHeaders, headerVary, varyOrigin))
|
||||
|
||||
host.CompleteHttp()
|
||||
})
|
||||
}
|
||||
|
||||
func TestIssue1743SpecificOriginPreflightAddsVaryOrigin(t *testing.T) {
|
||||
test.RunTest(t, func(t *testing.T) {
|
||||
host, status := test.NewTestHost(basicCorsConfig)
|
||||
defer host.Reset()
|
||||
require.Equal(t, types.OnPluginStartStatusOK, status)
|
||||
|
||||
action := host.CallOnHttpRequestHeaders([][2]string{
|
||||
{":scheme", "http"},
|
||||
{":authority", "api.example.com"},
|
||||
{":path", "/api/test"},
|
||||
{":method", "OPTIONS"},
|
||||
{"origin", "http://example.com"},
|
||||
{"access-control-request-method", "POST"},
|
||||
{"access-control-request-headers", "Content-Type"},
|
||||
})
|
||||
require.Equal(t, types.ActionPause, action)
|
||||
|
||||
localResponse := host.GetLocalResponse()
|
||||
require.NotNil(t, localResponse)
|
||||
require.True(t, test.HasHeaderWithValue(localResponse.Headers, config.HeaderAccessControlAllowOrigin, "http://example.com"))
|
||||
require.True(t, test.HasHeaderWithValue(localResponse.Headers, headerVary, varyOrigin))
|
||||
|
||||
host.CompleteHttp()
|
||||
})
|
||||
}
|
||||
|
||||
func TestIssue1743AppendVaryOriginPreservesExistingVaryHeader(t *testing.T) {
|
||||
headers := appendVaryOriginHeader([][2]string{
|
||||
{headerVary, "Accept-Encoding"},
|
||||
}, "http://example.com")
|
||||
|
||||
require.ElementsMatch(t, []string{"Accept-Encoding", varyOrigin}, headerValues(headers, headerVary))
|
||||
}
|
||||
|
||||
func TestIssue1743LiteralWildcardAllowOriginDoesNotAddVaryOrigin(t *testing.T) {
|
||||
headers := appendVaryOriginHeader([][2]string{
|
||||
{config.HeaderAccessControlAllowOrigin, "*"},
|
||||
}, "*")
|
||||
|
||||
require.False(t, test.HasHeader(headers, headerVary))
|
||||
}
|
||||
|
||||
func headerValues(headers [][2]string, name string) []string {
|
||||
values := make([]string, 0)
|
||||
for _, header := range headers {
|
||||
if strings.EqualFold(header[0], name) {
|
||||
values = append(values, header[1])
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
1
plugins/wasm-go/extensions/custom-response/.buildrc
Normal file
1
plugins/wasm-go/extensions/custom-response/.buildrc
Normal file
@@ -0,0 +1 @@
|
||||
IMAGE_NAME=go-custom-response
|
||||
@@ -104,8 +104,8 @@ func GetCookieValue(cookie string, key string) string {
|
||||
pairs := strings.Split(cookie, ";")
|
||||
for _, pair := range pairs {
|
||||
pair = strings.TrimSpace(pair)
|
||||
kv := strings.Split(pair, "=")
|
||||
if kv[0] == key {
|
||||
kv := strings.SplitN(pair, "=", 2)
|
||||
if len(kv) == 2 && kv[0] == key {
|
||||
value = kv[1]
|
||||
break
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ func TestGetCookieValue(t *testing.T) {
|
||||
{`cna=pf_9be76347560439f3b87daede1b485e37; userid=222`, "userid", "222"},
|
||||
{`uid=333`, "uid", "333"},
|
||||
{`cna=pf_9be76347560439f3b87daede1b485e37;`, "uid", ""},
|
||||
{`user; other=alice`, "user", ""},
|
||||
{`user=alice=admin; other=value`, "user", "alice=admin"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
testName := test.cookie
|
||||
|
||||
1
plugins/wasm-go/extensions/jwt-auth/.buildrc
Normal file
1
plugins/wasm-go/extensions/jwt-auth/.buildrc
Normal file
@@ -0,0 +1 @@
|
||||
IMAGE_NAME=go-jwt-auth
|
||||
@@ -129,8 +129,8 @@ func findCookie(cookie string, key string) string {
|
||||
|
||||
for _, pair := range pairs {
|
||||
pair = strings.TrimSpace(pair)
|
||||
kv := strings.Split(pair, "=")
|
||||
if kv[0] == key {
|
||||
kv := strings.SplitN(pair, "=", 2)
|
||||
if len(kv) == 2 && kv[0] == key {
|
||||
value = kv[1]
|
||||
break
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package handler
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestFindCookie(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cookie string
|
||||
key string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "extracts matching cookie value",
|
||||
cookie: "user=alice; other=value",
|
||||
key: "user",
|
||||
want: "alice",
|
||||
},
|
||||
{
|
||||
name: "skips segment without equals sign",
|
||||
cookie: "user; other=value",
|
||||
key: "user",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "keeps equals signs in cookie value",
|
||||
cookie: "user=alice=admin; other=value",
|
||||
key: "user",
|
||||
want: "alice=admin",
|
||||
},
|
||||
{
|
||||
name: "empty cookie returns empty",
|
||||
cookie: "",
|
||||
key: "user",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "key not present returns empty",
|
||||
cookie: "user=alice; other=value",
|
||||
key: "missing",
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := findCookie(tt.cookie, tt.key); got != tt.want {
|
||||
t.Fatalf("findCookie() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteCookie(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cookie string
|
||||
key string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "removes matching key",
|
||||
cookie: "user=alice; other=value",
|
||||
key: "user",
|
||||
want: "other=value",
|
||||
},
|
||||
{
|
||||
name: "key not present keeps all segments",
|
||||
cookie: "user=alice; other=value",
|
||||
key: "missing",
|
||||
want: "user=alice;other=value",
|
||||
},
|
||||
// deleteCookie 用 HasPrefix(pair, key) 删除,不区分段是否含 "=":
|
||||
// 任何以 key 开头的段都会被删除(包括无 "=" 的段)。
|
||||
{
|
||||
name: "segment without equals sign is deleted when key matches prefix",
|
||||
cookie: "user; other=value",
|
||||
key: "user",
|
||||
want: "other=value",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := deleteCookie(tt.cookie, tt.key); got != tt.want {
|
||||
t.Fatalf("deleteCookie() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
1
plugins/wasm-go/extensions/key-auth/.buildrc
Normal file
1
plugins/wasm-go/extensions/key-auth/.buildrc
Normal file
@@ -0,0 +1 @@
|
||||
IMAGE_NAME=go-key-auth
|
||||
1
plugins/wasm-go/extensions/sni-misdirect/.buildrc
Normal file
1
plugins/wasm-go/extensions/sni-misdirect/.buildrc
Normal file
@@ -0,0 +1 @@
|
||||
IMAGE_NAME=go-sni-misdirect
|
||||
@@ -18,6 +18,7 @@ Higress e2e tests are mainly focusing on two parts for now:
|
||||
Higress provides make target to run ingress api conformance tests and wasmplugin tests,
|
||||
|
||||
+ API Tests: `make higress-conformance-test`
|
||||
+ Gateway API Tests: `make gateway-conformance-test`
|
||||
+ WasmPlugin Tests: `make higress-wasmplugin-test`
|
||||
+ Build all Go WasmPlugins for testing: `make higress-wasmplugin-test`
|
||||
+ Build tests for a specific Go WasmPlugin only: `PLUGIN_NAME=request-block make higress-wasmplugin-test`
|
||||
@@ -71,15 +72,6 @@ The test environment reusability is primarily achieved through the following tar
|
||||
|
||||
## Gateway APIs Conformance Tests
|
||||
|
||||
Gateway API Conformance tests are based on the suite provided by `kubernetes-sig/gateway-api`, we can reuse that,
|
||||
and decide what conformance tests we need to open. Conformance tests of Gateway API.
|
||||
Run `make gateway-conformance-test` to execute the upstream Gateway API v1.4.0 Conformance Suite. The default scope is the required `GATEWAY-HTTP` Core profile (`Gateway`, `HTTPRoute`, and `ReferenceGrant`); Extended features are not enabled.
|
||||
|
||||
This API covers a broad set of features and use cases and has been implemented widely.
|
||||
This combination of both a large feature set and variety of implementations requires
|
||||
clear conformance definitions and tests to ensure the API provides a consistent experience wherever it is used.
|
||||
|
||||
Gateway API includes a set of conformance tests. These create a series of Gateways and Routes with the specified
|
||||
GatewayClass, and test that the implementation matches the API specification.
|
||||
|
||||
Each release contains a set of conformance tests, these will continue to expand as the API evolves.
|
||||
Currently conformance tests cover the majority of Core capabilities in the standard channel, in addition to some Extended capabilities.
|
||||
The runner imports the upstream suite and its embedded manifests directly, so Higress does not maintain copies of official test cases. Set `GATEWAY_CONFORMANCE_RUN_TEST=<ShortName>` only when debugging one upstream test. The default PR workflow always runs the complete Core profile and stores the generated report as a CI artifact.
|
||||
|
||||
@@ -18,6 +18,7 @@ Higress E2E 测试主要关注两个部分:
|
||||
Higress 提供了运行 Ingress API 一致性测试和 wasmplugin 测试的 make 目标,
|
||||
|
||||
+ API 测试: `make higress-conformance-test`
|
||||
+ Gateway API 测试: `make gateway-conformance-test`
|
||||
+ WasmPlugin 测试: `make higress-wasmplugin-test`
|
||||
+ 为测试构建所有 GO WasmPlugins: `make higress-wasmplugin-test`
|
||||
+ 仅为一个 GO WasmPlugin 构建测试: `PLUGIN_NAME=request-block make higress-wasmplugin-test`
|
||||
@@ -70,12 +71,6 @@ Higress 提供了运行 Ingress API 一致性测试和 wasmplugin 测试的 make
|
||||
|
||||
## Gateway API 一致性测试
|
||||
|
||||
Gateway API 一致性测试基于 `kubernetes-sig/gateway-api` 提供的套件,我们可以重复使用它,并决定我们需要打开哪些 Gateway API 的一致性测试。
|
||||
执行 `make gateway-conformance-test` 可运行上游 Gateway API v1.4.0 Conformance Suite。默认范围只包含 `GATEWAY-HTTP` Profile 的必选 Core 能力:`Gateway`、`HTTPRoute` 和 `ReferenceGrant`,不启用 Extended 能力。
|
||||
|
||||
此 API 包含一系列广泛的功能和用例,并已得到广泛实现。
|
||||
这个大的功能集和各种实现的结合需要明确的一致性定义和测试,以确保在任何地方使用该 API 时都提供一致的体验。
|
||||
|
||||
Gateway API 包括一组一致性测试。这些测试创建具有指定 GatewayClass 的一系列 Gateways 和 Routes,并测试实现是否符合 API 规范。
|
||||
|
||||
每个发布版本都包含一组一致性测试,随着 API 的演进,这些测试将不断扩展。
|
||||
目前,一致性测试覆盖了标准通道中的大多数核心功能,以及一些扩展功能。
|
||||
Runner 直接引用上游 Suite 及其内嵌 manifests,Higress 不复制维护官方测试用例。只有调试单个上游用例时才设置 `GATEWAY_CONFORMANCE_RUN_TEST=<ShortName>`;默认 PR 流水线始终执行完整 Core Profile,并将官方报告保存为 CI artifact。
|
||||
|
||||
@@ -49,7 +49,7 @@ var WasmPluginCacheControl = suite.ConformanceTest{
|
||||
ExpectedResponse: http.Response{
|
||||
StatusCode: 200,
|
||||
Headers: map[string]string{
|
||||
"Cache-Control": "maxAge=3600",
|
||||
"Cache-Control": "max-age=3600",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -45,4 +45,4 @@ spec:
|
||||
configDisable: false
|
||||
ingress:
|
||||
- higress-conformance-infra/wasmplugin-cache-control
|
||||
url: oci://higress-registry.cn-hangzhou.cr.aliyuncs.com/plugins/cache-control:2.0.0
|
||||
url: file:///opt/plugins/wasm-go/extensions/cache-control/plugin.wasm
|
||||
|
||||
@@ -13,3 +13,46 @@
|
||||
// limitations under the License.
|
||||
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"sigs.k8s.io/gateway-api/conformance"
|
||||
"sigs.k8s.io/gateway-api/conformance/utils/roundtripper"
|
||||
)
|
||||
|
||||
const dialLocalhostEnv = "HIGRESS_GATEWAY_API_TEST_DIAL_LOCALHOST"
|
||||
const localHTTPPortEnv = "HIGRESS_GATEWAY_API_TEST_LOCAL_HTTP_PORT"
|
||||
const localHTTPSPortEnv = "HIGRESS_GATEWAY_API_TEST_LOCAL_HTTPS_PORT"
|
||||
|
||||
func TestGatewayAPIConformance(t *testing.T) {
|
||||
opts := conformance.DefaultOptions(t)
|
||||
if os.Getenv(dialLocalhostEnv) == "true" {
|
||||
opts.RoundTripper = &roundtripper.DefaultRoundTripper{
|
||||
Debug: opts.Debug,
|
||||
TimeoutConfig: opts.TimeoutConfig,
|
||||
CustomDialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
_, port, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch port {
|
||||
case "80":
|
||||
if localPort := os.Getenv(localHTTPPortEnv); localPort != "" {
|
||||
port = localPort
|
||||
}
|
||||
case "443":
|
||||
if localPort := os.Getenv(localHTTPSPortEnv); localPort != "" {
|
||||
port = localPort
|
||||
}
|
||||
}
|
||||
var dialer net.Dialer
|
||||
return dialer.DialContext(ctx, network, net.JoinHostPort("127.0.0.1", port))
|
||||
},
|
||||
}
|
||||
}
|
||||
conformance.RunConformanceWithOptions(t, opts)
|
||||
}
|
||||
|
||||
71
tools/hack/check-golang-filter-envoy-sync.sh
Executable file
71
tools/hack/check-golang-filter-envoy-sync.sh
Executable file
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Copyright (c) 2022 Alibaba Group Holding Ltd.
|
||||
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# The golang-filter shared object is built against the Go bindings published by
|
||||
# the envoy/envoy submodule, while the Envoy binary that loads it is built from
|
||||
# that same submodule. The two must be produced from the same commit, otherwise
|
||||
# the cgo boundary between the Go filter and the host Envoy can diverge.
|
||||
#
|
||||
# This script asserts that the github.com/higress-group/envoy commit pinned by
|
||||
# plugins/golang-filter/go.mod matches the commit checked out for the
|
||||
# envoy/envoy git submodule. Run it from the repository root.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
cd "${REPO_ROOT}"
|
||||
|
||||
GO_MOD="plugins/golang-filter/go.mod"
|
||||
SUBMODULE_PATH="envoy/envoy"
|
||||
|
||||
# Commit recorded for the envoy/envoy submodule in the current tree. Reading it
|
||||
# from the tree (rather than the checked-out submodule) means the check works
|
||||
# even when submodules have not been initialized.
|
||||
submodule_commit="$(git rev-parse "HEAD:${SUBMODULE_PATH}")"
|
||||
|
||||
# The replace directive pins github.com/higress-group/envoy to a pseudo-version
|
||||
# of the form v0.0.0-<UTC timestamp>-<12-char commit prefix>.
|
||||
pin_line="$(grep -E 'replace[[:space:]]+github.com/envoyproxy/envoy[[:space:]]+=>[[:space:]]+github.com/higress-group/envoy' "${GO_MOD}" || true)"
|
||||
if [[ -z "${pin_line}" ]]; then
|
||||
echo "ERROR: could not find the github.com/higress-group/envoy replace directive in ${GO_MOD}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
pin_prefix="$(echo "${pin_line}" | grep -oE '[0-9a-f]{12}$' || true)"
|
||||
if [[ -z "${pin_prefix}" ]]; then
|
||||
echo "ERROR: could not parse the commit prefix from the envoy pseudo-version in ${GO_MOD}" >&2
|
||||
echo " line: ${pin_line}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "${submodule_commit:0:12}" != "${pin_prefix}" ]]; then
|
||||
cat >&2 <<EOF
|
||||
ERROR: golang-filter Envoy dependency is out of sync with the envoy/envoy submodule.
|
||||
|
||||
envoy/envoy submodule commit : ${submodule_commit}
|
||||
${GO_MOD} pin prefix : ${pin_prefix}
|
||||
|
||||
The Go bindings used to build golang-filter must come from the same Envoy commit
|
||||
as the host Envoy binary. When you bump the envoy/envoy submodule, update the
|
||||
github.com/higress-group/envoy replace directive in ${GO_MOD} to the matching
|
||||
pseudo-version and run 'go mod tidy'. You can obtain the pseudo-version with:
|
||||
|
||||
GOPROXY=direct go list -m -json github.com/higress-group/envoy@${submodule_commit}
|
||||
EOF
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "OK: ${GO_MOD} envoy pin (${pin_prefix}) matches envoy/envoy submodule (${submodule_commit})."
|
||||
@@ -20,6 +20,7 @@ set -euo pipefail
|
||||
CLUSTER_NAME=${CLUSTER_NAME:-"higress"}
|
||||
METALLB_VERSION=${METALLB_VERSION:-"v0.13.7"}
|
||||
KIND_NODE_TAG=${KIND_NODE_TAG:-"v1.25.3"}
|
||||
KIND=${KIND:-"tools/bin/kind"}
|
||||
PROJECT_DIR=$(pwd)
|
||||
|
||||
echo ${KIND_NODE_TAG}
|
||||
@@ -67,7 +68,7 @@ EOF
|
||||
|
||||
## Create kind cluster.
|
||||
if [[ -z "${KIND_NODE_TAG}" ]]; then
|
||||
tools/bin/kind create cluster --name "${CLUSTER_NAME}" --config=tools/hack/cluster.conf
|
||||
"${KIND}" create cluster --name "${CLUSTER_NAME}" --config=tools/hack/cluster.conf
|
||||
else
|
||||
tools/bin/kind create cluster --image "kindest/node:${KIND_NODE_TAG}" --name "${CLUSTER_NAME}" --config=tools/hack/cluster.conf
|
||||
"${KIND}" create cluster --image "kindest/node:${KIND_NODE_TAG}" --name "${CLUSTER_NAME}" --config=tools/hack/cluster.conf
|
||||
fi
|
||||
|
||||
20
tools/src/kind-gateway-api/go.mod
Normal file
20
tools/src/kind-gateway-api/go.mod
Normal file
@@ -0,0 +1,20 @@
|
||||
module github.com/alibaba/higress/tools/src/kind-gateway-api
|
||||
|
||||
go 1.24.0
|
||||
|
||||
require sigs.k8s.io/kind v0.30.0
|
||||
|
||||
require (
|
||||
al.essio.dev/pkg/shellescape v1.5.1 // indirect
|
||||
github.com/BurntSushi/toml v1.4.0 // indirect
|
||||
github.com/evanphx/json-patch/v5 v5.6.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/pelletier/go-toml v1.9.5 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/spf13/cobra v1.8.0 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/sys v0.6.0 // indirect
|
||||
sigs.k8s.io/yaml v1.4.0 // indirect
|
||||
)
|
||||
42
tools/src/kind-gateway-api/go.sum
Normal file
42
tools/src/kind-gateway-api/go.sum
Normal file
@@ -0,0 +1,42 @@
|
||||
al.essio.dev/pkg/shellescape v1.5.1 h1:86HrALUujYS/h+GtqoB26SBEdkWfmMI6FubjXlsXyho=
|
||||
al.essio.dev/pkg/shellescape v1.5.1/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890=
|
||||
github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0=
|
||||
github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/evanphx/json-patch/v5 v5.6.0 h1:b91NhWfaz02IuVxO9faSllyAtNXHMPkC5J8sJCLunww=
|
||||
github.com/evanphx/json-patch/v5 v5.6.0/go.mod h1:G79N1coSVB93tBe7j6PhzjmR3/2VvlbKOFpnXhI9Bw4=
|
||||
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
|
||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
|
||||
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
|
||||
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b h1:QRR6H1YWRnHb4Y/HeNFCTJLFVxaq6wH4YuVdsUOr75U=
|
||||
gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
sigs.k8s.io/kind v0.30.0 h1:2Xi1KFEfSMm0XDcvKnUt15ZfgRPCT0OnCBbpgh8DztY=
|
||||
sigs.k8s.io/kind v0.30.0/go.mod h1:FSqriGaoTPruiXWfRnUXNykF8r2t+fHtK0P0m1AbGF8=
|
||||
sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E=
|
||||
sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY=
|
||||
19
tools/src/kind-gateway-api/pin.go
Normal file
19
tools/src/kind-gateway-api/pin.go
Normal file
@@ -0,0 +1,19 @@
|
||||
// Copyright (c) 2026 Alibaba Group Holding Ltd.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//go:build pin
|
||||
|
||||
package ignore
|
||||
|
||||
import "sigs.k8s.io/kind"
|
||||
@@ -16,6 +16,7 @@ tools/controller-gen = $(tools.bindir)/controller-gen
|
||||
tools/golangci-lint = $(tools.bindir)/golangci-lint
|
||||
tools/kustomize = $(tools.bindir)/kustomize
|
||||
tools/kind = $(tools.bindir)/kind
|
||||
tools/kind-gateway-api = $(tools.bindir)/kind-gateway-api
|
||||
tools/setup-envtest = $(tools.bindir)/setup-envtest
|
||||
$(tools.bindir)/%: $(tools.srcdir)/%/pin.go $(tools.srcdir)/%/go.mod
|
||||
cd $(<D) && GOOS= GOARCH= go build -o $(abspath $@) $$(sed -En 's,^import "(.*)".*,\1,p' pin.go)
|
||||
|
||||
Reference in New Issue
Block a user