diff --git a/README.md b/README.md index 2f1a1bc363..1587241cc3 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,9 @@

Higress
- AI Native API Gateway + AI Gateway

+

AI Native API Gateway

[![Build Status](https://github.com/alibaba/higress/actions/workflows/build-and-test.yaml/badge.svg?branch=main)](https://github.com/alibaba/higress/actions) [![license](https://img.shields.io/github/license/alibaba/higress.svg)](https://www.apache.org/licenses/LICENSE-2.0.html) diff --git a/plugins/wasm-assemblyscript/README.md b/plugins/wasm-assemblyscript/README.md new file mode 100644 index 0000000000..88471f9352 --- /dev/null +++ b/plugins/wasm-assemblyscript/README.md @@ -0,0 +1,53 @@ +## 介绍 + +此 SDK 用于使用 AssemblyScript 语言开发 Higress 的 Wasm 插件。 + +### 如何使用SDK + +创建一个新的 AssemblyScript 项目。 + +``` +npm init +npm install --save-dev assemblyscript +npx asinit . +``` + +在asconfig.json文件中,作为传递给asc编译器的选项之一,包含"use": "abort=abort_proc_exit"。 + +``` +{ + "options": { + "use": "abort=abort_proc_exit" + } +} +``` + +将`"@higress/proxy-wasm-assemblyscript-sdk": "^0.0.1"`和`"@higress/wasm-assemblyscript": "^0.0.3"`添加到你的依赖项中,然后运行`npm install`。 + +### 本地构建 + +``` +npm run asbuild +``` + +构建结果将在`build`文件夹中。其中,`debug.wasm`和`release.wasm`是已编译的文件,在生产环境中建议使用`release.wasm`。 + +注:如果需要插件带有 name section 信息需要带上`"debug": true`,编译参数解释详见[using-the-compiler](https://www.assemblyscript.org/compiler.html#using-the-compiler)。 + +```json +"release": { + "outFile": "build/release.wasm", + "textFile": "build/release.wat", + "sourceMap": true, + "optimizeLevel": 3, + "shrinkLevel": 0, + "converge": false, + "noAssert": false, + "debug": true +} +``` + +### AssemblyScript 限制 + +此 SDK 使用的 AssemblyScript 版本为`0.27.29`,参考[AssemblyScript Status](https://www.assemblyscript.org/status.html)该版本尚未支持闭包、异常、迭代器等特性,并且JSON,正则表达式等功能还尚未在标准库中实现,暂时需要使用社区提供的实现。 + diff --git a/plugins/wasm-assemblyscript/asconfig.json b/plugins/wasm-assemblyscript/asconfig.json new file mode 100644 index 0000000000..9ba8d90d14 --- /dev/null +++ b/plugins/wasm-assemblyscript/asconfig.json @@ -0,0 +1,23 @@ +{ + "targets": { + "debug": { + "outFile": "build/debug.wasm", + "textFile": "build/debug.wat", + "sourceMap": true, + "debug": true + }, + "release": { + "outFile": "build/release.wasm", + "textFile": "build/release.wat", + "sourceMap": true, + "optimizeLevel": 3, + "shrinkLevel": 0, + "converge": false, + "noAssert": false + } + }, + "options": { + "bindings": "esm", + "use": "abort=abort_proc_exit" + } +} \ No newline at end of file diff --git a/plugins/wasm-assemblyscript/assembly/cluster_wrapper.ts b/plugins/wasm-assemblyscript/assembly/cluster_wrapper.ts new file mode 100644 index 0000000000..292b62f477 --- /dev/null +++ b/plugins/wasm-assemblyscript/assembly/cluster_wrapper.ts @@ -0,0 +1,214 @@ +import { + log, + LogLevelValues, + get_property, + WasmResultValues, +} from "@higress/proxy-wasm-assemblyscript-sdk/assembly"; +import { getRequestHost } from "./request_wrapper"; + +export abstract class Cluster { + abstract clusterName(): string; + abstract hostName(): string; +} + +export class RouteCluster extends Cluster { + host: string; + constructor(host: string = "") { + super(); + this.host = host; + } + + clusterName(): string { + let result = get_property("cluster_name"); + if (result.status != WasmResultValues.Ok) { + log(LogLevelValues.error, "get route cluster failed"); + return ""; + } + return String.UTF8.decode(result.returnValue); + } + + hostName(): string { + if (this.host != "") { + return this.host; + } + return getRequestHost(); + } +} + +export class K8sCluster extends Cluster { + serviceName: string; + namespace: string; + port: i64; + version: string; + host: string; + + constructor( + serviceName: string, + namespace: string, + port: i64, + version: string = "", + host: string = "" + ) { + super(); + this.serviceName = serviceName; + this.namespace = namespace; + this.port = port; + this.version = version; + this.host = host; + } + + clusterName(): string { + let namespace = this.namespace != "" ? this.namespace : "default"; + return `outbound|${this.port}|${this.version}|${this.serviceName}.${namespace}.svc.cluster.local`; + } + + hostName(): string { + if (this.host != "") { + return this.host; + } + return `${this.serviceName}.${this.namespace}.svc.cluster.local`; + } +} + +export class NacosCluster extends Cluster { + serviceName: string; + group: string; + namespaceID: string; + port: i64; + isExtRegistry: boolean; + version: string; + host: string; + + constructor( + serviceName: string, + namespaceID: string, + port: i64, + // use DEFAULT-GROUP by default + group: string = "DEFAULT-GROUP", + // set true if use edas/sae registry + isExtRegistry: boolean = false, + version: string = "", + host: string = "" + ) { + super(); + this.serviceName = serviceName; + this.group = group.replace("_", "-"); + this.namespaceID = namespaceID; + this.port = port; + this.isExtRegistry = isExtRegistry; + this.version = version; + this.host = host; + } + + clusterName(): string { + let tail = "nacos" + (this.isExtRegistry ? "-ext" : ""); + return `outbound|${this.port}|${this.version}|${this.serviceName}.${this.group}.${this.namespaceID}.${tail}`; + } + + hostName(): string { + if (this.host != "") { + return this.host; + } + return this.serviceName; + } +} + +export class StaticIpCluster extends Cluster { + serviceName: string; + port: i64; + host: string; + + constructor(serviceName: string, port: i64, host: string = "") { + super() + this.serviceName = serviceName; + this.port = port; + this.host = host; + } + + clusterName(): string { + return `outbound|${this.port}||${this.serviceName}.static`; + } + + hostName(): string { + if (this.host != "") { + return this.host; + } + return this.serviceName; + } +} + +export class DnsCluster extends Cluster { + serviceName: string; + domain: string; + port: i64; + + constructor(serviceName: string, domain: string, port: i64) { + super(); + this.serviceName = serviceName; + this.domain = domain; + this.port = port; + } + + clusterName(): string { + return `outbound|${this.port}||${this.serviceName}.dns`; + } + + hostName(): string { + return this.domain; + } +} + +export class ConsulCluster extends Cluster { + serviceName: string; + datacenter: string; + port: i64; + host: string; + + constructor( + serviceName: string, + datacenter: string, + port: i64, + host: string = "" + ) { + super(); + this.serviceName = serviceName; + this.datacenter = datacenter; + this.port = port; + this.host = host; + } + + clusterName(): string { + return `outbound|${this.port}||${this.serviceName}.${this.datacenter}.consul`; + } + + hostName(): string { + if (this.host != "") { + return this.host; + } + return this.serviceName; + } +} + +export class FQDNCluster extends Cluster { + fqdn: string; + host: string; + port: i64; + + constructor(fqdn: string, port: i64, host: string = "") { + super(); + this.fqdn = fqdn; + this.host = host; + this.port = port; + } + + clusterName(): string { + return `outbound|${this.port}||${this.fqdn}`; + } + + hostName(): string { + if (this.host != "") { + return this.host; + } + return this.fqdn; + } +} diff --git a/plugins/wasm-assemblyscript/assembly/http_wrapper.ts b/plugins/wasm-assemblyscript/assembly/http_wrapper.ts new file mode 100644 index 0000000000..695d91b96a --- /dev/null +++ b/plugins/wasm-assemblyscript/assembly/http_wrapper.ts @@ -0,0 +1,120 @@ +import { + Cluster +} from "./cluster_wrapper" + +import { + log, + LogLevelValues, + Headers, + HeaderPair, + root_context, + BufferTypeValues, + get_buffer_bytes, + BaseContext, + stream_context, + WasmResultValues, + RootContext, + ResponseCallBack +} from "@higress/proxy-wasm-assemblyscript-sdk/assembly"; + +export interface HttpClient { + get(path: string, headers: Headers, cb: ResponseCallBack, timeoutMillisecond: u32): boolean; + head(path: string, headers: Headers, cb: ResponseCallBack, timeoutMillisecond: u32): boolean; + options(path: string, headers: Headers, cb: ResponseCallBack, timeoutMillisecond: u32): boolean; + post(path: string, headers: Headers, body: ArrayBuffer, cb: ResponseCallBack, timeoutMillisecond: u32): boolean; + put(path: string, headers: Headers, body: ArrayBuffer, cb: ResponseCallBack, timeoutMillisecond: u32): boolean; + patch(path: string, headers: Headers, body: ArrayBuffer, cb: ResponseCallBack, timeoutMillisecond: u32): boolean; + delete(path: string, headers: Headers, body: ArrayBuffer, cb: ResponseCallBack, timeoutMillisecond: u32): boolean; + connect(path: string, headers: Headers, body: ArrayBuffer, cb: ResponseCallBack, timeoutMillisecond: u32): boolean; + trace(path: string, headers: Headers, body: ArrayBuffer, cb: ResponseCallBack, timeoutMillisecond: u32): boolean; +} + +const methodArrayBuffer: ArrayBuffer = String.UTF8.encode(":method"); +const pathArrayBuffer: ArrayBuffer = String.UTF8.encode(":path"); +const authorityArrayBuffer: ArrayBuffer = String.UTF8.encode(":authority"); + +const StatusBadGateway: i32 = 502; + +export class ClusterClient { + cluster: Cluster; + + constructor(cluster: Cluster) { + this.cluster = cluster; + } + + private httpCall(method: string, path: string, headers: Headers, body: ArrayBuffer, callback: ResponseCallBack, timeoutMillisecond: u32 = 500): boolean { + if (root_context == null) { + log(LogLevelValues.error, "Root context is null"); + return false; + } + for (let i: i32 = headers.length - 1; i >= 0; i--) { + const key = String.UTF8.decode(headers[i].key) + if ((key == ":method") || (key == ":path") || (key == ":authority")) { + headers.splice(i, 1); + } + } + + headers.push(new HeaderPair(methodArrayBuffer, String.UTF8.encode(method))); + headers.push(new HeaderPair(pathArrayBuffer, String.UTF8.encode(path))); + headers.push(new HeaderPair(authorityArrayBuffer, String.UTF8.encode(this.cluster.hostName()))); + + const result = (root_context as RootContext).httpCall(this.cluster.clusterName(), headers, body, [], timeoutMillisecond, root_context as BaseContext, callback, + (_origin_context: BaseContext, _numHeaders: u32, body_size: usize, _trailers: u32, callback: ResponseCallBack): void => { + const respBody = get_buffer_bytes(BufferTypeValues.HttpCallResponseBody, 0, body_size as u32); + const respHeaders = stream_context.headers.http_callback.get_headers() + let code = StatusBadGateway; + let headers = new Array(); + for (let i = 0; i < respHeaders.length; i++) { + const h = respHeaders[i]; + if (String.UTF8.decode(h.key) == ":status") { + code = parseInt(String.UTF8.decode(h.value)) + } + headers.push(new HeaderPair(h.key, h.value)); + } + log(LogLevelValues.debug, `http call end, code: ${code}, body: ${String.UTF8.decode(respBody)}`) + callback(code, headers, respBody); + }) + log(LogLevelValues.debug, `http call start, cluster: ${this.cluster.clusterName()}, method: ${method}, path: ${path}, body: ${String.UTF8.decode(body)}, timeout: ${timeoutMillisecond}`) + if (result != WasmResultValues.Ok) { + log(LogLevelValues.error, `http call failed, result: ${result}`) + return false + } + return true + } + + get(path: string, headers: Headers, cb: ResponseCallBack, timeoutMillisecond: u32 = 500): boolean { + return this.httpCall("GET", path, headers, new ArrayBuffer(0), cb, timeoutMillisecond); + } + + head(path: string, headers: Headers, cb: ResponseCallBack, timeoutMillisecond: u32 = 500): boolean { + return this.httpCall("HEAD", path, headers, new ArrayBuffer(0), cb, timeoutMillisecond); + } + + options(path: string, headers: Headers, cb: ResponseCallBack, timeoutMillisecond: u32 = 500): boolean { + return this.httpCall("OPTIONS", path, headers, new ArrayBuffer(0), cb, timeoutMillisecond); + } + + post(path: string, headers: Headers, body: ArrayBuffer, cb: ResponseCallBack, timeoutMillisecond: u32 = 500): boolean { + return this.httpCall("POST", path, headers, body, cb, timeoutMillisecond); + } + + put(path: string, headers: Headers, body: ArrayBuffer, cb: ResponseCallBack, timeoutMillisecond: u32 = 500): boolean { + return this.httpCall("PUT", path, headers, body, cb, timeoutMillisecond); + } + + patch(path: string, headers: Headers, body: ArrayBuffer, cb: ResponseCallBack, timeoutMillisecond: u32 = 500): boolean { + return this.httpCall("PATCH", path, headers, body, cb, timeoutMillisecond); + } + + delete(path: string, headers: Headers, body: ArrayBuffer, cb: ResponseCallBack, timeoutMillisecond: u32 = 500): boolean { + return this.httpCall("DELETE", path, headers, body, cb, timeoutMillisecond); + } + + connect(path: string, headers: Headers, body: ArrayBuffer, cb: ResponseCallBack, timeoutMillisecond: u32 = 500): boolean { + return this.httpCall("CONNECT", path, headers, body, cb, timeoutMillisecond); + } + + trace(path: string, headers: Headers, body: ArrayBuffer, cb: ResponseCallBack, timeoutMillisecond: u32 = 500): boolean { + return this.httpCall("TRACE", path, headers, body, cb, timeoutMillisecond); + } +} diff --git a/plugins/wasm-assemblyscript/assembly/index.ts b/plugins/wasm-assemblyscript/assembly/index.ts new file mode 100644 index 0000000000..6ff8a48936 --- /dev/null +++ b/plugins/wasm-assemblyscript/assembly/index.ts @@ -0,0 +1,18 @@ +export {RouteCluster, + K8sCluster, + NacosCluster, + ConsulCluster, + FQDNCluster, + StaticIpCluster} from "./cluster_wrapper" +export {HttpClient, + ClusterClient} from "./http_wrapper" +export {Log} from "./log_wrapper" +export {SetCtx, + HttpContext, + ParseConfigBy, + ProcessRequestBodyBy, + ProcessRequestHeadersBy, + ProcessResponseBodyBy, + ProcessResponseHeadersBy, + Logger, RegisteTickFunc} from "./plugin_wrapper" +export {ParseResult} from "./rule_matcher" \ No newline at end of file diff --git a/plugins/wasm-assemblyscript/assembly/log_wrapper.ts b/plugins/wasm-assemblyscript/assembly/log_wrapper.ts new file mode 100644 index 0000000000..15072d013d --- /dev/null +++ b/plugins/wasm-assemblyscript/assembly/log_wrapper.ts @@ -0,0 +1,66 @@ +import { log, LogLevelValues } from "@higress/proxy-wasm-assemblyscript-sdk/assembly"; + +enum LogLevel { + Trace = 0, + Debug, + Info, + Warn, + Error, + Critical, +} + +export class Log { + private pluginName: string; + + constructor(pluginName: string) { + this.pluginName = pluginName; + } + + private log(level: LogLevel, msg: string): void { + let formattedMsg = `[${this.pluginName}] ${msg}`; + switch (level) { + case LogLevel.Trace: + log(LogLevelValues.trace, formattedMsg); + break; + case LogLevel.Debug: + log(LogLevelValues.debug, formattedMsg); + break; + case LogLevel.Info: + log(LogLevelValues.info, formattedMsg); + break; + case LogLevel.Warn: + log(LogLevelValues.warn, formattedMsg); + break; + case LogLevel.Error: + log(LogLevelValues.error, formattedMsg); + break; + case LogLevel.Critical: + log(LogLevelValues.critical, formattedMsg); + break; + } + } + + public Trace(msg: string): void { + this.log(LogLevel.Trace, msg); + } + + public Debug(msg: string): void { + this.log(LogLevel.Debug, msg); + } + + public Info(msg: string): void { + this.log(LogLevel.Info, msg); + } + + public Warn(msg: string): void { + this.log(LogLevel.Warn, msg); + } + + public Error(msg: string): void { + this.log(LogLevel.Error, msg); + } + + public Critical(msg: string): void { + this.log(LogLevel.Critical, msg); + } +} \ No newline at end of file diff --git a/plugins/wasm-assemblyscript/assembly/plugin_wrapper.ts b/plugins/wasm-assemblyscript/assembly/plugin_wrapper.ts new file mode 100644 index 0000000000..52f5b5371c --- /dev/null +++ b/plugins/wasm-assemblyscript/assembly/plugin_wrapper.ts @@ -0,0 +1,445 @@ +import { Log } from "./log_wrapper"; +import { + Context, + FilterHeadersStatusValues, + RootContext, + setRootContext, + proxy_set_effective_context, + log, + LogLevelValues, + FilterDataStatusValues, + get_buffer_bytes, + BufferTypeValues, + set_tick_period_milliseconds, + get_current_time_nanoseconds +} from "@higress/proxy-wasm-assemblyscript-sdk/assembly"; +import { + getRequestHost, + getRequestMethod, + getRequestPath, + getRequestScheme, + isBinaryRequestBody, +} from "./request_wrapper"; +import { RuleMatcher, ParseResult } from "./rule_matcher"; +import { JSON } from "assemblyscript-json/assembly"; + +export function SetCtx( + pluginName: string, + setFuncs: usize[] = [] +): void { + const rootContextId = 1 + setRootContext(new CommonRootCtx(rootContextId, pluginName, setFuncs)); +} + +export interface HttpContext { + Scheme(): string; + Host(): string; + Path(): string; + Method(): string; + SetContext(key: string, value: usize): void; + GetContext(key: string): usize; + DontReadRequestBody(): void; + DontReadResponseBody(): void; +} + +type ParseConfigFunc = ( + json: JSON.Obj, +) => ParseResult; +type OnHttpHeadersFunc = ( + context: HttpContext, + config: PluginConfig, +) => FilterHeadersStatusValues; +type OnHttpBodyFunc = ( + context: HttpContext, + config: PluginConfig, + body: ArrayBuffer, +) => FilterDataStatusValues; + + +export var Logger: Log = new Log(""); + +class CommonRootCtx extends RootContext { + pluginName: string; + hasCustomConfig: boolean; + ruleMatcher: RuleMatcher; + parseConfig: ParseConfigFunc | null; + onHttpRequestHeaders: OnHttpHeadersFunc | null; + onHttpRequestBody: OnHttpBodyFunc | null; + onHttpResponseHeaders: OnHttpHeadersFunc | null; + onHttpResponseBody: OnHttpBodyFunc | null; + onTickFuncs: Array; + + constructor(context_id: u32, pluginName: string, setFuncs: usize[]) { + super(context_id); + this.pluginName = pluginName; + Logger = new Log(pluginName); + this.hasCustomConfig = true; + this.onHttpRequestHeaders = null; + this.onHttpRequestBody = null; + this.onHttpResponseHeaders = null; + this.onHttpResponseBody = null; + this.parseConfig = null; + this.ruleMatcher = new RuleMatcher(); + this.onTickFuncs = new Array(); + for (let i = 0; i < setFuncs.length; i++) { + changetype>(setFuncs[i]).lambdaFn( + setFuncs[i], + this + ); + } + if (this.parseConfig == null) { + this.hasCustomConfig = false; + this.parseConfig = (json: JSON.Obj): ParseResult =>{ return new ParseResult(null, true); }; + } + } + + createContext(context_id: u32): Context { + return new CommonCtx(context_id, this); + } + + onConfigure(configuration_size: u32): boolean { + super.onConfigure(configuration_size); + const data = this.getConfiguration(); + let jsonData: JSON.Obj = new JSON.Obj(); + if (data == "{}") { + if (this.hasCustomConfig) { + log(LogLevelValues.warn, "config is empty, but has ParseConfigFunc"); + } + } else { + const parseData = JSON.parse(data); + if (parseData.isObj) { + jsonData = changetype(JSON.parse(data)); + } else { + log(LogLevelValues.error, "parse json data failed") + return false; + } + } + + if (!this.ruleMatcher.parseRuleConfig(jsonData, this.parseConfig as ParseConfigFunc)) { + return false; + } + + if (globalOnTickFuncs.length > 0) { + this.onTickFuncs = globalOnTickFuncs; + set_tick_period_milliseconds(100); + } + return true; + } + + onTick(): void { + for (let i = 0; i < this.onTickFuncs.length; i++) { + const tickFuncEntry = this.onTickFuncs[i]; + const now = getCurrentTimeMilliseconds(); + if (tickFuncEntry.lastExecuted + tickFuncEntry.tickPeriod <= now) { + tickFuncEntry.tickFunc(); + tickFuncEntry.lastExecuted = getCurrentTimeMilliseconds(); + } + } + } +} + +function getCurrentTimeMilliseconds(): u64 { + return get_current_time_nanoseconds() / 1000000; +} + +class TickFuncEntry { + lastExecuted: u64; + tickPeriod: u64; + tickFunc: () => void; + + constructor(lastExecuted: u64, tickPeriod: u64, tickFunc: () => void) { + this.lastExecuted = lastExecuted; + this.tickPeriod = tickPeriod; + this.tickFunc = tickFunc; + } +} + +var globalOnTickFuncs = new Array(); + +export function RegisteTickFunc(tickPeriod: i64, tickFunc: () => void): void { + globalOnTickFuncs.push(new TickFuncEntry(0, tickPeriod, tickFunc)); +} + +class Closure { + lambdaFn: (closure: usize, ctx: CommonRootCtx) => void; + parseConfigFunc: ParseConfigFunc | null; + onHttpHeadersFunc: OnHttpHeadersFunc | null; + OnHttpBodyFunc: OnHttpBodyFunc | null; + + constructor( + lambdaFn: (closure: usize, ctx: CommonRootCtx) => void + ) { + this.lambdaFn = lambdaFn; + this.parseConfigFunc = null; + this.onHttpHeadersFunc = null; + this.OnHttpBodyFunc = null; + } + + setParseConfigFunc(f: ParseConfigFunc): void { + this.parseConfigFunc = f; + } + + setHttpHeadersFunc(f: OnHttpHeadersFunc): void { + this.onHttpHeadersFunc = f; + } + + setHttpBodyFunc(f: OnHttpBodyFunc): void { + this.OnHttpBodyFunc = f; + } +} + +export function ParseConfigBy( + f: ParseConfigFunc +): usize { + const lambdaFn = function ( + closure: usize, + ctx: CommonRootCtx + ): void { + const f = changetype>(closure).parseConfigFunc; + if (f != null) { + ctx.parseConfig = f; + } + }; + const closure = new Closure(lambdaFn); + closure.setParseConfigFunc(f); + return changetype(closure); +} + +export function ProcessRequestHeadersBy( + f: OnHttpHeadersFunc +): usize { + const lambdaFn = function ( + closure: usize, + ctx: CommonRootCtx + ): void { + const f = changetype>(closure).onHttpHeadersFunc; + if (f != null) { + ctx.onHttpRequestHeaders = f; + } + }; + const closure = new Closure(lambdaFn); + closure.setHttpHeadersFunc(f); + return changetype(closure); +} + +export function ProcessRequestBodyBy( + f: OnHttpBodyFunc +): usize { + const lambdaFn = function ( + closure: usize, + ctx: CommonRootCtx + ): void { + const f = changetype>(closure).OnHttpBodyFunc; + if (f != null) { + ctx.onHttpRequestBody = f; + } + }; + const closure = new Closure(lambdaFn); + closure.setHttpBodyFunc(f); + return changetype(closure); +} + +export function ProcessResponseHeadersBy( + f: OnHttpHeadersFunc +): usize { + const lambdaFn = function ( + closure: usize, + ctx: CommonRootCtx + ): void { + const f = changetype>(closure).onHttpHeadersFunc; + if (f != null) { + ctx.onHttpResponseHeaders = f; + } + }; + const closure = new Closure(lambdaFn); + closure.setHttpHeadersFunc(f); + return changetype(closure); +} + +export function ProcessResponseBodyBy( + f: OnHttpBodyFunc +): usize { + const lambdaFn = function ( + closure: usize, + ctx: CommonRootCtx + ): void { + const f = changetype>(closure).OnHttpBodyFunc; + if (f != null) { + ctx.onHttpResponseBody = f; + } + }; + const closure = new Closure(lambdaFn); + closure.setHttpBodyFunc(f); + return changetype(closure); +} + +class CommonCtx extends Context implements HttpContext { + commonRootCtx: CommonRootCtx; + config: PluginConfig |null; + needRequestBody: boolean; + needResponseBody: boolean; + requestBodySize: u32; + responseBodySize: u32; + contextID: u32; + userContext: Map; + + constructor(context_id: u32, root_context: CommonRootCtx) { + super(context_id, root_context); + this.userContext = new Map(); + this.commonRootCtx = root_context; + this.contextID = context_id; + this.requestBodySize = 0; + this.responseBodySize = 0; + this.config = null + if (this.commonRootCtx.onHttpRequestHeaders != null) { + this.needResponseBody = true; + } else { + this.needResponseBody = false; + } + if (this.commonRootCtx.onHttpRequestBody != null) { + this.needRequestBody = true; + } else { + this.needRequestBody = false; + } + } + + SetContext(key: string, value: usize): void { + this.userContext.set(key, value); + } + + GetContext(key: string): usize { + return this.userContext.get(key); + } + + Scheme(): string { + proxy_set_effective_context(this.contextID); + return getRequestScheme(); + } + + Host(): string { + proxy_set_effective_context(this.contextID); + return getRequestHost(); + } + + Path(): string { + proxy_set_effective_context(this.contextID); + return getRequestPath(); + } + + Method(): string { + proxy_set_effective_context(this.contextID); + return getRequestMethod(); + } + + DontReadRequestBody(): void { + this.needRequestBody = false; + } + + DontReadResponseBody(): void { + this.needResponseBody = false; + } + + onRequestHeaders(_a: u32, _end_of_stream: boolean): FilterHeadersStatusValues { + const parseResult = this.commonRootCtx.ruleMatcher.getMatchConfig(); + if (parseResult.success == false) { + log(LogLevelValues.error, "get match config failed"); + return FilterHeadersStatusValues.Continue; + } + this.config = parseResult.pluginConfig; + + if (isBinaryRequestBody()) { + this.needRequestBody = false; + } + + if (this.commonRootCtx.onHttpRequestHeaders == null) { + return FilterHeadersStatusValues.Continue; + } + return this.commonRootCtx.onHttpRequestHeaders( + this, + this.config as PluginConfig + ); + } + + onRequestBody( + body_buffer_length: usize, + end_of_stream: boolean + ): FilterDataStatusValues { + if (this.config == null || !this.needRequestBody) { + return FilterDataStatusValues.Continue; + } + + if (this.commonRootCtx.onHttpRequestBody == null) { + return FilterDataStatusValues.Continue; + } + this.requestBodySize += body_buffer_length as u32; + + if (!end_of_stream) { + return FilterDataStatusValues.StopIterationAndBuffer; + } + + const body = get_buffer_bytes( + BufferTypeValues.HttpRequestBody, + 0, + this.requestBodySize + ); + + return this.commonRootCtx.onHttpRequestBody( + this, + this.config as PluginConfig, + body + ); + } + + onResponseHeaders(_a: u32, _end_of_stream: bool): FilterHeadersStatusValues { + if (this.config == null) { + return FilterHeadersStatusValues.Continue; + } + + if (isBinaryRequestBody()) { + this.needResponseBody = false; + } + + if (this.commonRootCtx.onHttpResponseHeaders == null) { + return FilterHeadersStatusValues.Continue; + } + + return this.commonRootCtx.onHttpResponseHeaders( + this, + this.config as PluginConfig + ); + } + + onResponseBody( + body_buffer_length: usize, + end_of_stream: bool + ): FilterDataStatusValues { + if (this.config == null) { + return FilterDataStatusValues.Continue; + } + + if (this.commonRootCtx.onHttpResponseBody == null) { + return FilterDataStatusValues.Continue; + } + + if (!this.needResponseBody) { + return FilterDataStatusValues.Continue; + } + + this.responseBodySize += body_buffer_length as u32; + + if (!end_of_stream) { + return FilterDataStatusValues.StopIterationAndBuffer; + } + const body = get_buffer_bytes( + BufferTypeValues.HttpResponseBody, + 0, + this.responseBodySize + ); + + return this.commonRootCtx.onHttpResponseBody( + this, + this.config as PluginConfig, + body + ); + } +} diff --git a/plugins/wasm-assemblyscript/assembly/request_wrapper.ts b/plugins/wasm-assemblyscript/assembly/request_wrapper.ts new file mode 100644 index 0000000000..6a017575ee --- /dev/null +++ b/plugins/wasm-assemblyscript/assembly/request_wrapper.ts @@ -0,0 +1,65 @@ +import { + stream_context, + log, + LogLevelValues +} from "@higress/proxy-wasm-assemblyscript-sdk/assembly"; + +export function getRequestScheme(): string { + let scheme: string = stream_context.headers.request.get(":scheme"); + if (scheme == "") { + log(LogLevelValues.error, "Parse request scheme failed"); + } + return scheme; +} + +export function getRequestHost(): string { + let host: string = stream_context.headers.request.get(":authority"); + if (host == "") { + log(LogLevelValues.error, "Parse request host failed"); + } + return host; +} + +export function getRequestPath(): string { + let path: string = stream_context.headers.request.get(":path"); + if (path == "") { + log(LogLevelValues.error, "Parse request path failed"); + } + return path; +} + +export function getRequestMethod(): string { + let method: string = stream_context.headers.request.get(":method"); + if (method == "") { + log(LogLevelValues.error, "Parse request method failed"); + } + return method; +} + +export function isBinaryRequestBody(): boolean { + let contentType: string = stream_context.headers.request.get("content-type"); + if (contentType != "" && (contentType.includes("octet-stream") || contentType.includes("grpc"))) { + return true; + } + + let encoding: string = stream_context.headers.request.get("content-encoding"); + if (encoding != "") { + return true; + } + + return false; +} + +export function isBinaryResponseBody(): boolean { + let contentType: string = stream_context.headers.response.get("content-type"); + if (contentType != "" && (contentType.includes("octet-stream") || contentType.includes("grpc"))) { + return true; + } + + let encoding: string = stream_context.headers.response.get("content-encoding"); + if (encoding != "") { + return true; + } + + return false; +} \ No newline at end of file diff --git a/plugins/wasm-assemblyscript/assembly/rule_matcher.ts b/plugins/wasm-assemblyscript/assembly/rule_matcher.ts new file mode 100644 index 0000000000..ed625e311d --- /dev/null +++ b/plugins/wasm-assemblyscript/assembly/rule_matcher.ts @@ -0,0 +1,346 @@ +import { getRequestHost } from "./request_wrapper"; +import { + get_property, + LogLevelValues, + log, + WasmResultValues, +} from "@higress/proxy-wasm-assemblyscript-sdk/assembly"; +import { JSON } from "assemblyscript-json/assembly"; + +enum Category { + Route, + Host, + RoutePrefix, + Service +} + +enum MatchType { + Prefix, + Exact, + Suffix, +} + +const RULES_KEY: string = "_rules_"; +const MATCH_ROUTE_KEY: string = "_match_route_"; +const MATCH_DOMAIN_KEY: string = "_match_domain_"; +const MATCH_SERVICE_KEY: string = "_match_service_"; +const MATCH_ROUTE_PREFIX_KEY: string = "_match_route_prefix_" + +class HostMatcher { + matchType: MatchType; + host: string; + + constructor(matchType: MatchType, host: string) { + this.matchType = matchType; + this.host = host; + } +} + +class RuleConfig { + category: Category; + routes!: Map; + services!: Map; + routePrefixs!: Map; + hosts!: Array; + config: PluginConfig | null; + + constructor() { + this.category = Category.Route; + this.config = null; + } +} + +export class ParseResult { + pluginConfig: PluginConfig | null; + success: boolean; + constructor(pluginConfig: PluginConfig | null, success: boolean) { + this.pluginConfig = pluginConfig; + this.success = success; + } +} + +export class RuleMatcher { + ruleConfig: Array>; + globalConfig: PluginConfig | null; + hasGlobalConfig: boolean; + + constructor() { + this.ruleConfig = new Array>(); + this.globalConfig = null; + this.hasGlobalConfig = false; + } + + getMatchConfig(): ParseResult { + const host = getRequestHost(); + if (host == "") { + return new ParseResult(null, false); + } + let result = get_property("route_name"); + if (result.status != WasmResultValues.Ok && result.status != WasmResultValues.NotFound) { + return new ParseResult(null, false); + } + const routeName = String.UTF8.decode(result.returnValue); + + result = get_property("cluster_name"); + if (result.status != WasmResultValues.Ok && result.status != WasmResultValues.NotFound) { + return new ParseResult(null, false); + } + const serviceName = String.UTF8.decode(result.returnValue); + + for (let i = 0; i < this.ruleConfig.length; i++) { + const rule = this.ruleConfig[i]; + // category == Host + if (rule.category == Category.Host) { + if (this.hostMatch(rule, host)) { + log(LogLevelValues.debug, "getMatchConfig: match host " + host); + return new ParseResult(rule.config, true); + } + } + // category == Route + if (rule.category == Category.Route) { + if (rule.routes.has(routeName)) { + log(LogLevelValues.debug, "getMatchConfig: match route " + routeName); + return new ParseResult(rule.config, true); + } + } + // category == RoutePrefix + if (rule.category == Category.RoutePrefix) { + for (let i = 0; i < rule.routePrefixs.keys().length; i++) { + const routePrefix = rule.routePrefixs.keys()[i]; + if (routeName.startsWith(routePrefix)) { + return new ParseResult(rule.config, true); + } + } + } + // category == Cluster + if (this.serviceMatch(rule, serviceName)) { + return new ParseResult(rule.config, true); + } + } + + if (this.hasGlobalConfig) { + return new ParseResult(this.globalConfig, true); + } + return new ParseResult(null, false); + } + + parseRuleConfig( + config: JSON.Obj, + parsePluginConfig: (json: JSON.Obj) => ParseResult + ): boolean { + const obj = config; + let keyCount = obj.keys.length; + if (keyCount == 0) { + this.hasGlobalConfig = true; + const parseResult = parsePluginConfig(config); + if (parseResult.success) { + this.globalConfig = parseResult.pluginConfig; + return true; + } else { + return false; + } + } + + let rules: JSON.Arr | null = null; + if (obj.has(RULES_KEY)) { + rules = obj.getArr(RULES_KEY); + keyCount--; + } + + if (keyCount > 0) { + const parseResult = parsePluginConfig(config); + if (parseResult.success) { + this.globalConfig = parseResult.pluginConfig; + this.hasGlobalConfig = true; + } + } + + if (!rules) { + if (this.hasGlobalConfig) { + return true; + } + log(LogLevelValues.error, "parse config failed, no valid rules; global config parse error"); + return false; + } + + const rulesArray = rules.valueOf(); + for (let i = 0; i < rulesArray.length; i++) { + if (!rulesArray[i].isObj) { + log(LogLevelValues.error, "parse rule failed, rules must be an array of objects"); + continue; + } + const ruleJson = changetype(rulesArray[i]); + const rule = new RuleConfig(); + const parseResult = parsePluginConfig(ruleJson); + if (parseResult.success) { + rule.config = parseResult.pluginConfig; + } else { + return false; + } + + rule.routes = this.parseRouteMatchConfig(ruleJson); + rule.hosts = this.parseHostMatchConfig(ruleJson); + rule.services = this.parseServiceMatchConfig(ruleJson); + rule.routePrefixs = this.parseRoutePrefixMatchConfig(ruleJson); + + const noRoute = rule.routes.size == 0; + const noHosts = rule.hosts.length == 0; + const noServices = rule.services.size == 0; + const noRoutePrefixs = rule.routePrefixs.size == 0; + + if ((boolToInt(noRoute) + boolToInt(noHosts) + boolToInt(noServices) + boolToInt(noRoutePrefixs)) != 3) { + log(LogLevelValues.error, "there is only one of '_match_route_', '_match_domain_', '_match_service_' and '_match_route_prefix_' can present in configuration."); + return false; + } + if (!noRoute) { + rule.category = Category.Route; + } else if (!noHosts) { + rule.category = Category.Host; + } else if (!noServices) { + rule.category = Category.Service; + } else { + rule.category = Category.RoutePrefix; + } + this.ruleConfig.push(rule); + } + return true; + } + + parseRouteMatchConfig(config: JSON.Obj): Map { + const keys = config.getArr(MATCH_ROUTE_KEY); + const routes = new Map(); + if (keys) { + const array = keys.valueOf(); + for (let i = 0; i < array.length; i++) { + const key = array[i].toString(); + if (key != "") { + routes.set(key, true); + } + } + } + return routes; + } + + parseRoutePrefixMatchConfig(config: JSON.Obj): Map { + const keys = config.getArr(MATCH_ROUTE_PREFIX_KEY); + const routePrefixs = new Map(); + if (keys) { + const array = keys.valueOf(); + for (let i = 0; i < array.length; i++) { + const key = array[i].toString(); + if (key != "") { + routePrefixs.set(key, true); + } + } + } + return routePrefixs; + } + + parseServiceMatchConfig(config: JSON.Obj): Map { + const keys = config.getArr(MATCH_SERVICE_KEY); + const clusters = new Map(); + if (keys) { + const array = keys.valueOf(); + for (let i = 0; i < array.length; i++) { + const key = array[i].toString(); + if (key != "") { + clusters.set(key, true); + } + } + } + return clusters; + } + + parseHostMatchConfig(config: JSON.Obj): Array { + const hostMatchers = new Array(); + const keys = config.getArr(MATCH_DOMAIN_KEY); + if (keys !== null) { + const array = keys.valueOf(); + for (let i = 0; i < array.length; i++) { + const item = array[i].toString(); // Assuming the array has string elements + let hostMatcher: HostMatcher; + if (item.startsWith("*")) { + hostMatcher = new HostMatcher(MatchType.Suffix, item.substr(1)); + } else if (item.endsWith("*")) { + hostMatcher = new HostMatcher( + MatchType.Prefix, + item.substr(0, item.length - 1) + ); + } else { + hostMatcher = new HostMatcher(MatchType.Exact, item); + } + hostMatchers.push(hostMatcher); + } + } + return hostMatchers; + } + + stripPortFromHost(reqHost: string): string { + // Port removing code is inspired by + // https://github.com/envoyproxy/envoy/blob/v1.17.0/source/common/http/header_utility.cc#L219 + let portStart: i32 = reqHost.lastIndexOf(":"); + if (portStart != -1) { + // According to RFC3986 v6 address is always enclosed in "[]". + // section 3.2.2. + let v6EndIndex: i32 = reqHost.lastIndexOf("]"); + if (v6EndIndex == -1 || v6EndIndex < portStart) { + if (portStart + 1 <= reqHost.length) { + return reqHost.substring(0, portStart); + } + } + } + return reqHost; + } + + hostMatch(rule: RuleConfig, reqHost: string): boolean { + reqHost = this.stripPortFromHost(reqHost); + for (let i = 0; i < rule.hosts.length; i++) { + let hostMatch = rule.hosts[i]; + switch (hostMatch.matchType) { + case MatchType.Suffix: + if (reqHost.endsWith(hostMatch.host)) { + return true; + } + break; + case MatchType.Prefix: + if (reqHost.startsWith(hostMatch.host)) { + return true; + } + break; + case MatchType.Exact: + if (reqHost == hostMatch.host) { + return true; + } + break; + default: + return false; + } + } + return false; + } + + serviceMatch(rule: RuleConfig, serviceName: string): boolean { + const parts = serviceName.split('|'); + if (parts.length != 4) { + return false; + } + const port = parts[1]; + const fqdn = parts[3]; + for (let i = 0; i < rule.services.keys().length; i++) { + let configServiceName = rule.services.keys()[i]; + let colonIndex = configServiceName.lastIndexOf(':'); + if (colonIndex != -1) { + let configFQDN = configServiceName.slice(0, colonIndex); + let configPort = configServiceName.slice(colonIndex + 1); + if (fqdn == configFQDN && port == configPort) return true; + } else if (fqdn == configServiceName) { + return true; + } + } + return false; + } +} + +function boolToInt(value: boolean): i32 { + return value ? 1 : 0; +} \ No newline at end of file diff --git a/plugins/wasm-assemblyscript/assembly/tsconfig.json b/plugins/wasm-assemblyscript/assembly/tsconfig.json new file mode 100644 index 0000000000..e28fcf2573 --- /dev/null +++ b/plugins/wasm-assemblyscript/assembly/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "assemblyscript/std/assembly.json", + "include": [ + "./**/*.ts" + ] +} \ No newline at end of file diff --git a/plugins/wasm-assemblyscript/extensions/custom-response/README.md b/plugins/wasm-assemblyscript/extensions/custom-response/README.md new file mode 100644 index 0000000000..d3f07f8cd5 --- /dev/null +++ b/plugins/wasm-assemblyscript/extensions/custom-response/README.md @@ -0,0 +1,80 @@ +# 功能说明 +`custom-response`插件支持配置自定义的响应,包括自定义 HTTP 应答状态码、HTTP 应答头,以及 HTTP 应答 Body。可以用于 Mock 响应,也可以用于判断特定状态码后给出自定义应答,例如在触发网关限流策略时实现自定义响应。 + +# 配置字段 + +| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 | +| -------- | -------- | -------- | -------- | -------- | +| status_code | number | 选填 | 200 | 自定义 HTTP 应答状态码 | +| headers | array of string | 选填 | - | 自定义 HTTP 应答头,key 和 value 用`=`分隔 | +| body | string | 选填 | - | 自定义 HTTP 应答 Body | +| enable_on_status | array of number | 选填 | - | 匹配原始状态码,生成自定义响应,不填写时,不判断原始状态码 | + +# 配置示例 + +## Mock 应答场景 + +```yaml +status_code: 200 +headers: +- Content-Type=application/json +- Hello=World +body: "{\"hello\":\"world\"}" + +``` + +根据该配置,请求将返回自定义应答如下: + +```text +HTTP/1.1 200 OK +Content-Type: application/json +Hello: World +Content-Length: 17 + +{"hello":"world"} +``` + +## 触发限流时自定义响应 + +```yaml +enable_on_status: +- 429 +status_code: 302 +headers: +- Location=https://example.com +``` + +触发网关限流时一般会返回 `429` 状态码,这时请求将返回自定义应答如下: + +```text +HTTP/1.1 302 Found +Location: https://example.com +``` + +从而实现基于浏览器 302 重定向机制,将限流后的用户引导到其他页面,比如可以是一个 CDN 上的静态页面。 + +如果希望触发限流时,正常返回其他应答,参考 Mock 应答场景配置相应的字段即可。 + +## 对特定路由或域名开启 +```yaml +# 使用 matchRules 字段进行细粒度规则配置 +matchRules: +# 规则一:按 Ingress 名称匹配生效 +- ingress: + - default/foo + - default/bar + body: "{\"hello\":\"world\"}" +# 规则二:按域名匹配生效 +- domain: + - "*.example.com" + - test.com + enable_on_status: + - 429 + status_code: 200 + headers: + - Content-Type=application/json + body: "{\"errmsg\": \"rate limited\"}" +``` +此例 `ingress` 中指定的 `default/foo` 和 `default/bar` 对应 default 命名空间下名为 foo 和 bar 的 Ingress,当匹配到这两个 Ingress 时,将使用此段配置; +此例 `domain` 中指定的 `*.example.com` 和 `test.com` 用于匹配请求的域名,当发现域名匹配时,将使用此段配置; +配置的匹配生效顺序,将按照 `matchRules` 下规则的排列顺序,匹配第一个规则后生效对应配置,后续规则将被忽略。 \ No newline at end of file diff --git a/plugins/wasm-assemblyscript/extensions/custom-response/asconfig.json b/plugins/wasm-assemblyscript/extensions/custom-response/asconfig.json new file mode 100644 index 0000000000..f3435b13c7 --- /dev/null +++ b/plugins/wasm-assemblyscript/extensions/custom-response/asconfig.json @@ -0,0 +1,24 @@ +{ + "targets": { + "debug": { + "outFile": "build/debug.wasm", + "textFile": "build/debug.wat", + "sourceMap": true, + "debug": true + }, + "release": { + "outFile": "build/release.wasm", + "textFile": "build/release.wat", + "sourceMap": true, + "optimizeLevel": 3, + "shrinkLevel": 0, + "converge": false, + "noAssert": false, + "debug": true + } + }, + "options": { + "bindings": "esm", + "use": "abort=abort_proc_exit" + } +} \ No newline at end of file diff --git a/plugins/wasm-assemblyscript/extensions/custom-response/assembly/index.ts b/plugins/wasm-assemblyscript/extensions/custom-response/assembly/index.ts new file mode 100644 index 0000000000..8619528250 --- /dev/null +++ b/plugins/wasm-assemblyscript/extensions/custom-response/assembly/index.ts @@ -0,0 +1,96 @@ +export * from "@higress/proxy-wasm-assemblyscript-sdk/assembly/proxy"; +import { SetCtx, HttpContext, ProcessRequestHeadersBy, Logger, ParseConfigBy, ParseResult, ProcessResponseHeadersBy } from "@higress/wasm-assemblyscript/assembly"; +import { FilterHeadersStatusValues, Headers, send_http_response, stream_context, HeaderPair } from "@higress/proxy-wasm-assemblyscript-sdk/assembly" +import { JSON } from "assemblyscript-json/assembly"; + +class CustomResponseConfig { + statusCode: u32; + headers: Headers; + body: ArrayBuffer; + enableOnStatus: Array; + contentType: string; + constructor() { + this.statusCode = 200; + this.headers = []; + this.body = new ArrayBuffer(0); + this.enableOnStatus = []; + this.contentType = "text/plain; charset=utf-8"; + } +} + +SetCtx( + "custom-response", + [ParseConfigBy(parseConfig), + ProcessRequestHeadersBy(onHttpRequestHeaders), + ProcessResponseHeadersBy(onHttpResponseHeaders),]) + +function parseConfig(json: JSON.Obj): ParseResult { + let headersArray = json.getArr("headers"); + let config = new CustomResponseConfig(); + if (headersArray != null) { + for (let i = 0; i < headersArray.valueOf().length; i++) { + let header = headersArray._arr[i]; + let jsonString = (header).toString() + let kv = jsonString.split("=") + if (kv.length == 2) { + let key = kv[0].trim(); + let value = kv[1].trim(); + if (key.toLowerCase() == "content-type") { + config.contentType = value; + } else if (key.toLowerCase() == "content-length") { + continue; + } else { + config.headers.push(new HeaderPair(String.UTF8.encode(key), String.UTF8.encode(value))); + } + } else { + Logger.Error("parse header failed"); + return new ParseResult(null, false); + } + } + } + let body = json.getString("body"); + if (body != null) { + config.body = String.UTF8.encode(body.valueOf()); + } + config.headers.push(new HeaderPair(String.UTF8.encode("content-type"), String.UTF8.encode(config.contentType))); + + let statusCode = json.getInteger("statusCode"); + if (statusCode != null) { + config.statusCode = statusCode.valueOf() as u32; + } + + let enableOnStatus = json.getArr("enableOnStatus"); + + if (enableOnStatus != null) { + for (let i = 0; i < enableOnStatus.valueOf().length; i++) { + let status = enableOnStatus._arr[i]; + if (status.isInteger) { + config.enableOnStatus.push((status).valueOf() as u32); + } + } + } + return new ParseResult(config, true); +} + +function onHttpRequestHeaders(context: HttpContext, config: CustomResponseConfig): FilterHeadersStatusValues { + if (config.enableOnStatus.length != 0) { + return FilterHeadersStatusValues.Continue; + } + send_http_response(config.statusCode, "custom-response", config.body, config.headers); + return FilterHeadersStatusValues.StopIteration; +} + +function onHttpResponseHeaders(context: HttpContext, config: CustomResponseConfig): FilterHeadersStatusValues { + let statusCodeStr = stream_context.headers.response.get(":status") + if (statusCodeStr == "") { + Logger.Error("get http response status code failed"); + return FilterHeadersStatusValues.Continue; + } + let statusCode = parseInt(statusCodeStr); + for (let i = 0; i < config.enableOnStatus.length; i++) { + if (statusCode == config.enableOnStatus[i]) { + send_http_response(config.statusCode, "custom-response", config.body, config.headers); + } + } + return FilterHeadersStatusValues.Continue; +} diff --git a/plugins/wasm-assemblyscript/extensions/custom-response/assembly/tsconfig.json b/plugins/wasm-assemblyscript/extensions/custom-response/assembly/tsconfig.json new file mode 100644 index 0000000000..e28fcf2573 --- /dev/null +++ b/plugins/wasm-assemblyscript/extensions/custom-response/assembly/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "assemblyscript/std/assembly.json", + "include": [ + "./**/*.ts" + ] +} \ No newline at end of file diff --git a/plugins/wasm-assemblyscript/extensions/custom-response/package-lock.json b/plugins/wasm-assemblyscript/extensions/custom-response/package-lock.json new file mode 100644 index 0000000000..bbc970b844 --- /dev/null +++ b/plugins/wasm-assemblyscript/extensions/custom-response/package-lock.json @@ -0,0 +1,75 @@ +{ + "name": "custom-response", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "custom-response", + "version": "1.0.0", + "license": "ISC", + "devDependencies": { + "@higress/proxy-wasm-assemblyscript-sdk": "^0.0.2", + "@higress/wasm-assemblyscript": "^0.0.3", + "assemblyscript": "^0.27.29", + "assemblyscript-json": "^1.1.0" + } + }, + "node_modules/@higress/proxy-wasm-assemblyscript-sdk": { + "version": "0.0.2", + "resolved": "https://registry.npmmirror.com/@higress/proxy-wasm-assemblyscript-sdk/-/proxy-wasm-assemblyscript-sdk-0.0.2.tgz", + "integrity": "sha512-0J1tFJMTE6o37JpGJBLq0wc5kBC/fpbISrP+KFb4bAEeshu6daXzD2P3bAfJXmW+oZdY0WGptTGXWx8pf9Fk+g==", + "dev": true + }, + "node_modules/@higress/wasm-assemblyscript": { + "version": "0.0.3", + "resolved": "https://registry.npmmirror.com/@higress/wasm-assemblyscript/-/wasm-assemblyscript-0.0.3.tgz", + "integrity": "sha512-D9hTvjAt54WoedNBsYAp9q/mDPWOO9yoGY7yG7Gkgp3KB7O5lHEEu2T6V8K14DpfC8ObSP26EhBcJ6G70JjODg==", + "dev": true + }, + "node_modules/assemblyscript": { + "version": "0.27.29", + "resolved": "https://registry.npmmirror.com/assemblyscript/-/assemblyscript-0.27.29.tgz", + "integrity": "sha512-pH6udb7aE2F0t6cTh+0uCepmucykhMnAmm7k0kkAU3SY7LvpIngEBZWM6p5VCguu4EpmKGwEuZpZbEXzJ/frHQ==", + "dev": true, + "dependencies": { + "binaryen": "116.0.0-nightly.20240114", + "long": "^5.2.1" + }, + "bin": { + "asc": "bin/asc.js", + "asinit": "bin/asinit.js" + }, + "engines": { + "node": ">=16", + "npm": ">=7" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/assemblyscript" + } + }, + "node_modules/assemblyscript-json": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/assemblyscript-json/-/assemblyscript-json-1.1.0.tgz", + "integrity": "sha512-UbE8ts8csTWQgd5TnSPN7MRV9NveuHv1bVnKmDLoo/tzjqxkmsZb3lu59Uk8H7SGoqdkDSEE049alx/nHnSdFw==", + "dev": true + }, + "node_modules/binaryen": { + "version": "116.0.0-nightly.20240114", + "resolved": "https://registry.npmmirror.com/binaryen/-/binaryen-116.0.0-nightly.20240114.tgz", + "integrity": "sha512-0GZrojJnuhoe+hiwji7QFaL3tBlJoA+KFUN7ouYSDGZLSo9CKM8swQX8n/UcbR0d1VuZKU+nhogNzv423JEu5A==", + "dev": true, + "bin": { + "wasm-opt": "bin/wasm-opt", + "wasm2js": "bin/wasm2js" + } + }, + "node_modules/long": { + "version": "5.2.3", + "resolved": "https://registry.npmmirror.com/long/-/long-5.2.3.tgz", + "integrity": "sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q==", + "dev": true + } + } +} diff --git a/plugins/wasm-assemblyscript/extensions/custom-response/package.json b/plugins/wasm-assemblyscript/extensions/custom-response/package.json new file mode 100644 index 0000000000..60f2f359df --- /dev/null +++ b/plugins/wasm-assemblyscript/extensions/custom-response/package.json @@ -0,0 +1,28 @@ +{ + "name": "custom-response", + "version": "1.0.0", + "main": "index.js", + "scripts": { + "test": "node tests", + "asbuild:debug": "asc assembly/index.ts --target debug", + "asbuild:release": "asc assembly/index.ts --target release", + "asbuild": "npm run asbuild:debug && npm run asbuild:release", + "start": "npx serve ." + }, + "author": "", + "license": "ISC", + "description": "", + "devDependencies": { + "assemblyscript": "^0.27.29", + "assemblyscript-json": "^1.1.0", + "@higress/proxy-wasm-assemblyscript-sdk": "^0.0.2", + "@higress/wasm-assemblyscript": "^0.0.3" + }, + "type": "module", + "exports": { + ".": { + "import": "./build/release.js", + "types": "./build/release.d.ts" + } + } +} \ No newline at end of file diff --git a/plugins/wasm-assemblyscript/extensions/hello-world/asconfig.json b/plugins/wasm-assemblyscript/extensions/hello-world/asconfig.json new file mode 100644 index 0000000000..f3435b13c7 --- /dev/null +++ b/plugins/wasm-assemblyscript/extensions/hello-world/asconfig.json @@ -0,0 +1,24 @@ +{ + "targets": { + "debug": { + "outFile": "build/debug.wasm", + "textFile": "build/debug.wat", + "sourceMap": true, + "debug": true + }, + "release": { + "outFile": "build/release.wasm", + "textFile": "build/release.wat", + "sourceMap": true, + "optimizeLevel": 3, + "shrinkLevel": 0, + "converge": false, + "noAssert": false, + "debug": true + } + }, + "options": { + "bindings": "esm", + "use": "abort=abort_proc_exit" + } +} \ No newline at end of file diff --git a/plugins/wasm-assemblyscript/extensions/hello-world/assembly/index.ts b/plugins/wasm-assemblyscript/extensions/hello-world/assembly/index.ts new file mode 100644 index 0000000000..e7fb7b5d17 --- /dev/null +++ b/plugins/wasm-assemblyscript/extensions/hello-world/assembly/index.ts @@ -0,0 +1,42 @@ +export * from "@higress/proxy-wasm-assemblyscript-sdk/assembly/proxy"; +import { SetCtx, HttpContext, ProcessRequestHeadersBy, Logger, ParseResult, ParseConfigBy, RegisteTickFunc, ProcessResponseHeadersBy } from "@higress/wasm-assemblyscript/assembly"; +import { FilterHeadersStatusValues, send_http_response, stream_context } from "@higress/proxy-wasm-assemblyscript-sdk/assembly" +import { JSON } from "assemblyscript-json/assembly"; +class HelloWorldConfig { +} + +SetCtx("hello-world", + [ParseConfigBy(parseConfig), + ProcessRequestHeadersBy(onHttpRequestHeaders), + ProcessResponseHeadersBy(onHttpResponseHeaders) + ]) + +function parseConfig(json: JSON.Obj): ParseResult { + RegisteTickFunc(2000, () => { + Logger.Debug("tick 2s"); + }) + RegisteTickFunc(5000, () => { + Logger.Debug("tick 5s"); + }) + return new ParseResult(new HelloWorldConfig(), true); +} + +class TestContext{ + value: string + constructor(value: string){ + this.value = value + } +} +function onHttpRequestHeaders(context: HttpContext, config: HelloWorldConfig): FilterHeadersStatusValues { + stream_context.headers.request.add("hello", "world"); + Logger.Debug("[hello-world] logger test"); + context.SetContext("test-set-context", changetype(new TestContext("value"))) + send_http_response(200, "hello-world", String.UTF8.encode("[wasm-assemblyscript]hello world"), []); + return FilterHeadersStatusValues.Continue; +} + +function onHttpResponseHeaders(context: HttpContext, config: HelloWorldConfig): FilterHeadersStatusValues { + const str = changetype(context.GetContext("test-set-context")).value; + Logger.Debug("[hello-world] test-set-context: " + str); + return FilterHeadersStatusValues.Continue; +} \ No newline at end of file diff --git a/plugins/wasm-assemblyscript/extensions/hello-world/assembly/tsconfig.json b/plugins/wasm-assemblyscript/extensions/hello-world/assembly/tsconfig.json new file mode 100644 index 0000000000..e28fcf2573 --- /dev/null +++ b/plugins/wasm-assemblyscript/extensions/hello-world/assembly/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "assemblyscript/std/assembly.json", + "include": [ + "./**/*.ts" + ] +} \ No newline at end of file diff --git a/plugins/wasm-assemblyscript/extensions/hello-world/package-lock.json b/plugins/wasm-assemblyscript/extensions/hello-world/package-lock.json new file mode 100644 index 0000000000..9ccf0130e5 --- /dev/null +++ b/plugins/wasm-assemblyscript/extensions/hello-world/package-lock.json @@ -0,0 +1,75 @@ +{ + "name": "hello-world", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "hello-world", + "version": "1.0.0", + "license": "ISC", + "devDependencies": { + "@higress/proxy-wasm-assemblyscript-sdk": "^0.0.2", + "@higress/wasm-assemblyscript": "^0.0.3", + "assemblyscript": "^0.27.29", + "assemblyscript-json": "^1.1.0" + } + }, + "node_modules/@higress/proxy-wasm-assemblyscript-sdk": { + "version": "0.0.2", + "resolved": "https://registry.npmmirror.com/@higress/proxy-wasm-assemblyscript-sdk/-/proxy-wasm-assemblyscript-sdk-0.0.2.tgz", + "integrity": "sha512-0J1tFJMTE6o37JpGJBLq0wc5kBC/fpbISrP+KFb4bAEeshu6daXzD2P3bAfJXmW+oZdY0WGptTGXWx8pf9Fk+g==", + "dev": true + }, + "node_modules/@higress/wasm-assemblyscript": { + "version": "0.0.3", + "resolved": "https://registry.npmmirror.com/@higress/wasm-assemblyscript/-/wasm-assemblyscript-0.0.3.tgz", + "integrity": "sha512-D9hTvjAt54WoedNBsYAp9q/mDPWOO9yoGY7yG7Gkgp3KB7O5lHEEu2T6V8K14DpfC8ObSP26EhBcJ6G70JjODg==", + "dev": true + }, + "node_modules/assemblyscript": { + "version": "0.27.29", + "resolved": "https://registry.npmmirror.com/assemblyscript/-/assemblyscript-0.27.29.tgz", + "integrity": "sha512-pH6udb7aE2F0t6cTh+0uCepmucykhMnAmm7k0kkAU3SY7LvpIngEBZWM6p5VCguu4EpmKGwEuZpZbEXzJ/frHQ==", + "dev": true, + "dependencies": { + "binaryen": "116.0.0-nightly.20240114", + "long": "^5.2.1" + }, + "bin": { + "asc": "bin/asc.js", + "asinit": "bin/asinit.js" + }, + "engines": { + "node": ">=16", + "npm": ">=7" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/assemblyscript" + } + }, + "node_modules/assemblyscript-json": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/assemblyscript-json/-/assemblyscript-json-1.1.0.tgz", + "integrity": "sha512-UbE8ts8csTWQgd5TnSPN7MRV9NveuHv1bVnKmDLoo/tzjqxkmsZb3lu59Uk8H7SGoqdkDSEE049alx/nHnSdFw==", + "dev": true + }, + "node_modules/binaryen": { + "version": "116.0.0-nightly.20240114", + "resolved": "https://registry.npmmirror.com/binaryen/-/binaryen-116.0.0-nightly.20240114.tgz", + "integrity": "sha512-0GZrojJnuhoe+hiwji7QFaL3tBlJoA+KFUN7ouYSDGZLSo9CKM8swQX8n/UcbR0d1VuZKU+nhogNzv423JEu5A==", + "dev": true, + "bin": { + "wasm-opt": "bin/wasm-opt", + "wasm2js": "bin/wasm2js" + } + }, + "node_modules/long": { + "version": "5.2.3", + "resolved": "https://registry.npmmirror.com/long/-/long-5.2.3.tgz", + "integrity": "sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q==", + "dev": true + } + } +} diff --git a/plugins/wasm-assemblyscript/extensions/hello-world/package.json b/plugins/wasm-assemblyscript/extensions/hello-world/package.json new file mode 100644 index 0000000000..41d919d031 --- /dev/null +++ b/plugins/wasm-assemblyscript/extensions/hello-world/package.json @@ -0,0 +1,28 @@ +{ + "name": "hello-world", + "version": "1.0.0", + "main": "index.js", + "scripts": { + "test": "node tests", + "asbuild:debug": "asc assembly/index.ts --target debug", + "asbuild:release": "asc assembly/index.ts --target release", + "asbuild": "npm run asbuild:debug && npm run asbuild:release", + "start": "npx serve ." + }, + "author": "", + "license": "ISC", + "description": "", + "devDependencies": { + "assemblyscript": "^0.27.29", + "assemblyscript-json": "^1.1.0", + "@higress/proxy-wasm-assemblyscript-sdk": "^0.0.2", + "@higress/wasm-assemblyscript": "^0.0.3" + }, + "type": "module", + "exports": { + ".": { + "import": "./build/release.js", + "types": "./build/release.d.ts" + } + } +} \ No newline at end of file diff --git a/plugins/wasm-assemblyscript/package-lock.json b/plugins/wasm-assemblyscript/package-lock.json new file mode 100644 index 0000000000..e94af779b9 --- /dev/null +++ b/plugins/wasm-assemblyscript/package-lock.json @@ -0,0 +1,75 @@ +{ + "name": "@higress/wasm-assemblyscript", + "version": "0.0.4", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@higress/wasm-assemblyscript", + "version": "0.0.3", + "license": "Apache-2.0", + "devDependencies": { + "@higress/proxy-wasm-assemblyscript-sdk": "^0.0.2", + "as-uuid": "^0.0.4", + "assemblyscript": "^0.27.29", + "assemblyscript-json": "^1.1.0" + } + }, + "node_modules/@higress/proxy-wasm-assemblyscript-sdk": { + "version": "0.0.2", + "resolved": "https://registry.npmmirror.com/@higress/proxy-wasm-assemblyscript-sdk/-/proxy-wasm-assemblyscript-sdk-0.0.2.tgz", + "integrity": "sha512-0J1tFJMTE6o37JpGJBLq0wc5kBC/fpbISrP+KFb4bAEeshu6daXzD2P3bAfJXmW+oZdY0WGptTGXWx8pf9Fk+g==", + "dev": true + }, + "node_modules/as-uuid": { + "version": "0.0.4", + "resolved": "https://registry.npmmirror.com/as-uuid/-/as-uuid-0.0.4.tgz", + "integrity": "sha512-ZHNv0ETSzg5ZD0IWWJVyip/73LWtrWeMmvRi+16xbkpU/nZ0O8EegvgS7bgZ5xRqrUbc2NqZqHOWMOtPqbLrhg==", + "dev": true + }, + "node_modules/assemblyscript": { + "version": "0.27.29", + "resolved": "https://registry.npmmirror.com/assemblyscript/-/assemblyscript-0.27.29.tgz", + "integrity": "sha512-pH6udb7aE2F0t6cTh+0uCepmucykhMnAmm7k0kkAU3SY7LvpIngEBZWM6p5VCguu4EpmKGwEuZpZbEXzJ/frHQ==", + "dev": true, + "dependencies": { + "binaryen": "116.0.0-nightly.20240114", + "long": "^5.2.1" + }, + "bin": { + "asc": "bin/asc.js", + "asinit": "bin/asinit.js" + }, + "engines": { + "node": ">=16", + "npm": ">=7" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/assemblyscript" + } + }, + "node_modules/assemblyscript-json": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/assemblyscript-json/-/assemblyscript-json-1.1.0.tgz", + "integrity": "sha512-UbE8ts8csTWQgd5TnSPN7MRV9NveuHv1bVnKmDLoo/tzjqxkmsZb3lu59Uk8H7SGoqdkDSEE049alx/nHnSdFw==", + "dev": true + }, + "node_modules/binaryen": { + "version": "116.0.0-nightly.20240114", + "resolved": "https://registry.npmmirror.com/binaryen/-/binaryen-116.0.0-nightly.20240114.tgz", + "integrity": "sha512-0GZrojJnuhoe+hiwji7QFaL3tBlJoA+KFUN7ouYSDGZLSo9CKM8swQX8n/UcbR0d1VuZKU+nhogNzv423JEu5A==", + "dev": true, + "bin": { + "wasm-opt": "bin/wasm-opt", + "wasm2js": "bin/wasm2js" + } + }, + "node_modules/long": { + "version": "5.2.3", + "resolved": "https://registry.npmmirror.com/long/-/long-5.2.3.tgz", + "integrity": "sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q==", + "dev": true + } + } +} diff --git a/plugins/wasm-assemblyscript/package.json b/plugins/wasm-assemblyscript/package.json new file mode 100644 index 0000000000..b73751460e --- /dev/null +++ b/plugins/wasm-assemblyscript/package.json @@ -0,0 +1,37 @@ +{ + "name": "@higress/wasm-assemblyscript", + "version": "0.0.4", + "main": "assembly/index.ts", + "scripts": { + "test": "node tests", + "asbuild:debug": "asc assembly/index.ts --target debug", + "asbuild:release": "asc assembly/index.ts --target release", + "asbuild": "npm run asbuild:debug && npm run asbuild:release", + "start": "npx serve ." + }, + "author": "jingze.dai", + "license": "Apache-2.0", + "description": "", + "devDependencies": { + "assemblyscript": "^0.27.29", + "as-uuid": "^0.0.4", + "assemblyscript-json": "^1.1.0", + "@higress/proxy-wasm-assemblyscript-sdk": "^0.0.2" + }, + "type": "module", + "exports": { + ".": { + "import": "./build/release.js", + "types": "./build/release.d.ts" + } + }, + "files": [ + "/assembly", + "package-lock.json", + "index.js" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/Jing-ze/wasm-assemblyscript.git" + } +} diff --git a/plugins/wasm-go/extensions/ai-quota/README.md b/plugins/wasm-go/extensions/ai-quota/README.md new file mode 100644 index 0000000000..11ddf80359 --- /dev/null +++ b/plugins/wasm-go/extensions/ai-quota/README.md @@ -0,0 +1,58 @@ +# 功能说明 + +`ai-qutoa` 插件实现给特定 consumer 根据分配固定的 quota 进行 quota 策略限流,同时支持 quota 管理能力,包括查询 quota 、刷新 quota、增减 quota。 + +`ai-quota` 插件需要配合 认证插件比如 `key-auth`、`jwt-auth` 等插件获取认证身份的 consumer 名称,同时需要配合 `ai-statatistics` 插件获取 AI Token 统计信息。 + +# 配置说明 + +| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 | +|--------------------|-----------------|--------------------------------------| ---- |--------------------------------------------| +| `redis_key_prefix` | string | 选填 | chat_quota: | qutoa redis key 前缀 | +| `admin_consumer` | string | 必填 | | 管理 quota 管理身份的 consumer 名称 | +| `admin_path` | string | 选填 | /quota | 管理 quota 请求 path 前缀 | +| `redis` | object | 是 | | redis相关配置 | + +`redis`中每一项的配置字段说明 + +| 配置项 | 类型 | 必填 | 默认值 | 说明 | +| ------------ | ------ | ---- | ---------------------------------------------------------- | --------------------------- | +| service_name | string | 必填 | - | redis 服务名称,带服务类型的完整 FQDN 名称,例如 my-redis.dns、redis.my-ns.svc.cluster.local | +| service_port | int | 否 | 服务类型为固定地址(static service)默认值为80,其他为6379 | 输入redis服务的服务端口 | +| username | string | 否 | - | redis用户名 | +| password | string | 否 | - | redis密码 | +| timeout | int | 否 | 1000 | redis连接超时时间,单位毫秒 | + + + +# 配置示例 + +## 识别请求参数 apikey,进行区别限流 +```yaml +redis_key_prefix: "chat_quota:" +admin_consumer: consumer3 +admin_path: /quota +redis: + service_name: redis-service.default.svc.cluster.local + service_port: 6379 + timeout: 2000 +``` + + +## 刷新 quota + +如果当前请求 url 的后缀符合 admin_path,例如插件在 example.com/v1/chat/completions 这个路由上生效,那么更新 quota 可以通过 +curl https://example.com/v1/chat/completions/quota/refresh -H "Authorization: Bearer credential3" -d "consumer=consumer1"a=10000" + +Redis 中 key 为 chat_quota:consumer1 的值就会被刷新为 10000 + +## 查询 quota + +查询特定用户的 quota 可以通过 curl https://example.com/v1/chat/completions/quota?consumer=consumer1 -H "Authorization: Bearer credential3" +将返回: {"quota": 10000, "consumer": "consumer1"} + +## 增减 quota + +增减特定用户的 quota 可以通过 curl https://example.com/v1/chat/completions/quota/delta -d "consumer=consumer1&value=100" -H "Authorization: Bearer credential3" +这样 Redis 中 Key 为 chat_quota:consumer1 的值就会增加100,可以支持负数,则减去对应值。 + diff --git a/plugins/wasm-go/extensions/ai-quota/go.mod b/plugins/wasm-go/extensions/ai-quota/go.mod new file mode 100644 index 0000000000..ec77e402e4 --- /dev/null +++ b/plugins/wasm-go/extensions/ai-quota/go.mod @@ -0,0 +1,20 @@ +module github.com/alibaba/higress/plugins/wasm-go/extensions/ai-quota + +go 1.19 + +//replace github.com/alibaba/higress/plugins/wasm-go => ../.. + +require ( + github.com/alibaba/higress/plugins/wasm-go v1.4.3-0.20240808022948-34f5722d93de + github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20240711023527-ba358c48772f + github.com/tidwall/gjson v1.17.3 + github.com/tidwall/resp v0.1.1 +) + +require ( + github.com/google/uuid v1.3.0 // indirect + github.com/higress-group/nottinygc v0.0.0-20231101025119-e93c4c2f8520 // indirect + github.com/magefile/mage v1.14.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.0 // indirect +) diff --git a/plugins/wasm-go/extensions/ai-quota/go.sum b/plugins/wasm-go/extensions/ai-quota/go.sum new file mode 100644 index 0000000000..996d474d43 --- /dev/null +++ b/plugins/wasm-go/extensions/ai-quota/go.sum @@ -0,0 +1,22 @@ +github.com/alibaba/higress/plugins/wasm-go v1.4.3-0.20240808022948-34f5722d93de h1:lDLqj7Hw41ox8VdsP7oCTPhjPa3+QJUCKApcLh2a45Y= +github.com/alibaba/higress/plugins/wasm-go v1.4.3-0.20240808022948-34f5722d93de/go.mod h1:359don/ahMxpfeLMzr29Cjwcu8IywTTDUzWlBPRNLHw= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/higress-group/nottinygc v0.0.0-20231101025119-e93c4c2f8520 h1:IHDghbGQ2DTIXHBHxWfqCYQW1fKjyJ/I7W1pMyUDeEA= +github.com/higress-group/nottinygc v0.0.0-20231101025119-e93c4c2f8520/go.mod h1:Nz8ORLaFiLWotg6GeKlJMhv8cci8mM43uEnLA5t8iew= +github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20240711023527-ba358c48772f h1:ZIiIBRvIw62gA5MJhuwp1+2wWbqL9IGElQ499rUsYYg= +github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20240711023527-ba358c48772f/go.mod h1:hNFjhrLUIq+kJ9bOcs8QtiplSQ61GZXtd2xHKx4BYRo= +github.com/magefile/mage v1.14.0 h1:6QDX3g6z1YvJ4olPhT1wksUcSa/V0a1B+pJb73fBjyo= +github.com/magefile/mage v1.14.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/tidwall/gjson v1.17.3 h1:bwWLZU7icoKRG+C+0PNwIKC6FCJO/Q3p2pZvuP0jN94= +github.com/tidwall/gjson v1.17.3/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/resp v0.1.1 h1:Ly20wkhqKTmDUPlyM1S7pWo5kk0tDu8OoC/vFArXmwE= +github.com/tidwall/resp v0.1.1/go.mod h1:3/FrruOBAxPTPtundW0VXgmsQ4ZBA0Aw714lVYgwFa0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/plugins/wasm-go/extensions/ai-quota/main.go b/plugins/wasm-go/extensions/ai-quota/main.go new file mode 100644 index 0000000000..8d6e57dc45 --- /dev/null +++ b/plugins/wasm-go/extensions/ai-quota/main.go @@ -0,0 +1,399 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/alibaba/higress/plugins/wasm-go/extensions/ai-quota/util" + "github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper" + "github.com/higress-group/proxy-wasm-go-sdk/proxywasm" + "github.com/higress-group/proxy-wasm-go-sdk/proxywasm/types" + "github.com/tidwall/gjson" + "github.com/tidwall/resp" +) + +const ( + pluginName = "ai-quota" +) + +type ChatMode string + +const ( + ChatModeCompletion ChatMode = "completion" + ChatModeAdmin ChatMode = "admin" + ChatModeNone ChatMode = "none" +) + +type AdminMode string + +const ( + AdminModeRefresh AdminMode = "refresh" + AdminModeQuery AdminMode = "query" + AdminModeDelta AdminMode = "delta" + AdminModeNone AdminMode = "none" +) + +func main() { + wrapper.SetCtx( + pluginName, + wrapper.ParseConfigBy(parseConfig), + wrapper.ProcessRequestHeadersBy(onHttpRequestHeaders), + wrapper.ProcessRequestBodyBy(onHttpRequestBody), + wrapper.ProcessStreamingResponseBodyBy(onHttpStreamingResponseBody), + ) +} + +type QuotaConfig struct { + redisInfo RedisInfo `yaml:"redis"` + RedisKeyPrefix string `yaml:"redis_key_prefix"` + AdminConsumer string `yaml:"admin_consumer"` + AdminPath string `yaml:"admin_path"` + credential2Name map[string]string `yaml:"-"` + redisClient wrapper.RedisClient +} + +type Consumer struct { + Name string `yaml:"name"` + Credential string `yaml:"credential"` +} + +type RedisInfo struct { + ServiceName string `required:"true" yaml:"service_name" json:"service_name"` + ServicePort int `required:"false" yaml:"service_port" json:"service_port"` + Username string `required:"false" yaml:"username" json:"username"` + Password string `required:"false" yaml:"password" json:"password"` + Timeout int `required:"false" yaml:"timeout" json:"timeout"` +} + +func parseConfig(json gjson.Result, config *QuotaConfig, log wrapper.Log) error { + log.Debugf("parse config()") + // admin + config.AdminPath = json.Get("admin_path").String() + config.AdminConsumer = json.Get("admin_consumer").String() + if config.AdminPath == "" { + config.AdminPath = "/quota" + } + if config.AdminConsumer == "" { + return errors.New("missing admin_consumer in config") + } + // Redis + config.RedisKeyPrefix = json.Get("redis_key_prefix").String() + if config.RedisKeyPrefix == "" { + config.RedisKeyPrefix = "chat_quota:" + } + redisConfig := json.Get("redis") + if !redisConfig.Exists() { + return errors.New("missing redis in config") + } + serviceName := redisConfig.Get("service_name").String() + if serviceName == "" { + return errors.New("redis service name must not be empty") + } + servicePort := int(redisConfig.Get("service_port").Int()) + if servicePort == 0 { + if strings.HasSuffix(serviceName, ".static") { + // use default logic port which is 80 for static service + servicePort = 80 + } else { + servicePort = 6379 + } + } + username := redisConfig.Get("username").String() + password := redisConfig.Get("password").String() + timeout := int(redisConfig.Get("timeout").Int()) + if timeout == 0 { + timeout = 1000 + } + config.redisInfo.ServiceName = serviceName + config.redisInfo.ServicePort = servicePort + config.redisInfo.Username = username + config.redisInfo.Password = password + config.redisInfo.Timeout = timeout + config.redisClient = wrapper.NewRedisClusterClient(wrapper.FQDNCluster{ + FQDN: serviceName, + Port: int64(servicePort), + }) + + return config.redisClient.Init(username, password, int64(timeout)) +} + +func onHttpRequestHeaders(context wrapper.HttpContext, config QuotaConfig, log wrapper.Log) types.Action { + log.Debugf("onHttpRequestHeaders()") + // get tokens + consumer, err := proxywasm.GetHttpRequestHeader("x-mse-consumer") + if err != nil { + return deniedNoKeyAuthData() + } + if consumer == "" { + return deniedUnauthorizedConsumer() + } + + rawPath := context.Path() + path, _ := url.Parse(rawPath) + chatMode, adminMode := getOperationMode(path.Path, config.AdminPath, log) + context.SetContext("chatMode", chatMode) + context.SetContext("adminMode", adminMode) + context.SetContext("consumer", consumer) + log.Debugf("chatMode:%s, adminMode:%s, consumer:%s", chatMode, adminMode, consumer) + if chatMode == ChatModeNone { + return types.ActionContinue + } + if chatMode == ChatModeAdmin { + // query quota + if adminMode == AdminModeQuery { + return queryQuota(context, config, consumer, path, log) + } + if adminMode == AdminModeRefresh || adminMode == AdminModeDelta { + context.BufferRequestBody() + return types.HeaderStopIteration + } + return types.ActionContinue + } + + // there is no need to read request body when it is on chat completion mode + context.DontReadRequestBody() + // check quota here + config.redisClient.Get(config.RedisKeyPrefix+consumer, func(response resp.Value) { + isDenied := false + if err := response.Error(); err != nil { + isDenied = true + } + if response.IsNull() { + isDenied = true + } + if response.Integer() <= 0 { + isDenied = true + } + log.Debugf("get consumer:%s quota:%d isDenied:%t", consumer, response.Integer(), isDenied) + if isDenied { + util.SendResponse(http.StatusForbidden, "ai-quota.noquota", "text/plain", "Request denied by ai quota check, No quota left") + return + } + proxywasm.ResumeHttpRequest() + }) + return types.HeaderStopAllIterationAndWatermark +} + +func onHttpRequestBody(ctx wrapper.HttpContext, config QuotaConfig, body []byte, log wrapper.Log) types.Action { + log.Debugf("onHttpRequestBody()") + chatMode, ok := ctx.GetContext("chatMode").(ChatMode) + if !ok { + return types.ActionContinue + } + if chatMode == ChatModeNone || chatMode == ChatModeCompletion { + return types.ActionContinue + } + adminMode, ok := ctx.GetContext("adminMode").(AdminMode) + if !ok { + return types.ActionContinue + } + adminConsumer, ok := ctx.GetContext("consumer").(string) + if !ok { + return types.ActionContinue + } + + if adminMode == AdminModeRefresh { + return refreshQuota(ctx, config, adminConsumer, string(body), log) + } + if adminMode == AdminModeDelta { + return deltaQuota(ctx, config, adminConsumer, string(body), log) + } + + return types.ActionContinue +} + +func onHttpStreamingResponseBody(ctx wrapper.HttpContext, config QuotaConfig, data []byte, endOfStream bool, log wrapper.Log) []byte { + chatMode, ok := ctx.GetContext("chatMode").(ChatMode) + if !ok { + return data + } + if chatMode == ChatModeNone || chatMode == ChatModeAdmin { + return data + } + // chat completion mode + if !endOfStream { + return data + } + inputTokenStr, err := proxywasm.GetProperty([]string{"filter_state", "wasm.input_token"}) + if err != nil { + return data + } + outputTokenStr, err := proxywasm.GetProperty([]string{"filter_state", "wasm.output_token"}) + if err != nil { + return data + } + inputToken, err := strconv.Atoi(string(inputTokenStr)) + if err != nil { + return data + } + outputToken, err := strconv.Atoi(string(outputTokenStr)) + if err != nil { + return data + } + consumer, ok := ctx.GetContext("consumer").(string) + if ok { + totalToken := int(inputToken + outputToken) + log.Debugf("update consumer:%s, totalToken:%d", consumer, totalToken) + config.redisClient.DecrBy(config.RedisKeyPrefix+consumer, totalToken, nil) + } + return data +} + +func deniedNoKeyAuthData() types.Action { + util.SendResponse(http.StatusUnauthorized, "ai-quota.no_key", "text/plain", "Request denied by ai quota check. No Key Authentication information found.") + return types.ActionContinue +} + +func deniedUnauthorizedConsumer() types.Action { + util.SendResponse(http.StatusForbidden, "ai-quota.unauthorized", "text/plain", "Request denied by ai quota check. Unauthorized consumer.") + return types.ActionContinue +} + +func getOperationMode(path string, adminPath string, log wrapper.Log) (ChatMode, AdminMode) { + fullAdminPath := "/v1/chat/completions" + adminPath + if strings.HasSuffix(path, fullAdminPath+"/refresh") { + return ChatModeAdmin, AdminModeRefresh + } + if strings.HasSuffix(path, fullAdminPath+"/delta") { + return ChatModeAdmin, AdminModeDelta + } + if strings.HasSuffix(path, fullAdminPath) { + return ChatModeAdmin, AdminModeQuery + } + if strings.HasSuffix(path, "/v1/chat/completions") { + return ChatModeCompletion, AdminModeNone + } + return ChatModeNone, AdminModeNone +} + +func refreshQuota(ctx wrapper.HttpContext, config QuotaConfig, adminConsumer string, body string, log wrapper.Log) types.Action { + // check consumer + if adminConsumer != config.AdminConsumer { + util.SendResponse(http.StatusForbidden, "ai-quota.unauthorized", "text/plain", "Request denied by ai quota check. Unauthorized admin consumer.") + return types.ActionContinue + } + + queryValues, _ := url.ParseQuery(body) + values := make(map[string]string, len(queryValues)) + for k, v := range queryValues { + values[k] = v[0] + } + queryConsumer := values["consumer"] + quota, err := strconv.Atoi(values["quota"]) + if queryConsumer == "" || err != nil { + util.SendResponse(http.StatusForbidden, "ai-quota.unauthorized", "text/plain", "Request denied by ai quota check. consumer can't be empty and quota must be integer.") + return types.ActionContinue + } + err2 := config.redisClient.Set(config.RedisKeyPrefix+queryConsumer, quota, func(response resp.Value) { + log.Debugf("Redis set key = %s quota = %d", config.RedisKeyPrefix+queryConsumer, quota) + if err := response.Error(); err != nil { + util.SendResponse(http.StatusServiceUnavailable, "ai-quota.error", "text/plain", fmt.Sprintf("redis error:%v", err)) + return + } + util.SendResponse(http.StatusOK, "ai-quota.refreshquota", "text/plain", "refresh quota successful") + }) + + if err2 != nil { + util.SendResponse(http.StatusServiceUnavailable, "ai-quota.error", "text/plain", fmt.Sprintf("redis error:%v", err)) + return types.ActionContinue + } + + return types.ActionPause +} +func queryQuota(ctx wrapper.HttpContext, config QuotaConfig, adminConsumer string, url *url.URL, log wrapper.Log) types.Action { + // check consumer + if adminConsumer != config.AdminConsumer { + util.SendResponse(http.StatusForbidden, "ai-quota.unauthorized", "text/plain", "Request denied by ai quota check. Unauthorized admin consumer.") + return types.ActionContinue + } + // check url + queryValues := url.Query() + values := make(map[string]string, len(queryValues)) + for k, v := range queryValues { + values[k] = v[0] + } + if values["consumer"] == "" { + util.SendResponse(http.StatusForbidden, "ai-quota.unauthorized", "text/plain", "Request denied by ai quota check. consumer can't be empty.") + return types.ActionContinue + } + queryConsumer := values["consumer"] + err := config.redisClient.Get(config.RedisKeyPrefix+queryConsumer, func(response resp.Value) { + quota := 0 + if err := response.Error(); err != nil { + util.SendResponse(http.StatusServiceUnavailable, "ai-quota.error", "text/plain", fmt.Sprintf("redis error:%v", err)) + return + } else if response.IsNull() { + quota = 0 + } else { + quota = response.Integer() + } + result := struct { + Consumer string `json:"consumer"` + Quota int `json:"quota"` + }{ + Consumer: queryConsumer, + Quota: quota, + } + body, _ := json.Marshal(result) + util.SendResponse(http.StatusOK, "ai-quota.queryquota", "application/json", string(body)) + }) + if err != nil { + util.SendResponse(http.StatusServiceUnavailable, "ai-quota.error", "text/plain", fmt.Sprintf("redis error:%v", err)) + return types.ActionContinue + } + return types.ActionPause +} +func deltaQuota(ctx wrapper.HttpContext, config QuotaConfig, adminConsumer string, body string, log wrapper.Log) types.Action { + // check consumer + if adminConsumer != config.AdminConsumer { + util.SendResponse(http.StatusForbidden, "ai-quota.unauthorized", "text/plain", "Request denied by ai quota check. Unauthorized admin consumer.") + return types.ActionContinue + } + + queryValues, _ := url.ParseQuery(body) + values := make(map[string]string, len(queryValues)) + for k, v := range queryValues { + values[k] = v[0] + } + queryConsumer := values["consumer"] + value, err := strconv.Atoi(values["value"]) + if queryConsumer == "" || err != nil { + util.SendResponse(http.StatusForbidden, "ai-quota.unauthorized", "text/plain", "Request denied by ai quota check. consumer can't be empty and value must be integer.") + return types.ActionContinue + } + + if value >= 0 { + err := config.redisClient.IncrBy(config.RedisKeyPrefix+queryConsumer, value, func(response resp.Value) { + log.Debugf("Redis Incr key = %s value = %d", config.RedisKeyPrefix+queryConsumer, value) + if err := response.Error(); err != nil { + util.SendResponse(http.StatusServiceUnavailable, "ai-quota.error", "text/plain", fmt.Sprintf("redis error:%v", err)) + return + } + util.SendResponse(http.StatusOK, "ai-quota.deltaquota", "text/plain", "delta quota successful") + }) + if err != nil { + util.SendResponse(http.StatusServiceUnavailable, "ai-quota.error", "text/plain", fmt.Sprintf("redis error:%v", err)) + return types.ActionContinue + } + } else { + err := config.redisClient.DecrBy(config.RedisKeyPrefix+queryConsumer, 0-value, func(response resp.Value) { + log.Debugf("Redis Decr key = %s value = %d", config.RedisKeyPrefix+queryConsumer, 0-value) + if err := response.Error(); err != nil { + util.SendResponse(http.StatusServiceUnavailable, "ai-quota.error", "text/plain", fmt.Sprintf("redis error:%v", err)) + return + } + util.SendResponse(http.StatusOK, "ai-quota.deltaquota", "text/plain", "delta quota successful") + }) + if err != nil { + util.SendResponse(http.StatusServiceUnavailable, "ai-quota.error", "text/plain", fmt.Sprintf("redis error:%v", err)) + return types.ActionContinue + } + } + + return types.ActionPause +} diff --git a/plugins/wasm-go/extensions/ai-quota/plugin.yaml b/plugins/wasm-go/extensions/ai-quota/plugin.yaml new file mode 100644 index 0000000000..ac0cee4c5e --- /dev/null +++ b/plugins/wasm-go/extensions/ai-quota/plugin.yaml @@ -0,0 +1,61 @@ +apiVersion: extensions.higress.io/v1alpha1 +kind: WasmPlugin +metadata: + name: ai-quota + namespace: higress-system +spec: + defaultConfig: {} + defaultConfigDisable: true + matchRules: + - config: + redis_key_prefix: "chat_quota:" + admin_consumer: consumer3 + admin_path: /quota + redis: + service_name: redis-service.default.svc.cluster.local + service_port: 6379 + timeout: 2000 + configDisable: false + ingress: + - qwen + phase: UNSPECIFIED_PHASE + priority: 280 + url: oci://registry.cn-hangzhou.aliyuncs.com/2456868764/ai-quota:1.0.8 + +--- +apiVersion: extensions.higress.io/v1alpha1 +kind: WasmPlugin +metadata: + name: ai-statistics + namespace: higress-system +spec: + defaultConfig: + enable: true + defaultConfigDisable: false + phase: UNSPECIFIED_PHASE + priority: 250 + url: oci://higress-registry.cn-hangzhou.cr.aliyuncs.com/plugins/ai-statistics:1.0.0 + +--- +apiVersion: extensions.higress.io/v1alpha1 +kind: WasmPlugin +metadata: + name: wasm-keyauth + namespace: higress-system +spec: + defaultConfig: + consumers: + - credential: "Bearer credential1" + name: consumer1 + - credential: "Bearer credential2" + name: consumer2 + - credential: "Bearer credential3" + name: consumer3 + global_auth: true + keys: + - authorization + in_header: true + defaultConfigDisable: false + priority: 300 + url: oci://higress-registry.cn-hangzhou.cr.aliyuncs.com/plugins/key-auth:1.0.0 + imagePullPolicy: Always \ No newline at end of file diff --git a/plugins/wasm-go/extensions/ai-quota/util/http.go b/plugins/wasm-go/extensions/ai-quota/util/http.go new file mode 100644 index 0000000000..ae0e826478 --- /dev/null +++ b/plugins/wasm-go/extensions/ai-quota/util/http.go @@ -0,0 +1,22 @@ +package util + +import "github.com/higress-group/proxy-wasm-go-sdk/proxywasm" + +const ( + HeaderContentType = "Content-Type" + + MimeTypeTextPlain = "text/plain" + MimeTypeApplicationJson = "application/json" +) + +func SendResponse(statusCode uint32, statusCodeDetails string, contentType, body string) error { + return proxywasm.SendHttpResponseWithDetail(statusCode, statusCodeDetails, CreateHeaders(HeaderContentType, contentType), []byte(body), -1) +} + +func CreateHeaders(kvs ...string) [][2]string { + headers := make([][2]string, 0, len(kvs)/2) + for i := 0; i < len(kvs); i += 2 { + headers = append(headers, [2]string{kvs[i], kvs[i+1]}) + } + return headers +} diff --git a/plugins/wasm-go/extensions/cluster-key-rate-limit/go.sum b/plugins/wasm-go/extensions/cluster-key-rate-limit/go.sum index 10e66f8ec0..57f5b02b2f 100644 --- a/plugins/wasm-go/extensions/cluster-key-rate-limit/go.sum +++ b/plugins/wasm-go/extensions/cluster-key-rate-limit/go.sum @@ -5,8 +5,7 @@ github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/higress-group/nottinygc v0.0.0-20231101025119-e93c4c2f8520 h1:IHDghbGQ2DTIXHBHxWfqCYQW1fKjyJ/I7W1pMyUDeEA= github.com/higress-group/nottinygc v0.0.0-20231101025119-e93c4c2f8520/go.mod h1:Nz8ORLaFiLWotg6GeKlJMhv8cci8mM43uEnLA5t8iew= -github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20240327114451-d6b7174a84fc h1:t2AT8zb6N/59Y78lyRWedVoVWHNRSCBh0oWCC+bluTQ= -github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20240327114451-d6b7174a84fc/go.mod h1:hNFjhrLUIq+kJ9bOcs8QtiplSQ61GZXtd2xHKx4BYRo= +github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20240711023527-ba358c48772f h1:ZIiIBRvIw62gA5MJhuwp1+2wWbqL9IGElQ499rUsYYg= github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20240711023527-ba358c48772f/go.mod h1:hNFjhrLUIq+kJ9bOcs8QtiplSQ61GZXtd2xHKx4BYRo= github.com/magefile/mage v1.14.0 h1:6QDX3g6z1YvJ4olPhT1wksUcSa/V0a1B+pJb73fBjyo= github.com/magefile/mage v1.14.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= diff --git a/plugins/wasm-go/extensions/cluster-key-rate-limit/main.go b/plugins/wasm-go/extensions/cluster-key-rate-limit/main.go index 3ed1b839fa..ebf7fa58d2 100644 --- a/plugins/wasm-go/extensions/cluster-key-rate-limit/main.go +++ b/plugins/wasm-go/extensions/cluster-key-rate-limit/main.go @@ -16,15 +16,16 @@ package main import ( "fmt" + "net" + "net/url" + "strconv" + "strings" + "github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper" "github.com/higress-group/proxy-wasm-go-sdk/proxywasm" "github.com/higress-group/proxy-wasm-go-sdk/proxywasm/types" "github.com/tidwall/gjson" "github.com/tidwall/resp" - "net" - "net/url" - "strconv" - "strings" ) func main() { @@ -88,12 +89,10 @@ func onHttpRequestHeaders(ctx wrapper.HttpContext, config ClusterKeyRateLimitCon args := []interface{}{configItem.count, configItem.timeWindow} // 执行限流逻辑 err := config.redisClient.Eval(FixedWindowScript, 1, keys, args, func(response resp.Value) { - defer func() { - _ = proxywasm.ResumeHttpRequest() - }() resultArray := response.Array() if len(resultArray) != 3 { log.Errorf("redis response parse error, response: %v", response) + proxywasm.ResumeHttpRequest() return } context := LimitContext{ @@ -106,6 +105,7 @@ func onHttpRequestHeaders(ctx wrapper.HttpContext, config ClusterKeyRateLimitCon rejected(config, context) } else { ctx.SetContext(LimitContextKey, context) + proxywasm.ResumeHttpRequest() } }) if err != nil {