导读对象有两份,对照着看:
- 代码:
citic-wasm-plugin/key-auth/main.go(364 行,Higress 官方 key-auth 移植版)- Demo:
higress/key-auth-docker-demo/(docker-compose.yaml + envoy.yaml + plugin.wasm + test.sh)Demo 的价值:它绕开 K8s 和 Higress 控制面,用裸 Envoy 直接加载插件,于是"插件挂在哪、配置长什么样、每个分支对应什么现象"全部看得见摸得着。读完这两份,Wasm 插件从部署到代码的整条链路就通了。
第 1 章是所有 Higress Wasm 插件通用的运行原理,提炼自《ai-security-guard插件实现原理.md》的 §2~§4、§13——原理是相通的,这里做了 key-auth 视角的对照改写;两篇文档互为补充。
0. Demo 全景:一条请求的全部旅程
| |
四个文件各管一件事:
| 文件 | 作用 |
|---|---|
docker-compose.yaml | 起两个容器,把 envoy.yaml 和 plugin.wasm 挂进 envoy 容器,10000 端口暴露给宿主机 |
envoy.yaml | Envoy 的全部配置:监听、路由、wasm filter 及其配置、后端 cluster |
plugin.wasm | key-auth 的编译产物(GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared) |
test.sh | 5 个 curl 场景,每个对应代码里一个分支 |
1. Wasm 插件通用运行原理(与 ai-security-guard 相通)
本章机制对所有 Higress Wasm 插件都成立。每节分「通俗理解」与「技术实现」两层,技术实现层都给出 key-auth 的具体对照。
1.1 地基:沙箱、hostcall 与"每线程一个 VM"
通俗理解
Higress 的数据面是 Envoy,给它加功能传统上要改 C++ 源码重新编译——门槛高、风险大。Wasm(WebAssembly)换了个思路:插件编译成 .wasm 二进制,运行在 Envoy 内部的沙箱虚拟机里。
打个比方:Envoy 是一栋大楼,Wasm 插件是楼里的"租客"——住在隔音隔热的房间里(沙箱),崩溃不会拖垮大楼,也不能随便动楼里的东西;但大楼给租客留了一部服务电话,想做什么就打电话让前台代办。这部电话就是 hostcall(宿主调用)。
还要注意:大楼里有很多前台窗口(Envoy 多 worker 线程),每个窗口都配一个一模一样的租客(每线程一个独立 Wasm VM)。所以插件不能指望"上一次请求存在内存里的东西这次还在"——你可能换了窗口、换了租客。这就是插件里不能存跨请求全局状态的原因。
技术实现
plugin.wasm作为 Envoy 的 Http Filter 加载,Envoy 每个 worker 线程实例化一个 Wasm VM 执行它。demo 里 envoy 启动参数带--concurrency 1(单线程),就是排除多 VM 副本的干扰。插件的一切能力来自 proxy-wasm ABI 的 hostcall。key-auth 只用了 3 个,是"最瘦"的一类插件:
插件想干什么 hostcall 代码位置 读请求头(取 token) GetHttpRequestHeadermain.go:263 注入消费者头 AddHttpRequestHeadermain.go:294 直接给客户端回拒绝响应 SendHttpResponseWithDetailmain.go:330 对照 ai-security-guard,它还需要
GetHttpRequestBody(读 body)、DispatchHttpCall(外呼安全服务)、ResumeHttpRequest(异步放行)、InjectEncodedDataToFilterChain(流式注入)等——插件要干的事越多,用到的 hostcall 越多。跨请求状态的处理原则:要么不存,要么建在配置期。
credential2Name(main.go:130)这种"配置不变它就不变"的表,在parseGlobalConfig时每个 VM 各建一份;而请求级状态(本次认出的 consumer 名)不落 VM 内存,而是注入X-Mse-Consumer请求头随请求往下游走。ABI 版本注意:Go 编译的插件导出
proxy_abi_version_0_2_100扩展 ABI,只有 Higress fork 的 Envoy 支持,官方 Envoy 加载即失败(demo 用higress/gateway镜像的原因;详解见《key-auth插件本地Docker部署与运行机制指南.md》§3.2)。
1.2 骨架:wrapper 框架与回调注册
通俗理解
一次请求在网关里要过几个"检查站",插件在 init() 里登记了每个站点要干什么。wasm-go 的 wrapper 包还帮插件干了两件麻烦事:一是把分片到达的 body 拼完整再交给你(裸 SDK 里要自己拼);二是根据请求命中的配置级别(全局/域名/路由)把对应配置递给你的回调。
技术实现
- 插件执行阶段与优先级由代码注解声明:key-auth 是
@Phase AUTHN @Priority 321(main.go:62-63)。多插件并存时这决定执行顺序——认证(AUTHN 阶段)天然排在业务类插件(默认阶段)前面,先过门禁再谈别的。 wrapper.SetCtx注册回调(main.go:37-43,详见 §3)。key-auth 只挂两类:ParseOverrideConfigBy(配置解析)+ProcessRequestHeadersBy(请求头处理),因为认证只看请求头就够了;ai-security-guard 要挂 5~6 类(还要读请求体、拦响应),复杂度差别就在这。- wrapper 的配置分层机制:顶层 JSON 给
parseGlobalConfig,_rules_数组里每条给parseOverrideRuleConfig;请求期框架按请求命中的路由/域名,把对应的那份配置递给回调。这套机制的"实体"见 §2.3 对 envoy.yaml:42 的剖析。
1.3 异步模型:ActionPause / Resume,以及 key-auth 的"同步特权"
通俗理解
这是理解插件执行模型最关键的一点。Envoy 的每个线程是单线程事件循环:插件代码运行时,这个线程上其他请求都在排队。所以插件里绝不允许"发一个网络请求然后干等结果"——那会把整个线程卡死,全网关的请求都跟着遭殃。
需要外呼的插件(如 ai-security-guard)正确姿势是"过安检“模式:旅客(请求)到安检口,安检员说"你先到旁边等着”(ActionPause),安检仪扫完(安全服务返回回调),安检员看结果——没问题就喊"过来继续走"(ResumeHttpRequest),有问题就直接请出去(SendHttpResponse)。等待期间安检口照常处理其他旅客。
key-auth 没这个烦恼:它是"看一眼证件“的安检员——所有判定都是内存操作(查 map、比名单),微秒级出结果,从不外呼,所以永远同步返回。
技术实现
- key-auth 的
onHttpRequestHeaders(main.go:239)只做:读头 → map 查询 → 要么ActionContinue放行,要么SendHttpResponse本地拒绝后return ActionContinue(请求已被 hostcall 终止,返回值只是形式,见 §6)。 - 需要外呼的插件是另一套写法:body hook 返回
types.ActionPause,在DispatchHttpCall的回调里根据结果ResumeHttpRequest()放行或SendHttpResponse()拦截;同一时刻只许一次在途调用(单飞),避免结果乱序。 - 判断一个插件属于哪类的方法:看回调里有没有发起异步调用(HTTP / Redis 等)。有 → 必须 Pause/Resume;没有 → 像 key-auth 一样同步返回即可,代码简单一个数量级。
1.4 部署链路:plugin.wasm 怎么变成生效的插件
通俗理解
OCI 镜像是 wasm 的"快递盒”:plugin.wasm 只有几 MB,要送进 K8s 集群里每个网关 Pod,最省事的办法是借用现成的 Docker 镜像物流体系——装进盒子(Dockerfile COPY)、贴上单号(VERSION tag)、寄到仓库(docker push),网关填个快递单号(url: oci://...)就能收到并拆包加载。
McpBridge 是给服务"铺路":默认 Higress 只把"路由用到的服务"下发成 Envoy cluster;路由之外的服务(比如 ai-security-guard 要外呼的安全服务)想用,就得用 McpBridge 额外注册。
配置生效讲"就近原则":请求命中了具体路由/域名配置就用它,完全不沾边才用 defaultConfig。
技术实现
WasmPlugin.spec.url的三种形态:oci://...:镜像分发,生产环境用(keyauth.yaml示例里的oci://docker.io/dongjiang1989/keyauth:1.0.0);http://plugin-server:8080/...:standalone 部署内置插件服务,官方插件(如 key-auth 2.0.0)开箱即用;file:///...:本地文件挂载,demo 用的就是这种(plugin.wasm直接挂进 envoy 容器),改代码重编后重启容器即生效,最适合调试。
- K8s 链路:
WasmPluginCRD → Higress 控制面翻译成 Envoy 的 wasm filter 配置下发到 gateway。demo 里手写的 envoy.yaml:26-42 就是这份"翻译结果"的样貌(§2.2 逐行拆解)。 - McpBridge 的两个用途:① 给路由提供上游(本例把
httpbin.org注册成httpbin.dns供 Ingress 引用);② 给插件外呼铺路建 cluster(key-auth 无外呼用不到;ai-security-guard 靠它获得outbound|443||safecheck.dns这个 cluster)。 - 配置匹配的就近原则落到插件内部,就是
_rules_/_match_route_的命中逻辑(§2.3、§4.3)。
2. envoy.yaml:插件是怎么"挂"进请求链路的
K8s 里我们写 WasmPlugin CRD,控制面把它翻译成 Envoy 配置;demo 里没有控制面,这段 envoy.yaml 就是翻译后的最终产物,所以看它最能理解插件的真实形态。
2.1 路由层(envoy.yaml:14-25)
两个关键点:
domains决定哪些 Host 走这条路由——test.sh 场景⑤不带 Host 直接访问,匹配不到 virtual host,Envoy 直接回 404,请求根本没到插件;- 路由名
keyauth-route是给插件配置引用的"身份证号",马上会看到。
2.2 wasm filter 层(envoy.yaml:26-42)——全篇最重要的一段
| |
逐行拆解:
- http_filters 的顺序就是请求的处理顺序:wasm 排在 router 前面,所以插件先执行,拦下的请求根本不会转发给 httpbin。
configuration.value是一段 JSON 字符串——这就是插件parseGlobalConfig/parseOverrideRuleConfig收到的原始gjson.Result。- 注意它的结构:全局字段(consumers/keys/in_header/global_auth)平铺在顶层,路由级规则放在
_rules_数组里,每条用_match_route_指定生效的路由名。
2.3 大揭秘:_rules_ 就是 WasmPlugin 的 matchRules
对照 K8s 版配置(keyauth.yaml),两边其实一模一样:
| |
Higress 控制面干的事就是把 defaultConfig + matchRules 编译成这样一段 JSON(K8s 里 _match_route_ 的值是 ingress 名,demo 里是手写的 envoy 路由名)。wasm-go wrapper 收到后:顶层 JSON 给 parseGlobalConfig,_rules_ 里每条给 parseOverrideRuleConfig。
“两级配置机制"的抽象说法,在 envoy.yaml:42 这一行里能看到实体。
2.4 docker-compose 里的两个细节
- envoy 镜像是
higress/gateway(compose 文件第 11 行注释):Go 编译的插件用了proxy_abi_version_0_2_100这个 ABI 扩展,只有 Higress fork 的 Envoy 支持,官方 Envoy 加载会失败(见 §1.1)。 --concurrency 1:单 worker 线程。回忆 §1.1"每个 worker 线程一个独立 Wasm VM”——demo 设成 1,保证你看到的日志和行为都来自同一个 VM,排除多副本干扰。
3. 代码骨架(main.go:35-43)
Wasm 插件没有进程入口,main() 是编译占位;真正的入口是 init()——插件加载时向 wrapper 框架挂号三件事:我的名字(要和 envoy.yaml / WasmPlugin 里的 name 对上)、配置来了调谁、请求头到了调谁。之后所有代码都是被动回调。
只挂两个 hook,因为认证只看请求头就够了。对比 ai-security-guard 挂 6 个(要读 body、拦响应),复杂度差别就在这(见 §1.2)。
4. 配置解析:把 envoy.yaml:42 那段 JSON 变成内存结构
4.1 配置结构体(main.go:88-131)
| |
globalAuth用*bool指针(main.go:94):bool 零值 false 分不清"配了 false"和"根本没配"。指针区分三态:nil=未设置、&true、&false——这是后面裁决表的基础。credential2Name是运行时索引(main.go:130):配置里 consumers 是列表,逐请求遍历是 O(n);解析时建成凭证→名字的 map,运行时 O(1) 查表。配置解析一次、请求千千万万次——开销挪到解析期,是插件开发通用思路(§1.1 的状态原则)。
4.2 parseGlobalConfig:校验 + 建索引(main.go:133-204)
收到顶层 JSON(demo 里就是 consumers/keys/in_header/global_auth 那部分),先做一串防御式校验,任一不满足整条配置被拒:
keys必须非空(:148-154)——没定义从哪取 key,认证无从谈起;in_query/in_header至少配一个(:163-165);consumers非空,name/credential 都不能空;- 凭证不允许重复(:192-194)——两个 consumer 同 key 就分不清"你是谁",直接拒配。
通过后建索引。用 demo 的配置,解析完内存里是:
demo 视角:改 envoy.yaml:42 里的 JSON(比如删掉 consumers),重启 compose,就能在 envoy 日志里看到对应的校验报错——配置错误在插件加载期就暴露,不会带病运行。
4.3 parseOverrideRuleConfig:叠加路由级 allow(main.go:206-225)
对 _rules_ 里每条规则调用一次:
用 demo 配置:命中 keyauth-route 的请求,拿到的最终 config = 全局 consumers/keys + allow: ["consumer1"];不命中任何 _rules_ 的请求,拿到的就是纯全局配置(allow 为空)。
ruleSet 是包级全局变量,记录"至少有一条路由配了插件",裁决时用来猜管理员意图(见 §5 第一步)。
5. 核心裁决:onHttpRequestHeaders(main.go:239-327)
请求头到达时框架调它。开头算四个布尔值:
第一步:要不要认证?(:249-254)
demo 配置 global_auth: false:请求没命中 keyauth-route(不会有,因为只有一个 virtual host;K8s 多路由场景才有意义)→ 直接放行,连 token 都不取。
ruleSet 的作用:global_auth 没设置时猜意图——任何路由都没配过插件(ruleSet==false)说明想全局认证;有路由配了,说明按路由精细控制,没配的路由放行。
第二步:取 token(:259-278)
demo 配置 in_header: true:先试 x-api-key 头,再试 apikey 头。两个头都带就都收进 tokens——下一步会当成攻击拒绝(test.sh 没演示这个,可以自己加一条 curl 验证)。
坑:else if 意味着 in_header/in_query 同开时 query 永不生效(官方原版行为)。
第三步:数量检查(:281-285)
带多个 key 为什么拒绝?防凭证混淆——一合法一非法同时带,后端处理顺序不同可能放行非法请求。→ test.sh 场景①(不带 Key)命中的就是这步的 401。
第四步:认证——查表认出"你是谁"(:288-294)
一次 map 查找完成认证。查到就把 consumer 名写进请求头往下游传——X-Mse-Consumer 是 Higress 消费者体系的通用语言,限流、ai-security-guard 的消费者级配置都读它。
→ test.sh 场景④(假 Key wrong-key)命中的就是这步的 403。 想亲眼看到注入效果:场景②通过的请求,httpbin 的 /get 响应体会把收到的请求头原样回显,里面就有 X-Mse-Consumer: consumer1。
第五步:授权——“你能进这扇门吗”(:299-326)
| global_auth | 路由无 allow | 路由有 allow |
|---|---|---|
| true | 凭证合法即放行 | 合法且在 allow 里 |
| false | 直接放行(第一步已走掉) | 合法且在 allow 里 |
| 未设置 | ruleSet==false → 按 true;否则按 false | 同上 |
| |
demo 落在第三分支:→ test.sh 场景③(consumer2 的 Key,凭证合法但不在 allow: [consumer1])命中这里的 403;场景②(consumer1)走到 authenticated 放行。
6. 拒绝响应怎么发(main.go:329-345)
WWW-Authenticate 是 RFC 7235 的要求,告诉客户端"这里用 Key 认证"。test.sh 的 ①③④ 输出里都能看到这行响应头,可以对照确认命中了哪个拒绝函数(body 文案各不相同)。
容易困惑的点:发了拒绝响应为什么还 return ActionContinue?SendHttpResponse 是"立即响应并终止该请求"的 hostcall,请求已结束,返回值只是形式。对比 ai-security-guard 外呼时的 ActionPause(等异步回调,见 §1.3),语义完全不同。
7. 剧本对照表:test.sh ⑤ 个场景 → 代码分支
| test.sh 场景 | 请求 | 命中代码 | 期望 |
|---|---|---|---|
| ① 不带 Key | 无认证头 | len(tokens)<=0 → deniedNoKeyAuthData(:283) | 401 no_key |
| ② consumer1 的 Key | x-api-key: 2bda... | 查表得 consumer1(:288)→ allow 命中(:317-322) | 200,响应回显含 X-Mse-Consumer: consumer1 |
| ③ consumer2 的 Key | apikey: c8c8...(第二个 key 名) | 认证过、授权失败(:317-319) | 403 unauthorized |
| ④ 假 Key | x-api-key: wrong-key | 查表未命中(:289-291) | 403 unauthorized |
| ⑤ 不带 Host | 直接访问 IP:10000 | 到不了插件:virtual host domains 不匹配(envoy.yaml:18-19),Envoy 路由层 404 | 404 |
场景⑤是"插件不是万能的"的最好示例:它只在命中路由后执行,路由都没匹配上的请求根本轮不到它。
想动手验证:
cd key-auth-docker-demo && docker compose up -d && ./test.sh。改 envoy.yaml:42 的 JSON 后docker compose restart envoy即生效,不用重编 wasm——配置和代码是两条独立的发布链路。
8. 设计脉络总结
一句话:配置期做好校验和索引(慢一次),请求期只做取 token、查 map、比名单(快万次);三态 global_auth 是唯一绕的地方,记住"nil = 未设置,靠 ruleSet 猜意图"。
9. 已知小问题(移植自官方,未改动)
in_header/in_query是else if关系,同开时 query 不生效(main.go:268);url.Parse错误被忽略(main.go:270);- 凭证以
%q打进日志(main.go:290),有轻微泄露风险; - 落后于上游:官方已支持 consumer 多凭证
credentials字段,此版本只有单credential(main.go:57); keyauth.yaml示例镜像指向个人仓库docker.io/dongjiang1989/keyauth:1.0.0,实际部署需替换。
附:测试怎么验证这些(main_test.go,580 行)
TestParseGlobalConfig(:240):喂残缺 JSON,验证校验报错;TestParseRuleConfig(:361):验证 allow 解析与*config = global继承;TestOnHTTPRequestHeaders(:388):用 wasm-gopkg/test的 mock host 模拟请求头,断言 action 与发出的响应状态码——§7 的剧本①②③④都有对应断言。
单测(mock host,进程内跑)和 demo(真实 Envoy,容器里跑)是两种互补的验证层:前者快、覆盖分支;后者验证真实 ABI、真实配置链路和实际 HTTP 行为。
