diff --git a/plugins/wasm-go/extensions/log-request-response/README.md b/plugins/wasm-go/extensions/log-request-response/README.md new file mode 100644 index 000000000..c35677b1e --- /dev/null +++ b/plugins/wasm-go/extensions/log-request-response/README.md @@ -0,0 +1,154 @@ +# log-request-response 插件 + +这个插件用于在 Higress 的访问日志中添加以下信息: + +- HTTP 请求头(添加为 `%FILTER_STATE(wasm.log-request-headers:PLAIN)%`) +- POST、PUT、PATCH 请求的请求体内容(添加为 `%FILTER_STATE(wasm.log-request-body:PLAIN)%`) +- 响应头(添加为 `%FILTER_STATE(wasm.log-response-headers:PLAIN)%`) +- 响应体内容(添加为 `%FILTER_STATE(wasm.log-response-body:PLAIN)%`) + +## 配置参数 + +在 Higress 控制台配置该插件时,使用以下结构化的 YAML 配置: + +```yaml +# 请求相关配置 +request: + # 请求头配置 + headers: + # 是否记录请求头(默认:false) + enabled: true + # 请求体配置 + body: + # 是否记录请求体内容(默认:false) + enabled: true + # 最大记录长度限制,单位字节(默认:10KB) + maxSize: 10240 + # 需要记录请求体的内容类型(默认包含常见的内容类型) + contentTypes: + - application/json + - application/xml + - application/x-www-form-urlencoded + - text/plain + +# 响应相关配置 +response: + # 响应头配置 + headers: + # 是否记录响应头(默认:false) + enabled: true + # 响应体配置 + body: + # 是否记录响应体内容(默认:false) + enabled: true + # 最大记录长度限制,单位字节(默认:10KB) + maxSize: 10240 + # 需要记录响应体的内容类型(默认包含常见的内容类型) + contentTypes: + - application/json + - application/xml + - text/plain + - text/html +``` + +## 工作原理 + +1. 请求处理时,插件会根据配置决定是否记录请求头和请求体 +2. 只有当请求方法为 POST、PUT 或 PATCH,且内容类型在配置的 `request.body.contentTypes` 列表中时,才会记录请求体 +3. 响应处理时,插件会根据配置决定是否记录响应头和响应体 +4. 只有当响应的内容类型在配置的 `response.body.contentTypes` 列表中时,才会记录响应体 +5. 所有记录的内容都会被限制在配置的 `maxSize` 指定的大小内 +6. 插件对请求体和响应体都使用流式处理方式,不会阻止或修改原始内容传递 +7. 记录的内容会被存储在 Envoy 的 Filter State 中,可以通过访问日志配置获取 + +## 编译方法 + +```bash +# 先整理依赖 +go mod tidy + +# 编译 +tinygo build -o main.wasm -scheduler=none -target=wasi -gc=custom -tags="custommalloc nottinygc_finalizer" ./main.go +``` + +## 访问日志配置 + +要在 Higress 访问日志中显示插件添加的 Filter State 数据,需要修改 Higress 的访问日志配置。编辑 ConfigMap: + +```bash +kubectl edit cm -n higress-system higress-config +``` + +在 `envoyAccessLogService.config.accessLog` 下的 `format` 字段中添加以下内容: + +```json +{ + "request_headers": "%FILTER_STATE(wasm.log-request-headers:PLAIN)%", + "request_body": "%FILTER_STATE(wasm.log-request-body:PLAIN)%", + "response_headers": "%FILTER_STATE(wasm.log-response-headers:PLAIN)%", + "response_body": "%FILTER_STATE(wasm.log-response-body:PLAIN)%" +} +``` + +完整的访问日志配置可能会像这样(添加到现有配置中): + +```yaml +mesh: + accessLogFile: "/dev/stdout" + accessLogFormat: | + { + "authority": "%REQ(:AUTHORITY)%", + "bytes_received": "%BYTES_RECEIVED%", + "bytes_sent": "%BYTES_SENT%", + "downstream_local_address": "%DOWNSTREAM_LOCAL_ADDRESS%", + "downstream_remote_address": "%DOWNSTREAM_REMOTE_ADDRESS%", + "duration": "%DURATION%", + "method": "%REQ(:METHOD)%", + "path": "%REQ(X-ENVOY-ORIGINAL-PATH?:PATH)%", + "protocol": "%PROTOCOL%", + "request_id": "%REQ(X-REQUEST-ID)%", + "requested_server_name": "%REQUESTED_SERVER_NAME%", + "response_code": "%RESPONSE_CODE%", + "response_flags": "%RESPONSE_FLAGS%", + "route_name": "%ROUTE_NAME%", + "start_time": "%START_TIME%", + "trace_id": "%REQ(X-B3-TRACEID)%", + "upstream_cluster": "%UPSTREAM_CLUSTER%", + "upstream_host": "%UPSTREAM_HOST%", + "upstream_local_address": "%UPSTREAM_LOCAL_ADDRESS%", + "upstream_service_time": "%RESP(X-ENVOY-UPSTREAM-SERVICE-TIME)%", + "upstream_transport_failure_reason": "%UPSTREAM_TRANSPORT_FAILURE_REASON%", + "user_agent": "%REQ(USER-AGENT)%", + "x_forwarded_for": "%REQ(X-FORWARDED-FOR)%", + "request_headers": "%FILTER_STATE(wasm.log-request-headers:PLAIN)%", + "request_body": "%FILTER_STATE(wasm.log-request-body:PLAIN)%", + "response_headers": "%FILTER_STATE(wasm.log-response-headers:PLAIN)%", + "response_body": "%FILTER_STATE(wasm.log-response-body:PLAIN)%" + } +``` + +## 日志输出示例 + +配置完成后,Higress 的访问日志中将包含这些额外的字段(取决于您的配置启用了哪些选项): + +```json +{ + "authority": "example.com", + "method": "POST", + "path": "/api/users", + "response_code": 200, + "request_headers": "{\"host\":\"example.com\",\"path\":\"/api/users\",\"method\":\"POST\",\"content-type\":\"application/json\"}", + "request_body": "{\"name\":\"测试用户\",\"email\":\"test@example.com\"}", + "response_headers": "{\"content-type\":\"application/json\",\"status\":\"200\"}", + "response_body": "{\"id\":123,\"status\":\"success\"}" +} +``` + +## 注意事项 + +1. 所有日志记录选项默认都是关闭的(false),需要明确启用才会记录相应内容 +2. 对于大型请求体或响应体,可以通过 `request.body.maxSize` 和 `response.body.maxSize` 参数限制记录的长度,以避免日志过大 +3. 插件使用流式处理方式处理请求体和响应体,不会对原始内容产生任何影响 +4. 只有指定内容类型的 POST、PUT、PATCH 请求才会记录请求体内容 +5. 只有指定内容类型的响应才会记录响应体内容 +6. 请确保合理配置该插件,避免记录敏感信息到日志中 \ No newline at end of file diff --git a/plugins/wasm-go/extensions/log-request-response/VERSION b/plugins/wasm-go/extensions/log-request-response/VERSION new file mode 100644 index 000000000..3eefcb9dd --- /dev/null +++ b/plugins/wasm-go/extensions/log-request-response/VERSION @@ -0,0 +1 @@ +1.0.0 diff --git a/plugins/wasm-go/extensions/log-request-response/docker-compose.yaml b/plugins/wasm-go/extensions/log-request-response/docker-compose.yaml new file mode 100644 index 000000000..2bc995797 --- /dev/null +++ b/plugins/wasm-go/extensions/log-request-response/docker-compose.yaml @@ -0,0 +1,25 @@ +services: + envoy: + image: higress-registry.cn-hangzhou.cr.aliyuncs.com/higress/gateway:v2.1.3 + entrypoint: /usr/local/bin/envoy + # 注意这里对wasm开启了debug级别日志,正式部署时则默认info级别 + command: -c /etc/envoy/envoy.yaml --component-log-level wasm:debug + depends_on: + - httpbin + networks: + - wasmtest + ports: + - "10000:10000" + volumes: + - ./envoy.yaml:/etc/envoy/envoy.yaml + - ./main.wasm:/etc/envoy/main.wasm + + httpbin: + image: kennethreitz/httpbin:latest + networks: + - wasmtest + ports: + - "12345:80" + +networks: + wasmtest: {} \ No newline at end of file diff --git a/plugins/wasm-go/extensions/log-request-response/envoy.yaml b/plugins/wasm-go/extensions/log-request-response/envoy.yaml new file mode 100644 index 000000000..30537f9b5 --- /dev/null +++ b/plugins/wasm-go/extensions/log-request-response/envoy.yaml @@ -0,0 +1,137 @@ +admin: + address: + socket_address: + protocol: TCP + address: 0.0.0.0 + port_value: 9901 +static_resources: + listeners: + - name: listener_0 + address: + socket_address: + protocol: TCP + address: 0.0.0.0 + port_value: 10000 + filter_chains: + - filters: + - name: envoy.filters.network.http_connection_manager + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + access_log: + - name: envoy.access_loggers.file + typed_config: + "@type": type.googleapis.com/envoy.extensions.access_loggers.file.v3.FileAccessLog + path: "/dev/stdout" + format: | + { + "request_headers": "%FILTER_STATE(wasm.log-request-headers:PLAIN)%", + "request_body": "%FILTER_STATE(wasm.log-request-body:PLAIN)%", + "response_headers": "%FILTER_STATE(wasm.log-response-headers:PLAIN)%", + "response_body": "%FILTER_STATE(wasm.log-response-body:PLAIN)%", + "ai_log": "%FILTER_STATE(wasm.ai_log:PLAIN)%", + "authority": "%REQ(X-ENVOY-ORIGINAL-HOST?:AUTHORITY)%", + "bytes_received": "%BYTES_RECEIVED%", + "bytes_sent": "%BYTES_SENT%", + "downstream_local_address": "%DOWNSTREAM_LOCAL_ADDRESS%", + "downstream_remote_address": "%DOWNSTREAM_REMOTE_ADDRESS%", + "duration": "%DURATION%", + "istio_policy_status": "%DYNAMIC_METADATA(istio.mixer:status)%", + "method": "%REQ(:METHOD)%", + "path": "%REQ(X-ENVOY-ORIGINAL-PATH?:PATH)%", + "protocol": "%PROTOCOL%", + "request_id": "%REQ(X-REQUEST-ID)%", + "requested_server_name": "%REQUESTED_SERVER_NAME%", + "response_code": "%RESPONSE_CODE%", + "response_flags": "%RESPONSE_FLAGS%", + "route_name": "%ROUTE_NAME%", + "start_time": "%START_TIME%", + "trace_id": "%REQ(X-B3-TRACEID)%", + "upstream_cluster": "%UPSTREAM_CLUSTER%", + "upstream_host": "%UPSTREAM_HOST%", + "upstream_local_address": "%UPSTREAM_LOCAL_ADDRESS%", + "upstream_service_time": "%RESP(X-ENVOY-UPSTREAM-SERVICE-TIME)%", + "upstream_transport_failure_reason": "%UPSTREAM_TRANSPORT_FAILURE_REASON%", + "user_agent": "%REQ(USER-AGENT)%", + "x_forwarded_for": "%REQ(X-FORWARDED-FOR)%", + "response_code_details": "%RESPONSE_CODE_DETAILS%" + } + scheme_header_transformation: + scheme_to_overwrite: https + stat_prefix: ingress_http + route_config: + name: local_route + virtual_hosts: + - name: local_service + domains: ["*"] + routes: + - match: + prefix: "/" + route: + cluster: httpbin + http_filters: + - name: wasmdemo + typed_config: + "@type": type.googleapis.com/udpa.type.v1.TypedStruct + type_url: type.googleapis.com/envoy.extensions.filters.http.wasm.v3.Wasm + value: + config: + name: wasmdemo + vm_config: + runtime: envoy.wasm.runtime.v8 + code: + local: + filename: /etc/envoy/main.wasm + configuration: + "@type": "type.googleapis.com/google.protobuf.StringValue" + value: | + { + "request": { + "headers": { + "enabled": true + }, + "body": { + "enabled": true, + "maxSize": 25, + "contentTypes": [ + "application/json", + "application/xml", + "application/x-www-form-urlencoded", + "text/plain" + ] + } + }, + "response": { + "headers": { + "enabled": true + }, + "body": { + "enabled": true, + "maxSize": 100, + "contentTypes": [ + "application/json", + "application/xml", + "text/plain", + "text/html" + ] + } + } + } + - name: envoy.filters.http.router + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + clusters: + - name: httpbin + connect_timeout: 30s + type: LOGICAL_DNS + # Comment out the following line to test on v6 networks + dns_lookup_family: V4_ONLY + lb_policy: ROUND_ROBIN + load_assignment: + cluster_name: httpbin + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: httpbin + port_value: 80 \ No newline at end of file diff --git a/plugins/wasm-go/extensions/log-request-response/go.mod b/plugins/wasm-go/extensions/log-request-response/go.mod new file mode 100644 index 000000000..bd98ee492 --- /dev/null +++ b/plugins/wasm-go/extensions/log-request-response/go.mod @@ -0,0 +1,21 @@ +module github.com/alibaba/higress/plugins/wasm-go/extensions/log-request-response + +go 1.19 + +replace github.com/alibaba/higress/plugins/wasm-go => ../.. + +require ( + github.com/alibaba/higress/plugins/wasm-go v0.0.0 + github.com/higress-group/proxy-wasm-go-sdk v1.0.0 + github.com/tidwall/gjson v1.17.3 + github.com/tidwall/sjson v1.2.5 +) + +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 + github.com/tidwall/resp v0.1.1 // indirect +) diff --git a/plugins/wasm-go/extensions/log-request-response/go.sum b/plugins/wasm-go/extensions/log-request-response/go.sum new file mode 100644 index 000000000..f76459d11 --- /dev/null +++ b/plugins/wasm-go/extensions/log-request-response/go.sum @@ -0,0 +1,23 @@ +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 v1.0.0 h1:BZRNf4R7jr9hwRivg/E29nkVaKEak5MWjBDhWjuHijU= +github.com/higress-group/proxy-wasm-go-sdk v1.0.0/go.mod h1:iiSyFbo+rAtbtGt/bsefv8GU57h9CCLYGJA74/tF5/0= +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.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +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= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/plugins/wasm-go/extensions/log-request-response/main.go b/plugins/wasm-go/extensions/log-request-response/main.go new file mode 100644 index 000000000..4bd218643 --- /dev/null +++ b/plugins/wasm-go/extensions/log-request-response/main.go @@ -0,0 +1,399 @@ +package main + +import ( + "encoding/json" + "strings" + + "github.com/alibaba/higress/plugins/wasm-go/pkg/log" + "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/sjson" +) + +// Constants for log keys in Filter State +const ( + pluginName = "log-request-response" + logKeyRequestHeaders = "log-request-headers" + logKeyRequestBody = "log-request-body" + logKeyResponseHeaders = "log-response-headers" + logKeyResponseBody = "log-response-body" +) + +// Constants for context keys +const ( + contextKeyRequestBodyBuffer = "request_body_buffer" + contextKeyResponseBodyBuffer = "response_body_buffer" +) + +// HTTP/2 header name mapping +var http2HeaderMap = map[string]string{ + ":authority": "authority", + ":method": "method", + ":path": "path", + ":scheme": "scheme", + ":status": "status", +} + +func main() { + wrapper.SetCtx( + // Plugin name + pluginName, + // Set custom function for parsing plugin configuration + wrapper.ParseConfig(parseConfig), + // Set custom function for processing request headers + wrapper.ProcessRequestHeaders(onHttpRequestHeaders), + // Set custom function for processing streaming request body + wrapper.ProcessStreamingRequestBody(onStreamingRequestBody), + // Set custom function for processing response headers + wrapper.ProcessResponseHeaders(onHttpResponseHeaders), + // Set custom function for processing streaming response body + wrapper.ProcessStreamingResponseBody(onStreamingResponseBody), + ) +} + +// PluginConfig Custom plugin configuration +type PluginConfig struct { + // Request configuration + Request struct { + // Headers configuration + Headers struct { + // Whether to enable request headers logging + Enabled bool + } + // Body configuration + Body struct { + // Whether to enable request body logging + Enabled bool + // Maximum size limit for logging (bytes) + MaxSize int + // Content types to be logged + ContentTypes []string + } + } + // Response configuration + Response struct { + // Headers configuration + Headers struct { + // Whether to enable response headers logging + Enabled bool + } + // Body configuration + Body struct { + // Whether to enable response body logging + Enabled bool + // Maximum size limit for logging (bytes) + MaxSize int + // Content types to be logged + ContentTypes []string + } + } +} + +// The YAML configuration filled in the console will be automatically converted to JSON, +// so we can directly parse the configuration from this JSON parameter +func parseConfig(json gjson.Result, config *PluginConfig) error { + // Parse request headers configuration + config.Request.Headers.Enabled = json.Get("request.headers.enabled").Bool() + + // Parse request body configuration + config.Request.Body.Enabled = json.Get("request.body.enabled").Bool() + config.Request.Body.MaxSize = int(json.Get("request.body.maxSize").Int()) + + // Set default maximum size for request body + if config.Request.Body.MaxSize <= 0 { + config.Request.Body.MaxSize = 10 * 1024 // Default 10KB + } + + // Parse request body content types + if contentTypes := json.Get("request.body.contentTypes").Array(); len(contentTypes) > 0 { + for _, ct := range contentTypes { + config.Request.Body.ContentTypes = append(config.Request.Body.ContentTypes, ct.String()) + } + } else { + // Default content types + config.Request.Body.ContentTypes = []string{ + "application/json", + "application/xml", + "application/x-www-form-urlencoded", + "text/plain", + } + } + + // Parse response headers configuration + config.Response.Headers.Enabled = json.Get("response.headers.enabled").Bool() + + // Parse response body configuration + config.Response.Body.Enabled = json.Get("response.body.enabled").Bool() + config.Response.Body.MaxSize = int(json.Get("response.body.maxSize").Int()) + + // Set default maximum size for response body + if config.Response.Body.MaxSize <= 0 { + config.Response.Body.MaxSize = 10 * 1024 // Default 10KB + } + + // Parse response body content types + if contentTypes := json.Get("response.body.contentTypes").Array(); len(contentTypes) > 0 { + for _, ct := range contentTypes { + config.Response.Body.ContentTypes = append(config.Response.Body.ContentTypes, ct.String()) + } + } else { + // Default content types + config.Response.Body.ContentTypes = []string{ + "application/json", + "application/xml", + "text/plain", + "text/html", + } + } + + return nil +} + +// normalizeHeaderName standardizes HTTP/2 header names by removing the colon prefix +// or mapping them to more standard names +func normalizeHeaderName(name string) string { + // If it's a known HTTP/2 header, map it to a standard name + if standardName, exists := http2HeaderMap[name]; exists { + return standardName + } + + // For other headers that might start with colon, just remove the colon + if strings.HasPrefix(name, ":") { + return name[1:] + } + + // Return the original name for regular headers + return name +} + +// processStreamingBody common function to process streaming body +func processStreamingBody( + ctx wrapper.HttpContext, + enabled bool, + maxSize int, + bufferKey string, + logKey string, + chunk []byte, + isEndStream bool, +) []byte { + // If body logging is not enabled or max size is <= 0, just return the chunk as is + if !enabled || maxSize <= 0 { + return chunk + } + + // Get the buffer from context + buffer, _ := ctx.GetContext(bufferKey).([]byte) + + // If we haven't reached max size yet, append chunk to buffer + if len(buffer) < maxSize { + // Calculate how much of this chunk we can add + remainingCapacity := maxSize - len(buffer) + if remainingCapacity > 0 { + if len(chunk) <= remainingCapacity { + buffer = append(buffer, chunk...) + ctx.SetContext(bufferKey, buffer) + } else { + buffer = append(buffer, chunk[:remainingCapacity]...) + // reach max size, record and clear + bodyStr := string(buffer) + setPropertyWithMarshal(logKey, bodyStr) + // clear buffer + ctx.SetContext(bufferKey, []byte{}) + } + } + } + + // When we reach the end of stream, create log entry + if isEndStream && len(buffer) > 0 { + bodyStr := string(buffer) + setPropertyWithMarshal(logKey, bodyStr) + // clear buffer + ctx.SetContext(bufferKey, []byte{}) + } + + // Always return the original chunk unmodified + return chunk +} + +// setPropertyWithMarshal marshals the given string value into a JSON-safe format +// and sets it as a property in the Envoy filter state with the specified key. +// This ensures proper escaping of special characters when the value is included in JSON. +func setPropertyWithMarshal(key string, value string) { + // Create a helper map to properly escape the string using JSON marshaling + helper := map[string]string{ + "placeholder": value, + } + + // Marshal the helper map to JSON + marshalledHelper, _ := json.Marshal(helper) + + // Extract the properly escaped value using gjson + marshalledRaw := gjson.GetBytes(marshalledHelper, "placeholder").Raw + + var marshalledStr string + if len(marshalledRaw) >= 2 { + // Remove the surrounding quotes from the JSON string + marshalledStr = marshalledRaw[1 : len(marshalledRaw)-1] + } else { + log.Errorf("failed to marshal json string, raw string is: %s", value) + marshalledStr = "" + } + + // Set the property with the marshaled string + if err := proxywasm.SetProperty([]string{key}, []byte(marshalledStr)); err != nil { + log.Errorf("failed to set %s in filter state, err: %v, raw:\n%s", key, err, value) + } +} + +// onHttpRequestHeaders processes the request headers and logs them if enabled +func onHttpRequestHeaders(ctx wrapper.HttpContext, config PluginConfig) types.Action { + // Get all request headers + headers, err := proxywasm.GetHttpRequestHeaders() + if err != nil { + log.Errorf("Failed to get request headers: %v", err) + return types.ActionContinue + } + + method := "" + contentType := "" + + // Check if request headers need to be logged + if config.Request.Headers.Enabled { + jsonStr := "{}" + for _, header := range headers { + var err error + normalizedName := normalizeHeaderName(header[0]) + jsonStr, err = sjson.Set(jsonStr, normalizedName, header[1]) + if err != nil { + log.Errorf("Failed to convert request header to JSON: name=%s, value=%s, error=%v", normalizedName, header[1], err) + } + } + + setPropertyWithMarshal(logKeyRequestHeaders, jsonStr) + } + + // Get request method and Content-Type for subsequent processing + for _, header := range headers { + if strings.ToLower(header[0]) == ":method" { + method = header[1] + } else if strings.ToLower(header[0]) == "content-type" { + contentType = header[1] + } + } + + // For non-POST/PUT/PATCH requests, or if request body logging is not enabled, no need to log the request body + if !config.Request.Body.Enabled || (method != "POST" && method != "PUT" && method != "PATCH") { + ctx.DontReadRequestBody() + return types.ActionContinue + } + + // Check if the content type is in the configured list for logging + shouldLogBody := false + for _, allowedType := range config.Request.Body.ContentTypes { + if strings.Contains(contentType, allowedType) { + shouldLogBody = true + break + } + } + + if !shouldLogBody { + ctx.DontReadRequestBody() + return types.ActionContinue + } + + // Initialize a buffer to accumulate request body chunks + ctx.SetContext(contextKeyRequestBodyBuffer, []byte{}) + + return types.ActionContinue +} + +// onStreamingRequestBody processes each chunk of the request body in streaming mode +// This allows us to log the request body without affecting the original request +func onStreamingRequestBody(ctx wrapper.HttpContext, config PluginConfig, chunk []byte, isEndStream bool) []byte { + return processStreamingBody( + ctx, + config.Request.Body.Enabled, + config.Request.Body.MaxSize, + contextKeyRequestBodyBuffer, + logKeyRequestBody, + chunk, + isEndStream, + ) +} + +// onHttpResponseHeaders processes the response headers and logs them if enabled +func onHttpResponseHeaders(ctx wrapper.HttpContext, config PluginConfig) types.Action { + // Get all response headers + headers, err := proxywasm.GetHttpResponseHeaders() + if err != nil { + log.Errorf("Failed to get response headers: %v", err) + return types.ActionContinue + } + + // Check if response headers need to be logged + if config.Response.Headers.Enabled { + jsonStr := "{}" + for _, header := range headers { + var err error + normalizedName := normalizeHeaderName(header[0]) + jsonStr, err = sjson.Set(jsonStr, normalizedName, header[1]) + if err != nil { + log.Errorf("Failed to convert response header to JSON: name=%s, value=%s, error=%v", normalizedName, header[1], err) + } + } + + setPropertyWithMarshal(logKeyResponseHeaders, jsonStr) + } + + // Check if response body needs to be logged + if !config.Response.Body.Enabled { + ctx.DontReadResponseBody() + return types.ActionContinue + } + + // Check Content-Type for response body logging + contentType := "" + for _, header := range headers { + if strings.ToLower(header[0]) == "content-type" { + contentType = header[1] + break + } + } + + // Skip response body logging if content type is not in the configured list + if contentType != "" { + shouldLogBody := false + for _, allowedType := range config.Response.Body.ContentTypes { + if strings.Contains(contentType, allowedType) { + shouldLogBody = true + break + } + } + + if !shouldLogBody { + ctx.DontReadResponseBody() + return types.ActionContinue + } + } + + // Initialize a buffer to accumulate response body chunks + ctx.SetContext(contextKeyResponseBodyBuffer, []byte{}) + + return types.ActionContinue +} + +// onStreamingResponseBody processes each chunk of the response body in streaming mode +// This allows us to log the response body without affecting the original response +func onStreamingResponseBody(ctx wrapper.HttpContext, config PluginConfig, chunk []byte, isEndStream bool) []byte { + return processStreamingBody( + ctx, + config.Response.Body.Enabled, + config.Response.Body.MaxSize, + contextKeyResponseBodyBuffer, + logKeyResponseBody, + chunk, + isEndStream, + ) +}