Export the reviewed application-neutral package set through the exact public allowlist. Development history and private application evidence remain outside this canonical source root. Developed with material AI assistance under maintainer review. Signed-off-by: Cole Speelman <crspeelman@gmail.com>
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
// Package requestlog records bounded, versioned HTTP request observations.
|
||||
package requestlog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"gamertan.com/web/requestmeta"
|
||||
)
|
||||
|
||||
const RecordVersion = 1
|
||||
|
||||
// Record is deliberately stable and append-log friendly. Sensitive fields are
|
||||
// populated only when explicitly enabled by Policy.
|
||||
type Record struct {
|
||||
Version int `json:"version"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
Method string `json:"method"`
|
||||
Route string `json:"route"`
|
||||
Status int `json:"status"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
DurationMicros int64 `json:"duration_micros"`
|
||||
ClientIP string `json:"client_ip,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Query string `json:"query,omitempty"`
|
||||
Referer string `json:"referer,omitempty"`
|
||||
UserAgent string `json:"user_agent,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
}
|
||||
|
||||
// Validate rejects records that cannot have been produced by this package's
|
||||
// bounded middleware contract.
|
||||
func (record Record) Validate() error {
|
||||
if record.Version != RecordVersion || record.Timestamp.IsZero() || !boundedField(record.Method, 16, false) || !boundedField(record.Route, 256, false) || record.Status < 100 || record.Status > 999 || record.Bytes < 0 || record.DurationMicros < 0 {
|
||||
return errors.New("requestlog: invalid record")
|
||||
}
|
||||
fields := []struct {
|
||||
value string
|
||||
limit int
|
||||
}{
|
||||
{record.RequestID, 64}, {record.ClientIP, 64}, {record.Path, 2048},
|
||||
{record.Query, 4096}, {record.Referer, 2048}, {record.UserAgent, 1024},
|
||||
{record.SessionID, 256},
|
||||
}
|
||||
for _, field := range fields {
|
||||
if !boundedField(field.value, field.limit, true) {
|
||||
return errors.New("requestlog: invalid record")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Sink receives complete records after a handler returns.
|
||||
type Sink interface {
|
||||
WriteRecord(context.Context, Record) error
|
||||
}
|
||||
|
||||
// SensitiveFields must be opted into field by field.
|
||||
type SensitiveFields struct {
|
||||
ClientIP bool
|
||||
Path bool
|
||||
Query bool
|
||||
Referer bool
|
||||
UserAgent bool
|
||||
SessionID bool
|
||||
}
|
||||
|
||||
// Policy controls classification and collection. Route must return a low-cardinality
|
||||
// route label; nil produces "unclassified" rather than recording a raw path.
|
||||
type Policy struct {
|
||||
Route func(*http.Request) string
|
||||
SessionID func(*http.Request) string
|
||||
Sensitive SensitiveFields
|
||||
OnSinkError func(error)
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
func Middleware(sink Sink, policy Policy) func(http.Handler) http.Handler {
|
||||
if policy.Now == nil {
|
||||
policy.Now = time.Now
|
||||
}
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
started := policy.Now()
|
||||
capture := &responseCapture{ResponseWriter: response, status: http.StatusOK}
|
||||
defer func() {
|
||||
recovered := recover()
|
||||
if recovered != nil && !capture.wroteHeader {
|
||||
capture.status = http.StatusInternalServerError
|
||||
}
|
||||
record := makeRecord(request, capture, policy, started, policy.Now())
|
||||
if sink != nil {
|
||||
if err := sink.WriteRecord(context.WithoutCancel(request.Context()), record); err != nil && policy.OnSinkError != nil {
|
||||
policy.OnSinkError(errors.New("requestlog: sink write failed"))
|
||||
}
|
||||
}
|
||||
if recovered != nil {
|
||||
panic(recovered)
|
||||
}
|
||||
}()
|
||||
next.ServeHTTP(capture, request)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func makeRecord(request *http.Request, capture *responseCapture, policy Policy, started, finished time.Time) Record {
|
||||
route := "unclassified"
|
||||
if policy.Route != nil {
|
||||
route = bounded(policy.Route(request), 256)
|
||||
if route == "" {
|
||||
route = "unclassified"
|
||||
}
|
||||
}
|
||||
duration := finished.Sub(started).Microseconds()
|
||||
if duration < 0 {
|
||||
duration = 0
|
||||
}
|
||||
record := Record{Version: RecordVersion, Timestamp: finished.UTC(), Method: bounded(request.Method, 16), Route: route, Status: capture.status, Bytes: capture.bytes, DurationMicros: duration}
|
||||
if metadata, ok := requestmeta.FromContext(request.Context()); ok {
|
||||
record.RequestID = metadata.RequestID
|
||||
if policy.Sensitive.ClientIP && metadata.ClientIP.IsValid() {
|
||||
record.ClientIP = metadata.ClientIP.String()
|
||||
}
|
||||
}
|
||||
if policy.Sensitive.Path {
|
||||
record.Path = bounded(request.URL.EscapedPath(), 2048)
|
||||
}
|
||||
if policy.Sensitive.Query {
|
||||
record.Query = bounded(request.URL.RawQuery, 4096)
|
||||
}
|
||||
if policy.Sensitive.Referer {
|
||||
record.Referer = bounded(request.Referer(), 2048)
|
||||
}
|
||||
if policy.Sensitive.UserAgent {
|
||||
record.UserAgent = bounded(request.UserAgent(), 1024)
|
||||
}
|
||||
if policy.Sensitive.SessionID && policy.SessionID != nil {
|
||||
record.SessionID = bounded(policy.SessionID(request), 256)
|
||||
}
|
||||
return record
|
||||
}
|
||||
|
||||
func boundedField(value string, limit int, emptyOK bool) bool {
|
||||
if (!emptyOK && value == "") || len(value) > limit || !utf8.ValidString(value) {
|
||||
return false
|
||||
}
|
||||
for _, character := range value {
|
||||
if character == 0 || character == '\r' || character == '\n' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func bounded(value string, limit int) string {
|
||||
value = strings.ToValidUTF8(value, "�")
|
||||
value = strings.Map(func(r rune) rune {
|
||||
if r == 0 || r == '\r' || r == '\n' {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, value)
|
||||
if len(value) <= limit {
|
||||
return value
|
||||
}
|
||||
value = value[:limit]
|
||||
for !utf8.ValidString(value) {
|
||||
value = value[:len(value)-1]
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
type responseCapture struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
bytes int64
|
||||
wroteHeader bool
|
||||
}
|
||||
|
||||
func (capture *responseCapture) WriteHeader(status int) {
|
||||
if capture.wroteHeader {
|
||||
return
|
||||
}
|
||||
capture.wroteHeader = true
|
||||
capture.status = status
|
||||
capture.ResponseWriter.WriteHeader(status)
|
||||
}
|
||||
|
||||
func (capture *responseCapture) Write(body []byte) (int, error) {
|
||||
if !capture.wroteHeader {
|
||||
capture.WriteHeader(http.StatusOK)
|
||||
}
|
||||
written, err := capture.ResponseWriter.Write(body)
|
||||
capture.bytes += int64(written)
|
||||
return written, err
|
||||
}
|
||||
|
||||
func (capture *responseCapture) Unwrap() http.ResponseWriter { return capture.ResponseWriter }
|
||||
Reference in New Issue
Block a user