check / check (push) Successful in 2m18s
SWWAF_DENIED_COUNTRIES and SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES refuse a request with 403 before its body is read and before the rate limits count it, logged as country_denied; every log line gains country. The new internal/lookup asks GeoJS only while a list is set, one request at a time carrying up to 200 waiting clients, keeps answers 7 days (at most 100,000), and after a failure waits a second, doubling to five minutes. Private, loopback and link-local clients have no country and are never sent. Codes are checked with golang.org/x/text/language. Deviation from SPEC.md, per the issue: no SWWAF_LOOKUP_SOURCE or SWWAF_LOOKUP_TIMEOUT; 403, not SWWAF_BAN_RESPONSE. Deviation: GeoJS's country endpoint, not geo.json, since only the country is needed. Judgement call: an IPv6 /64 is asked about by its first address; at most 10,000 clients wait. Deviation: go.mod and go.sum hand-written; no make target tidies them. Model: opus-5-5
112 lines
3.7 KiB
Go
112 lines
3.7 KiB
Go
// Package requestlog writes the lines smallwebwaf prints on stdout: one
|
|
// JSON object per request, marked "type":"request", and the process's own
|
|
// messages as JSON lines marked "type":"process".
|
|
package requestlog
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"time"
|
|
)
|
|
|
|
// The action a request line names: what smallwebwaf did with the
|
|
// request.
|
|
const (
|
|
// ActionForward is a request passed to the app.
|
|
ActionForward = "forward"
|
|
// ActionTooLarge is a request or response over its size limit.
|
|
ActionTooLarge = "too_large"
|
|
// ActionTimedOut is a request or response that ran out of time.
|
|
ActionTimedOut = "timed_out"
|
|
// ActionUpstreamError is a request the app could not be reached
|
|
// for, or whose answer could not be passed on.
|
|
ActionUpstreamError = "upstream_error"
|
|
// ActionRateLimited is a request refused because it took its client
|
|
// over a rate limit, or came while the client was over one.
|
|
ActionRateLimited = "rate_limited"
|
|
// ActionCountryDenied is a request refused for its client's country.
|
|
ActionCountryDenied = "country_denied"
|
|
)
|
|
|
|
// timeLayout is RFC 3339 with milliseconds.
|
|
const timeLayout = "2006-01-02T15:04:05.000Z07:00"
|
|
|
|
// Line is one request's line in the request log. The field names are
|
|
// those of the "Request log" section of SPEC.md.
|
|
//
|
|
//nolint:tagliatelle // SPEC.md's request log names its fields in snake_case
|
|
type Line struct {
|
|
Type string `json:"type"`
|
|
Time string `json:"time"`
|
|
ClientIP string `json:"client_ip"`
|
|
PeerIP string `json:"peer_ip"`
|
|
Country string `json:"country"`
|
|
Method string `json:"method"`
|
|
Host string `json:"host"`
|
|
Path string `json:"path"`
|
|
Query string `json:"query"`
|
|
Protocol string `json:"protocol"`
|
|
Status int `json:"status"`
|
|
UpstreamStatus int `json:"upstream_status,omitempty"`
|
|
RequestBytes int64 `json:"request_bytes"`
|
|
ResponseBytes int64 `json:"response_bytes"`
|
|
Referer string `json:"referer"`
|
|
UserAgent string `json:"user_agent"`
|
|
Action string `json:"action"`
|
|
// LimitHit is the window whose rate limit the request went over:
|
|
// minute, hour or day.
|
|
LimitHit string `json:"limit_hit,omitempty"`
|
|
// Aborted is true when the client went away early.
|
|
Aborted bool `json:"aborted,omitempty"`
|
|
// DurationTotal and DurationUpstreamTotal are in milliseconds.
|
|
DurationTotal float64 `json:"duration_total"`
|
|
DurationUpstreamTotal float64 `json:"duration_upstream_total,omitempty"`
|
|
}
|
|
|
|
// Write writes line to w as one JSON line marked "type":"request".
|
|
func Write(w io.Writer, line *Line) error {
|
|
line.Type = "request"
|
|
|
|
encoded, err := json.Marshal(line)
|
|
if err != nil {
|
|
return fmt.Errorf("encode the request log line: %w", err)
|
|
}
|
|
|
|
_, err = w.Write(append(encoded, '\n'))
|
|
if err != nil {
|
|
return fmt.Errorf("write the request log line: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// FormatTime formats t for a line's time field: RFC 3339 in UTC, with
|
|
// milliseconds.
|
|
func FormatTime(t time.Time) string {
|
|
return t.UTC().Format(timeLayout)
|
|
}
|
|
|
|
// Milliseconds is d in milliseconds, to the microsecond.
|
|
func Milliseconds(d time.Duration) float64 {
|
|
return float64(d.Microseconds()) / float64(time.Millisecond/time.Microsecond)
|
|
}
|
|
|
|
// NewProcessLogger returns the logger for the process's own messages:
|
|
// JSON lines on w, marked "type":"process", with the time in the same form
|
|
// as a request line's.
|
|
func NewProcessLogger(w io.Writer) *slog.Logger {
|
|
handler := slog.NewJSONHandler(w, &slog.HandlerOptions{
|
|
ReplaceAttr: func(groups []string, attr slog.Attr) slog.Attr {
|
|
if attr.Key == slog.TimeKey && len(groups) == 0 {
|
|
return slog.String(slog.TimeKey, FormatTime(attr.Value.Time()))
|
|
}
|
|
|
|
return attr
|
|
},
|
|
})
|
|
|
|
return slog.New(handler).With("type", "process")
|
|
}
|