diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a99778aac334..5e5891e5da76 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -84,7 +84,7 @@ jobs: export TINYGO_VER=0.20.0 wget https://github.com/tinygo-org/tinygo/releases/download/v${TINYGO_VER}/tinygo_${TINYGO_VER}_amd64.deb 2>/dev/null sudo dpkg -i tinygo_${TINYGO_VER}_amd64.deb - cd t/wasm && find . -type f -name "main.go" | xargs -Ip tinygo build -o p.wasm -scheduler=none -target=wasi p + cd t/wasm && find . -type f -name "*.go" | xargs -Ip tinygo build -o p.wasm -scheduler=none -target=wasi p - name: Linux Before install run: sudo ./ci/${{ matrix.os_name }}_runner.sh before_install diff --git a/docs/en/latest/wasm.md b/docs/en/latest/wasm.md index 31e9ca4fac00..bc550226a925 100644 --- a/docs/en/latest/wasm.md +++ b/docs/en/latest/wasm.md @@ -102,3 +102,11 @@ Here is the mapping between Proxy WASM callbacks and APISIX's phases: For example, when the first request hits the route which has WASM plugin configured. * `proxy_on_http_request_headers`: run in the access/rewrite phase, depends on the configuration of `http_request_phase`. * `proxy_on_http_response_headers`: run in the header_filter phase. + +## Example + +We have reimplemented some Lua plugin via Wasm, under `t/wasm/` of this repo: + +* fault-injection +* forward-auth +* response-rewrite diff --git a/t/cli/test_admin.sh b/t/cli/test_admin.sh index ecfbffcccb2d..0ab1c6e5cc69 100755 --- a/t/cli/test_admin.sh +++ b/t/cli/test_admin.sh @@ -339,4 +339,4 @@ fi make stop -echo "pass: ccept changes to /apisix/plugins successfully" +echo "pass: accept changes to /apisix/plugins successfully" diff --git a/t/wasm/forward-auth.go b/t/wasm/forward-auth.go new file mode 100644 index 000000000000..10eaadab433f --- /dev/null +++ b/t/wasm/forward-auth.go @@ -0,0 +1,219 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package main + +import ( + "net/url" + "strconv" + "strings" + + "github.com/tetratelabs/proxy-wasm-go-sdk/proxywasm" + "github.com/tetratelabs/proxy-wasm-go-sdk/proxywasm/types" + "github.com/valyala/fastjson" +) + +func main() { + proxywasm.SetVMContext(&vmContext{}) +} + +type vmContext struct { + types.DefaultVMContext +} + +func (*vmContext) NewPluginContext(contextID uint32) types.PluginContext { + return &pluginContext{ + contextID: contextID, + upstreamHeaders: map[string]struct{}{}, + clientHeaders: map[string]struct{}{}, + requestHeaders: map[string]struct{}{}, + } +} + +type pluginContext struct { + types.DefaultPluginContext + contextID uint32 + + host string + path string + scheme string + upstreamHeaders map[string]struct{} + clientHeaders map[string]struct{} + requestHeaders map[string]struct{} + timeout uint32 +} + +func (ctx *pluginContext) OnPluginStart(pluginConfigurationSize int) types.OnPluginStartStatus { + data, err := proxywasm.GetPluginConfiguration() + if err != nil { + proxywasm.LogErrorf("error reading plugin configuration: %v", err) + return types.OnPluginStartStatusFailed + } + + var p fastjson.Parser + v, err := p.ParseBytes(data) + if err != nil { + proxywasm.LogErrorf("erorr decoding plugin configuration: %v", err) + return types.OnPluginStartStatusFailed + } + + ctx.timeout = uint32(v.GetUint("timeout")) + if ctx.timeout == 0 { + ctx.timeout = 3000 + } + + // schema check + if ctx.timeout < 1 || ctx.timeout > 60000 { + proxywasm.LogError("bad timeout") + return types.OnPluginStartStatusFailed + } + + s := string(v.GetStringBytes("uri")) + if s == "" { + proxywasm.LogError("bad uri") + return types.OnPluginStartStatusFailed + } + + uri, err := url.Parse(s) + if err != nil { + proxywasm.LogErrorf("bad uri: %v", err) + return types.OnPluginStartStatusFailed + } + + ctx.host = uri.Host + ctx.path = uri.Path + ctx.scheme = uri.Scheme + + arr := v.GetArray("upstream_headers") + for _, a := range arr { + ctx.upstreamHeaders[strings.ToLower(string(a.GetStringBytes()))] = struct{}{} + } + + arr = v.GetArray("request_headers") + for _, a := range arr { + ctx.requestHeaders[string(a.GetStringBytes())] = struct{}{} + } + + arr = v.GetArray("client_headers") + for _, a := range arr { + ctx.clientHeaders[strings.ToLower(string(a.GetStringBytes()))] = struct{}{} + } + + return types.OnPluginStartStatusOK +} + +func (pluginCtx *pluginContext) NewHttpContext(contextID uint32) types.HttpContext { + ctx := &httpContext{contextID: contextID, pluginCtx: pluginCtx} + return ctx +} + +type httpContext struct { + types.DefaultHttpContext + contextID uint32 + pluginCtx *pluginContext +} + +func (ctx *httpContext) dispatchHttpCall(elem *fastjson.Value) { + method, _ := proxywasm.GetHttpRequestHeader(":method") + uri, _ := proxywasm.GetHttpRequestHeader(":path") + scheme, _ := proxywasm.GetHttpRequestHeader(":scheme") + host, _ := proxywasm.GetHttpRequestHeader("host") + addr, _ := proxywasm.GetProperty([]string{"remote_addr"}) + + pctx := ctx.pluginCtx + hs := [][2]string{} + hs = append(hs, [2]string{":scheme", pctx.scheme}) + hs = append(hs, [2]string{"host", pctx.host}) + hs = append(hs, [2]string{":path", pctx.path}) + hs = append(hs, [2]string{"X-Forwarded-Proto", scheme}) + hs = append(hs, [2]string{"X-Forwarded-Method", method}) + hs = append(hs, [2]string{"X-Forwarded-Host", host}) + hs = append(hs, [2]string{"X-Forwarded-Uri", uri}) + hs = append(hs, [2]string{"X-Forwarded-For", string(addr)}) + + for k := range pctx.requestHeaders { + h, err := proxywasm.GetHttpRequestHeader(k) + + if err != nil && err != types.ErrorStatusNotFound { + proxywasm.LogErrorf("httpcall failed: %v", err) + return + } + hs = append(hs, [2]string{k, h}) + } + + calloutID, err := proxywasm.DispatchHttpCall(pctx.host, hs, nil, nil, + pctx.timeout, ctx.httpCallback) + if err != nil { + proxywasm.LogErrorf("httpcall failed: %v", err) + return + } + proxywasm.LogInfof("httpcall calloutID %d, pluginCtxID %d", calloutID, ctx.pluginCtx.contextID) +} + +func (ctx *httpContext) OnHttpRequestHeaders(numHeaders int, endOfStream bool) types.Action { + data, err := proxywasm.GetPluginConfiguration() + if err != nil { + proxywasm.LogErrorf("error reading plugin configuration: %v", err) + return types.ActionContinue + } + + var p fastjson.Parser + v, err := p.ParseBytes(data) + if err != nil { + proxywasm.LogErrorf("erorr decoding plugin configuration: %v", err) + return types.ActionContinue + } + + ctx.dispatchHttpCall(v) + return types.ActionContinue +} + +func (ctx *httpContext) httpCallback(numHeaders int, bodySize int, numTrailers int) { + hs, err := proxywasm.GetHttpCallResponseHeaders() + if err != nil { + proxywasm.LogErrorf("callback err: %v", err) + return + } + + var status int + for _, h := range hs { + if h[0] == ":status" { + status, _ = strconv.Atoi(h[1]) + } + + if _, ok := ctx.pluginCtx.upstreamHeaders[h[0]]; ok { + err := proxywasm.ReplaceHttpRequestHeader(h[0], h[1]) + if err != nil { + proxywasm.LogErrorf("set header failed: %v", err) + } + } + } + + if status >= 300 { + chs := [][2]string{} + for _, h := range hs { + if _, ok := ctx.pluginCtx.clientHeaders[h[0]]; ok { + chs = append(chs, [2]string{h[0], h[1]}) + } + } + + if err := proxywasm.SendHttpResponse(403, chs, nil, -1); err != nil { + proxywasm.LogErrorf("send http failed: %v", err) + return + } + } +} diff --git a/t/wasm/forward-auth.t b/t/wasm/forward-auth.t new file mode 100644 index 000000000000..ed9866b62311 --- /dev/null +++ b/t/wasm/forward-auth.t @@ -0,0 +1,257 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +use t::APISIX; + +my $nginx_binary = $ENV{'TEST_NGINX_BINARY'} || 'nginx'; +my $version = eval { `$nginx_binary -V 2>&1` }; + +if ($version !~ m/\/apisix-nginx-module/) { + plan(skip_all => "apisix-nginx-module not installed"); +} else { + plan('no_plan'); +} + + +repeat_each(1); +no_long_string(); +no_root_location(); + +add_block_preprocessor(sub { + my ($block) = @_; + + if ((!defined $block->error_log) && (!defined $block->no_error_log)) { + $block->set_value("no_error_log", "[error]"); + } + + if (!defined $block->request) { + $block->set_value("request", "GET /t"); + } + + my $extra_yaml_config = <<_EOC_; +wasm: + plugins: + - name: wasm-forward-auth + priority: 7997 + file: t/wasm/forward-auth.go.wasm +_EOC_ + $block->set_value("extra_yaml_config", $extra_yaml_config); +}); + +run_tests(); + +__DATA__ + +=== TEST 1: setup route with plugin +--- config + location /t { + content_by_lua_block { + local datas = { + { + url = "/apisix/admin/upstreams/u1", + data = [[{ + "nodes": { + "127.0.0.1:1984": 1 + }, + "type": "roundrobin" + }]], + }, + { + url = "/apisix/admin/routes/auth", + data = [[{ + "plugins": { + "serverless-pre-function": { + "phase": "rewrite", + "functions": [ + "return function(conf, ctx) + local core = require(\"apisix.core\"); + if core.request.header(ctx, \"Authorization\") == \"111\" then + core.response.exit(200); + end + end", + "return function(conf, ctx) + local core = require(\"apisix.core\"); + if core.request.header(ctx, \"Authorization\") == \"222\" then + core.response.set_header(\"X-User-ID\", \"i-am-an-user\"); + core.response.exit(200); + end + end",]] .. [[ + "return function(conf, ctx) + local core = require(\"apisix.core\"); + if core.request.header(ctx, \"Authorization\") == \"333\" then + core.response.set_header(\"X-User-ID\", \"i-am-an-user\"); + core.response.exit(401); + end + end", + "return function(conf, ctx) + local core = require(\"apisix.core\"); + if core.request.header(ctx, \"Authorization\") == \"444\" then + local auth_headers = { + 'X-Forwarded-Proto', + 'X-Forwarded-Method', + 'X-Forwarded-Host', + 'X-Forwarded-Uri', + 'X-Forwarded-For', + } + for _, k in ipairs(auth_headers) do + core.log.warn('get header ', string.lower(k), ': ', core.request.header(ctx, k)) + end + core.response.exit(403); + end + end" + ] + } + }, + "uri": "/auth" + }]], + }, + { + url = "/apisix/admin/routes/echo", + data = [[{ + "plugins": { + "serverless-pre-function": { + "phase": "rewrite", + "functions": [ + "return function (conf, ctx) + local core = require(\"apisix.core\"); + core.response.exit(200, core.request.headers(ctx)); + end" + ] + } + }, + "uri": "/echo" + }]], + }, + { + url = "/apisix/admin/routes/1", + data = [[{ + "plugins": { + "wasm-forward-auth": { + "conf": "{ + \"uri\": \"http://127.0.0.1:1984/auth\", + \"request_headers\": [\"Authorization\"], + \"client_headers\": [\"X-User-ID\"], + \"upstream_headers\": [\"X-User-ID\"] + }" + }, + "proxy-rewrite": { + "uri": "/echo" + } + }, + "upstream_id": "u1", + "uri": "/hello" + }]], + }, + { + url = "/apisix/admin/routes/2", + data = [[{ + "plugins": { + "wasm-forward-auth": { + "conf": "{ + \"uri\": \"http://127.0.0.1:1984/auth\", + \"request_headers\": [\"Authorization\"] + }" + }, + "proxy-rewrite": { + "uri": "/echo" + } + }, + "upstream_id": "u1", + "uri": "/empty" + }]], + }, + } + + local t = require("lib.test_admin").test + + for _, data in ipairs(datas) do + local code, body = t(data.url, ngx.HTTP_PUT, data.data) + ngx.say(body) + end + } + } +--- response_body eval +"passed\n" x 5 + + + +=== TEST 2: hit route (test request_headers) +--- request +GET /hello +--- more_headers +Authorization: 111 +--- response_body_like eval +qr/\"authorization\":\"111\"/ + + + +=== TEST 3: hit route (test upstream_headers) +--- request +GET /hello +--- more_headers +Authorization: 222 +--- response_body_like eval +qr/\"x-user-id\":\"i-am-an-user\"/ + + + +=== TEST 4: hit route (test client_headers) +--- request +GET /hello +--- more_headers +Authorization: 333 +--- error_code: 403 +--- response_headers +x-user-id: i-am-an-user + + + +=== TEST 5: hit route (check APISIX generated headers and ignore client headers) +--- request +GET /hello +--- more_headers +Authorization: 444 +X-Forwarded-Host: apisix.apache.org +--- error_code: 403 +--- grep_error_log eval +qr/get header \S+: \S+/ +--- grep_error_log_out +get header x-forwarded-proto: http, +get header x-forwarded-method: GET, +get header x-forwarded-host: localhost, +get header x-forwarded-uri: /hello, +get header x-forwarded-for: 127.0.0.1, + + + +=== TEST 6: hit route (not send upstream headers) +--- request +GET /empty +--- more_headers +Authorization: 222 +--- response_body_unlike eval +qr/\"x-user-id\":\"i-am-an-user\"/ + + + +=== TEST 7: hit route (not send client headers) +--- request +GET /empty +--- more_headers +Authorization: 333 +--- error_code: 403 +--- response_headers +!x-user-id