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, ) }