diff --git a/examples/http_body_chunk/README.md b/examples/http_body_chunk/README.md new file mode 100644 index 00000000..afcd6d39 --- /dev/null +++ b/examples/http_body_chunk/README.md @@ -0,0 +1,28 @@ +## http_body_chunk + +This example demonstrates how to perform operations on a request body, chunk by chunk. + +Reading the received body chunk by chunk, it looks for the string `pattern` inside the body. If it finds it, a 403 response is returned providing the number of the chunk where the pattern was found. Logs are printed every time a chunk is received providing also the size of the read chunk. + +Build and run the example: +```bash +$ make build.example name=http_body_chunk +$ make run name=http_body_chunk +``` + +Perform a request with a body containing the string `pattern`: +```bash +$ head -c 700000 /dev/urandom | base64 > /tmp/file.txt && echo "pattern" >> /tmp/file.txt && curl 'localhost:18000/anything' -d @/tmp/file.txt +pattern found in chunk: 2 +``` + +Generated logs: +``` +wasm log: OnHttpRequestBody called. BodySize: 114532, totalRequestBodyReadSize: 0, endOfStream: false +wasm log: read chunk size: 114532 +wasm log: OnHttpRequestBody called. BodySize: 114532, totalRequestBodyReadSize: 114532, endOfStream: false +wasm log: OnHttpRequestBody called. BodySize: 933343, totalRequestBodyReadSize: 114532, endOfStream: true +wasm log: read chunk size: 818811 +wasm log: pattern found in chunk: 2 +wasm log: local 403 response sent +``` diff --git a/examples/http_body_chunk/envoy.yaml b/examples/http_body_chunk/envoy.yaml new file mode 100644 index 00000000..0a38126b --- /dev/null +++ b/examples/http_body_chunk/envoy.yaml @@ -0,0 +1,66 @@ +static_resources: + listeners: + - name: main + address: + socket_address: + address: 0.0.0.0 + port_value: 18000 + filter_chains: + - filters: + - name: envoy.http_connection_manager + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + stat_prefix: ingress_http + codec_type: auto + route_config: + name: admin + virtual_hosts: + - name: admin + domains: + - "*" + routes: + - match: + prefix: "/" + route: + cluster: admin + http_filters: + - name: envoy.filters.http.wasm + typed_config: + "@type": type.googleapis.com/udpa.type.v1.TypedStruct + type_url: type.googleapis.com/envoy.extensions.filters.http.wasm.v3.Wasm + value: + config: + configuration: + "@type": type.googleapis.com/google.protobuf.StringValue + value: "body-set" + vm_config: + runtime: "envoy.wasm.runtime.v8" + code: + local: + filename: "./examples/http_body_chunk/main.wasm" + - name: envoy.filters.http.router + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + + clusters: + - name: admin + connect_timeout: 5000s + type: strict_dns + lb_policy: round_robin + load_assignment: + cluster_name: admin + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: 0.0.0.0 + port_value: 8001 + + +admin: + access_log_path: "/dev/null" + address: + socket_address: + address: 0.0.0.0 + port_value: 8001 diff --git a/examples/http_body_chunk/go.mod b/examples/http_body_chunk/go.mod new file mode 100644 index 00000000..800774a0 --- /dev/null +++ b/examples/http_body_chunk/go.mod @@ -0,0 +1,17 @@ +module github.com/tetratelabs/proxy-wasm-go-sdk/examples/http_body_chunk + +go 1.19 + +replace github.com/tetratelabs/proxy-wasm-go-sdk => ../.. + +require ( + github.com/stretchr/testify v1.8.2 + github.com/tetratelabs/proxy-wasm-go-sdk v0.0.0-00010101000000-000000000000 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/tetratelabs/wazero v1.0.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/examples/http_body_chunk/go.sum b/examples/http_body_chunk/go.sum new file mode 100644 index 00000000..69c905db --- /dev/null +++ b/examples/http_body_chunk/go.sum @@ -0,0 +1,19 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/tetratelabs/wazero v1.0.1 h1:xyWBoGyMjYekG3mEQ/W7xm9E05S89kJ/at696d/9yuc= +github.com/tetratelabs/wazero v1.0.1/go.mod h1:wYx2gNRg8/WihJfSDxA1TIL8H+GkfLYm+bIfbblu9VQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/examples/http_body_chunk/main.go b/examples/http_body_chunk/main.go new file mode 100644 index 00000000..84b15d42 --- /dev/null +++ b/examples/http_body_chunk/main.go @@ -0,0 +1,108 @@ +// Copyright 2020-2021 Tetrate +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "fmt" + "strings" + + "github.com/tetratelabs/proxy-wasm-go-sdk/proxywasm" + "github.com/tetratelabs/proxy-wasm-go-sdk/proxywasm/types" +) + +func main() { + proxywasm.SetVMContext(&vmContext{}) +} + +type vmContext struct { + // Embed the default VM context here, + // so that we don't need to reimplement all the methods. + types.DefaultVMContext +} + +// Override types.DefaultVMContext. +func (*vmContext) NewPluginContext(contextID uint32) types.PluginContext { + return &pluginContext{} +} + +type pluginContext struct { + // Embed the default plugin context here, + // so that we don't need to reimplement all the methods. + types.DefaultPluginContext +} + +// Override types.DefaultPluginContext. +func (ctx *pluginContext) NewHttpContext(contextID uint32) types.HttpContext { + return &setBodyContext{} +} + +// Override types.DefaultPluginContext. +func (ctx *pluginContext) OnPluginStart(pluginConfigurationSize int) types.OnPluginStartStatus { + return types.OnPluginStartStatusOK +} + +type setBodyContext struct { + // Embed the default root http context here, + // so that we don't need to reimplement all the methods. + types.DefaultHttpContext + totalRequestBodyReadSize int + receivedChunks int +} + +// Override types.DefaultHttpContext. +func (ctx *setBodyContext) OnHttpRequestBody(bodySize int, endOfStream bool) types.Action { + proxywasm.LogInfof("OnHttpRequestBody called. BodySize: %d, totalRequestBodyReadSize: %d, endOfStream: %v", bodySize, ctx.totalRequestBodyReadSize, endOfStream) + + // If some data has been received, we read it. + // Reading the body chunk by chunk, bodySize is the size of the current chunk, not the total size of the body. + chunkSize := bodySize - ctx.totalRequestBodyReadSize + if chunkSize > 0 { + ctx.receivedChunks++ + chunk, err := proxywasm.GetHttpRequestBody(ctx.totalRequestBodyReadSize, chunkSize) + if err != nil { + proxywasm.LogCriticalf("failed to get request body: %v", err) + return types.ActionContinue + } + proxywasm.LogInfof("read chunk size: %d", len(chunk)) + if len(chunk) != chunkSize { + proxywasm.LogErrorf("read data does not match the expected size: %d != %d", len(chunk), chunkSize) + } + ctx.totalRequestBodyReadSize += len(chunk) + if strings.Contains(string(chunk), "pattern") { + patternFound := fmt.Sprintf("pattern found in chunk: %d", ctx.receivedChunks) + proxywasm.LogInfo(patternFound) + if err := proxywasm.SendHttpResponse(403, [][2]string{ + {"powered-by", "proxy-wasm-go-sdk"}, + }, []byte(patternFound), -1); err != nil { + proxywasm.LogCriticalf("failed to send local response: %v", err) + proxywasm.ResumeHttpRequest() + } else { + proxywasm.LogInfo("local 403 response sent") + } + return types.ActionPause + } + } + + if !endOfStream { + // Wait until we see the entire body before sending the request upstream. + return types.ActionPause + } + // When endOfStream is true, we have received the entire body. We expect the total size is equal to the sum of the sizes of the chunks. + if ctx.totalRequestBodyReadSize != bodySize { + proxywasm.LogErrorf("read data does not match the expected total size: %d != %d", ctx.totalRequestBodyReadSize, bodySize) + } + proxywasm.LogInfof("pattern not found") + return types.ActionContinue +} diff --git a/examples/http_body_chunk/main_test.go b/examples/http_body_chunk/main_test.go new file mode 100644 index 00000000..a5c56426 --- /dev/null +++ b/examples/http_body_chunk/main_test.go @@ -0,0 +1,159 @@ +// The framework emulates the expected behavior of Envoyproxy, and you can test your extensions without running Envoy and with +// the standard Go CLI. To run tests, simply run +// go test ./... + +package main + +import ( + "os" + "strconv" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/tetratelabs/proxy-wasm-go-sdk/proxywasm/proxytest" + "github.com/tetratelabs/proxy-wasm-go-sdk/proxywasm/types" +) + +func Test_OnHttpRequestBody(t *testing.T) { + vmTest(t, func(t *testing.T, vm types.VMContext) { + opt := proxytest.NewEmulatorOption().WithVMContext(vm) + host, reset := proxytest.NewHostEmulator(opt) + defer reset() + + t.Run("pause until EOS", func(t *testing.T) { + // Create http context. + id := host.InitializeHttpContext() + + // Call OnRequestBody. + action := host.CallOnRequestBody(id, []byte("aaaa"), false /* end of stream */) + + // Must be paused + require.Equal(t, types.ActionPause, action) + }) + t.Run("pattern found", func(t *testing.T) { + // Create http context. + id := host.InitializeHttpContext() + + body := "This is a payload with the pattern word." + + // Call OnRequestHeaders. + action := host.CallOnRequestHeaders(id, [][2]string{ + {"content-length", strconv.Itoa(len(body))}, + }, false) + + // Must be continued. + require.Equal(t, types.ActionContinue, action) + + // Call OnRequestBody. + action = host.CallOnRequestBody(id, []byte(body), true) + + // Must be paused + require.Equal(t, types.ActionPause, action) + + // Check Envoy logs. + logs := host.GetInfoLogs() + require.Contains(t, logs, `pattern found in chunk: 1`) + + // Check the local response. + localResponse := host.GetSentLocalResponse(id) + require.NotNil(t, localResponse) + require.Equal(t, uint32(403), localResponse.StatusCode) + }) + t.Run("pattern found multiple chunks", func(t *testing.T) { + // Create http context. + id := host.InitializeHttpContext() + + chunks := []string{ + "chunk1...", + "chunk2...", + "chunk3...", + "chunk4 with pattern ...", + } + var chunksSize int + for _, chunk := range chunks { + chunksSize += len(chunk) + } + + // Call OnRequestHeaders. + action := host.CallOnRequestHeaders(id, [][2]string{ + {"content-length", strconv.Itoa(chunksSize)}, + }, false) + + // Must be continued. + require.Equal(t, types.ActionContinue, action) + + for _, chunk := range chunks { + action := host.CallOnRequestBody(id, []byte(chunk), false /* end of stream */) + + // Must be paused. + require.Equal(t, types.ActionPause, action) + } + + // Check Envoy logs. + logs := host.GetInfoLogs() + require.Contains(t, logs, `pattern found in chunk: 4`) + logs = host.GetErrorLogs() + for _, log := range logs { + require.NotContains(t, log, `read data does not match`) + } + + // Check the local response. + localResponse := host.GetSentLocalResponse(id) + require.NotNil(t, localResponse) + require.Equal(t, uint32(403), localResponse.StatusCode) + }) + t.Run("pattern not found", func(t *testing.T) { + // Create http context. + id := host.InitializeHttpContext() + + body := "This is a generic payload." + + // Call OnRequestHeaders. + action := host.CallOnRequestHeaders(id, [][2]string{ + {"content-length", strconv.Itoa(len(body))}, + }, false) + + // Must be continued. + require.Equal(t, types.ActionContinue, action) + + // Call OnRequestBody. + action = host.CallOnRequestBody(id, []byte(body), false) + + // Must be paused + require.Equal(t, types.ActionPause, action) + + // Call OnRequestBody. + action = host.CallOnRequestBody(id, nil, true) + + // Must be continued + require.Equal(t, types.ActionContinue, action) + + // Check Envoy logs. + logs := host.GetInfoLogs() + require.Contains(t, logs, `pattern not found`) + }) + }) +} + +// vmTest executes f twice, once with a types.VMContext that executes plugin code directly +// in the host, and again by executing the plugin code within the compiled main.wasm binary. +// Execution with main.wasm will be skipped if the file cannot be found. +func vmTest(t *testing.T, f func(*testing.T, types.VMContext)) { + t.Helper() + + t.Run("go", func(t *testing.T) { + f(t, &vmContext{}) + }) + + t.Run("wasm", func(t *testing.T) { + wasm, err := os.ReadFile("main.wasm") + if err != nil { + t.Skip("wasm not found") + } + v, err := proxytest.NewWasmVMContext(wasm) + require.NoError(t, err) + defer v.Close() + f(t, v) + }) +}